mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
feat(ui): add dedicated BYO apps page
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
649a008dd7
commit
e6a7728087
6 files changed
+116
-82
No files matched your search
@@ -249,6 +249,7 @@ describe("Apps routes", () => {
|
||||
expect(appSource).toContain('<Route path="apps" element={<Browse />} />');
|
||||
expect(appSource).toContain('<Route path="apps/browse" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).toContain('<Route path="apps/connections" element={<Connections />} />');
|
||||
expect(appSource).toContain('<Route path="apps/byo" element={<AppsConnect byoOnly />} />');
|
||||
expect(appSource).toContain('<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).toContain('<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).toContain('<Route path="apps/advanced/gateways" element={<GatewaysList />} />');
|
||||
|
||||
@@ -135,6 +135,7 @@ function boardRoutes() {
|
||||
<Route path="apps" element={<Browse />} />
|
||||
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connections" element={<Connections />} />
|
||||
<Route path="apps/byo" element={<AppsConnect byoOnly />} />
|
||||
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
|
||||
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
|
||||
|
||||
@@ -173,13 +173,13 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render(queryClient?: QueryClient) {
|
||||
async function render(queryClient?: QueryClient, byoOnly = false) {
|
||||
const root = createRoot(container);
|
||||
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AppsConnect />
|
||||
<AppsConnect byoOnly={byoOnly} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
@@ -188,6 +188,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
return root;
|
||||
}
|
||||
|
||||
it("shows only the paste-first connection choices on the BYO page", async () => {
|
||||
await render(undefined, true);
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Connect your own MCP server");
|
||||
expect(text).toContain("More ways to connect");
|
||||
expect(text).toContain("Run your own");
|
||||
expect(text).toContain("Paste a config");
|
||||
expect(text).not.toContain("Search apps…");
|
||||
expect(text).not.toContain("Pick the app you want your agents to use.");
|
||||
expect(text).not.toContain("Zapier");
|
||||
|
||||
const urlInput = container.querySelector<HTMLInputElement>('input[aria-label="MCP server URL"]');
|
||||
expect(urlInput).toBeTruthy();
|
||||
expect(document.activeElement).toBe(urlInput);
|
||||
});
|
||||
|
||||
it("an unrecognized URL routes to a frame with the URL, defaulted Name, and a Yes/No toggle", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://www.example.com/actions");
|
||||
|
||||
@@ -150,7 +150,7 @@ function reusableOAuthConnection(
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
export function AppsConnect() {
|
||||
export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
||||
const navigate = useNavigate();
|
||||
const routeParams = useParams<{ appKey?: string }>();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
@@ -163,6 +163,7 @@ export function AppsConnect() {
|
||||
const directOAuthSource = isMcpDirectOAuthConnectSlug(sourceSlug) ? sourceSlug : null;
|
||||
const requestedAppKey = appKey ?? directOAuthSource ?? undefined;
|
||||
const zapierSource = sourceSlug === "zapier";
|
||||
const byo = byoOnly || searchParams.get("byo") === "1";
|
||||
|
||||
// Prefill arrives from the app page for reconnects; read once so later
|
||||
// wizard navigation doesn't fight the URL.
|
||||
@@ -270,17 +271,17 @@ export function AppsConnect() {
|
||||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setStep("gallery");
|
||||
navigate("/apps/connect?byo=1");
|
||||
navigate(byoOnly ? "/apps/byo" : "/apps/connect?byo=1");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Connect an app" },
|
||||
{ label: byoOnly ? "Connect your own tool" : "Connect an app" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
}, [byoOnly, setBreadcrumbs, selectedCompany?.name]);
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||||
@@ -687,30 +688,33 @@ export function AppsConnect() {
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
{step !== "success" && (
|
||||
<StepHeader
|
||||
subtitle={
|
||||
step === "gallery"
|
||||
? "Pick the app you want your agents to use."
|
||||
: `Step ${stepIndex + 1} of ${stepLabels.length}`
|
||||
}
|
||||
step={step}
|
||||
activeIndex={stepIndex}
|
||||
labels={stepLabels}
|
||||
appIdentity={
|
||||
zapierSource
|
||||
? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null }
|
||||
: undefined
|
||||
}
|
||||
unverifiedHost={!entry && !zapierSource && step !== "gallery" ? endpointHost(linkUrl) : null}
|
||||
onCancel={() => navigate("/apps")}
|
||||
/>
|
||||
!(byoOnly && step === "gallery") && (
|
||||
<StepHeader
|
||||
subtitle={
|
||||
step === "gallery"
|
||||
? "Pick the app you want your agents to use."
|
||||
: `Step ${stepIndex + 1} of ${stepLabels.length}`
|
||||
}
|
||||
step={step}
|
||||
activeIndex={stepIndex}
|
||||
labels={stepLabels}
|
||||
appIdentity={
|
||||
zapierSource
|
||||
? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null }
|
||||
: undefined
|
||||
}
|
||||
unverifiedHost={!entry && !zapierSource && step !== "gallery" ? endpointHost(linkUrl) : null}
|
||||
onCancel={() => navigate("/apps")}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{step === "gallery" && (
|
||||
<GalleryStep
|
||||
loading={galleryQuery.isLoading}
|
||||
apps={galleryQuery.data?.apps ?? []}
|
||||
byo={searchParams.get("byo") === "1"}
|
||||
byo={byo}
|
||||
byoOnly={byoOnly}
|
||||
source={searchParams.get("source")}
|
||||
onPick={useMatchedGalleryEntry}
|
||||
onUseLink={(url) => {
|
||||
@@ -1093,6 +1097,7 @@ function GalleryStep({
|
||||
loading,
|
||||
apps,
|
||||
byo = false,
|
||||
byoOnly = false,
|
||||
source = null,
|
||||
onPick,
|
||||
onUseLink,
|
||||
@@ -1103,6 +1108,8 @@ function GalleryStep({
|
||||
apps: AppDefinition[];
|
||||
/** Entered via the "Connect your own MCP server" card (PAP-12371, Finding C): focus the link path. */
|
||||
byo?: boolean;
|
||||
/** Canonical BYO page: keep the URL path and alternate methods, without the app gallery. */
|
||||
byoOnly?: boolean;
|
||||
source?: string | null;
|
||||
onPick: (entry: AppDefinition) => void;
|
||||
onUseLink: (link: string) => void;
|
||||
@@ -1114,14 +1121,17 @@ function GalleryStep({
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const linkSectionRef = useRef<HTMLDivElement>(null);
|
||||
const linkInputRef = useRef<HTMLInputElement>(null);
|
||||
const linkInputSelectedRef = useRef(false);
|
||||
|
||||
// Arriving from the BYO card: scroll the "Connect with a link" section into
|
||||
// view and focus its input so the paste-URL path is the obvious next step.
|
||||
// Arriving from the BYO card: scroll the URL section into view and select its
|
||||
// input so the operator can paste immediately.
|
||||
useEffect(() => {
|
||||
if (!byo || loading) return;
|
||||
linkSectionRef.current?.scrollIntoView({ block: "center" });
|
||||
if (!byo || (loading && !byoOnly) || linkInputSelectedRef.current) return;
|
||||
if (!byoOnly) linkSectionRef.current?.scrollIntoView?.({ block: "center" });
|
||||
linkInputRef.current?.focus();
|
||||
}, [byo, loading]);
|
||||
linkInputRef.current?.select();
|
||||
linkInputSelectedRef.current = true;
|
||||
}, [byo, byoOnly, loading]);
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return apps;
|
||||
@@ -1141,7 +1151,7 @@ function GalleryStep({
|
||||
onUseLink(next);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (loading && !byoOnly) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
@@ -1153,58 +1163,62 @@ function GalleryStep({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search apps…"
|
||||
className="h-11 pl-9"
|
||||
/>
|
||||
</div>
|
||||
{!byoOnly && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search apps…"
|
||||
className="h-11 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{filtered.map((app) => {
|
||||
const copy = appCopyFor(app.slug, app.description);
|
||||
const methods = getAvailableConnectionMethods(app);
|
||||
const oauth = methods[0]?.auth === "oauth";
|
||||
const oauthBlocked = methods.length === 1 && oauth && !isMcpDirectOAuthConnectSlug(app.slug);
|
||||
const unavailable = app.availability?.available === false;
|
||||
return (
|
||||
<button
|
||||
key={app.slug}
|
||||
type="button"
|
||||
disabled={oauthBlocked || unavailable}
|
||||
title={
|
||||
unavailable
|
||||
? `${app.name} isn't configured on this instance yet. Ask your Paperclip admin.`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onPick(app)}
|
||||
className={cn(
|
||||
"flex flex-col rounded-xl border border-border bg-card p-4 text-left transition-colors",
|
||||
oauthBlocked || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<AppLogo name={app.name} logoUrl={app.branding.logoUrl} size={36} />
|
||||
<div className="mt-3 text-sm font-bold text-foreground">{app.name}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{copy.tagline}</div>
|
||||
<div className="mt-3 text-xs font-semibold text-foreground">
|
||||
{unavailable ? (
|
||||
<span className="text-muted-foreground">Not available on this instance - ask your admin.</span>
|
||||
) : oauthBlocked ? (
|
||||
<span className="text-muted-foreground">Sign-in coming soon</span>
|
||||
) : (
|
||||
<span>Connect →</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{filtered.map((app) => {
|
||||
const copy = appCopyFor(app.slug, app.description);
|
||||
const methods = getAvailableConnectionMethods(app);
|
||||
const oauth = methods[0]?.auth === "oauth";
|
||||
const oauthBlocked = methods.length === 1 && oauth && !isMcpDirectOAuthConnectSlug(app.slug);
|
||||
const unavailable = app.availability?.available === false;
|
||||
return (
|
||||
<button
|
||||
key={app.slug}
|
||||
type="button"
|
||||
disabled={oauthBlocked || unavailable}
|
||||
title={
|
||||
unavailable
|
||||
? `${app.name} isn't configured on this instance yet. Ask your Paperclip admin.`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onPick(app)}
|
||||
className={cn(
|
||||
"flex flex-col rounded-xl border border-border bg-card p-4 text-left transition-colors",
|
||||
oauthBlocked || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<AppLogo name={app.name} logoUrl={app.branding.logoUrl} size={36} />
|
||||
<div className="mt-3 text-sm font-bold text-foreground">{app.name}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{copy.tagline}</div>
|
||||
<div className="mt-3 text-xs font-semibold text-foreground">
|
||||
{unavailable ? (
|
||||
<span className="text-muted-foreground">Not available on this instance - ask your admin.</span>
|
||||
) : oauthBlocked ? (
|
||||
<span className="text-muted-foreground">Sign-in coming soon</span>
|
||||
) : (
|
||||
<span>Connect →</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">No apps match “{search}”.</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">No apps match “{search}”.</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
@@ -1265,6 +1279,7 @@ function GalleryStep({
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={linkInputRef}
|
||||
aria-label="MCP server URL"
|
||||
value={linkInput}
|
||||
onChange={(e) => {
|
||||
setLinkInput(e.target.value);
|
||||
|
||||
@@ -178,7 +178,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
||||
await act(async () => {
|
||||
byoCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1");
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/byo");
|
||||
});
|
||||
|
||||
it("filters the gallery by the search query", async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { appSourceConnectHref } from "./app-connect-policy";
|
||||
export const POPULAR_KEYS = ["zapier", "github", "slack", "notion", "posthog", "linear"];
|
||||
|
||||
/** Deep-link into the Connect wizard's bring-your-own-tool URL flow. */
|
||||
export const BYO_CONNECT_HREF = "/apps/connect?byo=1";
|
||||
export const BYO_CONNECT_HREF = "/apps/byo";
|
||||
|
||||
/** Zapier connects with the complete MCP URL issued by Zapier. */
|
||||
export const ZAPIER_CONNECT_HREF = "/apps/connect?byo=1&source=zapier";
|
||||
|
||||
Reference in new issue
Block a user