diff --git a/.changeset/connect-picker-browsable.md b/.changeset/connect-picker-browsable.md new file mode 100644 index 000000000..f09520110 --- /dev/null +++ b/.changeset/connect-picker-browsable.md @@ -0,0 +1,7 @@ +--- +"@executor-js/react": patch +--- + +**The connect picker is browsable instead of an 85-row scroll box** + +Every preset every plugin ships was listed flat through a 224px window, and two providers contributed half those rows as bare service names ("Users", "Directory", "Profile"). Providers with more than one service now browse as a single card that opens into its services — 85 rows become 39 cards — with the curated `featured` presets leading. Searching ungroups, so "outlook" returns the Outlook services rather than the Microsoft card hiding them, and protocol facets (All, OpenAPI, MCP, GraphQL) count the cards each reveals. The dialog is wider and two columns. Adding your own spec by hand sits under the catalog as "Not in the library? Add your own" with one button per format, rather than repeating "Add" and a plus icon across all three. Closing it now unmounts it, so a detection you walked away from no longer leaves its error waiting in the next open — or, once it finally answers, navigates you to that URL's add flow. diff --git a/e2e/scenarios/connect-dialog-abandoned-detection.test.ts b/e2e/scenarios/connect-dialog-abandoned-detection.test.ts new file mode 100644 index 000000000..ad42e6ab1 --- /dev/null +++ b/e2e/scenarios/connect-dialog-abandoned-detection.test.ts @@ -0,0 +1,97 @@ +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { clickToReveal, visit } from "../src/surfaces/browser"; + +const DETECT_ROUTE = "**/integrations/detect"; + +const DETECTED_OPENAPI = JSON.stringify([ + { + kind: "openapi", + confidence: "high", + endpoint: "https://example.com/openapi.json", + name: "Example", + slug: "example", + }, +]); + +// The connect dialog owns in-flight work: pasting a URL asks the server to +// detect what it is. Closing the dialog is the user withdrawing that question, +// so the answer has to land nowhere — not as an error banner waiting in the +// next open, and above all not as a navigation that moves the app under them. +scenario( + "Connect dialog · a detection the user walked away from lands nowhere", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); + const connect = page.getByRole("button", { name: "Connect" }); + const search = () => dialog.getByPlaceholder(/Search or paste a URL/); + const catalog = () => dialog.getByRole("button", { name: /^Google\b.*services$/s }); + const detectError = dialog.getByText(/Detection failed|Could not detect/); + + /** Paste a URL, start detecting, and abandon the dialog mid-flight. + * Resolves the held request with `body` and waits for it to land. */ + const abandonDetection = async (status: number, body: string) => { + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route(DETECT_ROUTE, async (route) => { + await held; + await route.fulfill({ status, contentType: "application/json", body }); + }); + + await search().fill("https://example.com/openapi.json"); + await dialog.getByRole("button", { name: "Detect" }).click(); + await dialog.getByRole("button", { name: "Detecting..." }).waitFor(); + + await page.keyboard.press("Escape"); + await dialog.waitFor({ state: "hidden" }); + + const answered = page.waitForResponse(DETECT_ROUTE); + release(); + await answered; + await page.unroute(DETECT_ROUTE); + }; + + await step("Open the connect picker", async () => { + await visit(page, "/integrations"); + await clickToReveal(connect, dialog); + await catalog().waitFor(); + }); + + await step("Abandon a detection, then let it fail", async () => { + await abandonDetection(500, JSON.stringify({ _tag: "InternalError" })); + }); + + await step("Reopening offers a clean dialog, not the abandoned failure", async () => { + await clickToReveal(connect, dialog); + await catalog().waitFor(); + expect(await detectError.count(), "the abandoned failure is not waiting here").toBe(0); + expect(await search().inputValue()).toBe(""); + }); + + await step("Abandon a second detection, then let it succeed", async () => { + await abandonDetection(200, DETECTED_OPENAPI); + }); + + await step("The successful answer does not steer the app to an add flow", async () => { + expect(page.url(), "a withdrawn detection must not navigate").not.toMatch( + /\/integrations\/add\//, + ); + // Reopening is the settle point: if the abandoned detection had steered + // the app, this page (and its Connect button) would already be gone. + await clickToReveal(connect, dialog); + await catalog().waitFor(); + expect(page.url()).not.toMatch(/\/integrations\/add\//); + }); + }); + }), +); diff --git a/e2e/scenarios/connect-integration-picker.test.ts b/e2e/scenarios/connect-integration-picker.test.ts new file mode 100644 index 000000000..ad34f7fec --- /dev/null +++ b/e2e/scenarios/connect-integration-picker.test.ts @@ -0,0 +1,147 @@ +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import type { Locator, Page } from "playwright"; + +import { Browser, Target } from "../src/services"; +import { clickToReveal, visit } from "../src/surfaces/browser"; + +// The picker holds ~85 presets, and two providers contribute roughly half of +// them as bare service names. Browsing has to collapse those; searching has to +// uncollapse them again, or the search hands back the card it was looking past. +scenario( + "Connect picker · providers collapse while browsing and open up on search", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); + const search = () => dialog.getByPlaceholder(/Search or paste a URL/); + const googleCard = () => dialog.getByRole("button", { name: /^Google\b.*services$/s }); + const allFacet = () => dialog.getByRole("button", { name: /^All\s+\d+$/ }); + + await step("Open the connect picker", async () => { + await visit(page, "/integrations"); + await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); + }); + + await step("A multi-service provider browses as one card, not its services", async () => { + await googleCard().waitFor(); + expect(await googleCard().innerText()).toMatch(/\d+ services/); + // The services behind the card stay behind it. + expect(await dialog.getByRole("link", { name: /^Gmail\b/ }).count()).toBe(0); + }); + + await step("Opening the provider card reveals its services", async () => { + await googleCard().click(); + await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor(); + await dialog.getByRole("link", { name: /^Google Drive\b/ }).waitFor(); + // Inside a provider the protocol facets would advertise catalog-wide + // counts over a list that isn't the catalog, so they stand down. + expect(await allFacet().count()).toBe(0); + }); + + await step("Going back returns to the browsable catalog", async () => { + await dialog.getByRole("button", { name: /All integrations/ }).click(); + await googleCard().waitFor(); + await allFacet().waitFor(); + expect(await dialog.getByRole("link", { name: /^Gmail\b/ }).count()).toBe(0); + }); + + await step("Searching returns the services themselves, not the provider card", async () => { + await search().fill("outlook"); + await dialog.getByRole("link", { name: /^Outlook Mail\b/ }).waitFor(); + await dialog.getByRole("link", { name: /^Outlook Calendar\b/ }).waitFor(); + expect(await dialog.getByRole("button", { name: /^Microsoft\b.*services$/s }).count()).toBe( + 0, + ); + }); + + await step("A protocol filter narrows the catalog to that protocol", async () => { + await search().fill(""); + await dialog.getByRole("button", { name: /^MCP\s+\d+$/ }).click(); + await dialog.getByRole("link", { name: /^Context7\b/ }).waitFor(); + // Figma is OpenAPI-only, so the MCP facet must not offer it. + expect(await dialog.getByRole("link", { name: /^Figma\b/ }).count()).toBe(0); + }); + + // "Add OpenAPI / Add MCP / Add GraphQL" spent a verb and a plus icon on + // each of three buttons for one idea. The lead-in carries the verb once + // and the buttons carry only the format they add. The plus is gone too: + // these open a form, they do not create anything inline. + await step("The manual add path spells out the verb once", async () => { + await dialog.getByText(/Not in the library\? Add your own/).waitFor(); + for (const format of ["OpenAPI", "MCP", "GraphQL"]) { + await dialog.getByRole("link", { name: format, exact: true }).waitFor(); + } + expect( + await dialog.getByRole("link", { name: /^Add (OpenAPI|MCP|GraphQL)$/ }).count(), + "the verb is not repeated per button", + ).toBe(0); + expect( + await dialog + .getByRole("link", { name: /^(OpenAPI|MCP|GraphQL)$/ }) + .evaluateAll((links) => links.filter((link) => link.querySelector("svg")).length), + "the manual add links carry no icon", + ).toBe(0); + }); + + await step("On a phone the filters and the add path stay on one row", async () => { + await page.setViewportSize({ width: 390, height: 844 }); + const facetTops = await dialog + .getByRole("button", { name: /^(All|OpenAPI|MCP|GraphQL)\s+\d+$/ }) + .evaluateAll((chips) => chips.map((chip) => chip.getBoundingClientRect().top)); + expect(facetTops.length).toBeGreaterThan(1); + expect(new Set(facetTops).size, "the facets scroll sideways, they do not wrap").toBe(1); + + // Three protocol buttons would wrap into a second row down here, so + // they collapse into one menu that opens the same links. + await dialog.getByRole("button", { name: "Add manually" }).waitFor(); + expect(await dialog.getByRole("link", { name: "OpenAPI", exact: true }).isVisible()).toBe( + false, + ); + await dialog.getByRole("button", { name: "Add manually" }).click(); + await page.getByRole("menuitem", { name: "GraphQL", exact: true }).waitFor(); + + // Touch guidelines (WCAG 2.5.5, Apple, Material) put a thumb target at + // 44px; the defaults here land at 32 and the dialog's close at 16. + const undersized = (scope: Locator | Page, selector: string) => + scope.locator(selector).evaluateAll((els) => + els + .map((el) => ({ + label: (el.textContent ?? "").trim().slice(0, 24), + box: el.getBoundingClientRect(), + })) + .filter((t) => t.box.width > 0 && (t.box.height < 44 || t.box.width < 44)) + .map( + (t) => + `${t.label || "(icon)"} ${Math.round(t.box.width)}x${Math.round(t.box.height)}`, + ), + ); + expect(await undersized(page, "[role=menuitem]"), "menu items are thumb-sized").toEqual([]); + await page.keyboard.press("Escape"); + await page + .getByRole("menuitem", { name: "GraphQL", exact: true }) + .waitFor({ state: "detached" }); + expect( + await undersized(dialog, "a[href], button, input"), + "every control in the picker is thumb-sized", + ).toEqual([]); + }); + + await step("Picking a service opens its add flow with the preset applied", async () => { + await page.setViewportSize({ width: 1280, height: 800 }); + await allFacet().click(); + await search().fill("gmail"); + await dialog.getByRole("link", { name: /^Gmail\b/ }).click(); + await page.waitForURL(/\/integrations\/add\/openapi/); + await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); + expect(new URL(page.url()).searchParams.get("preset")).toBe("google-gmail"); + }); + }); + }), +); diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 2b68f57ad..567aa5a4b 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -42,6 +42,9 @@ export interface AnalyticsEvents { via: "detect" | "manual" | "preset" | "command_palette"; preset_id?: string; }; + /** A multi-service provider card was opened in the connect dialog. `family` + * is a curated catalog value (e.g. "google"), never user-entered text. */ + integration_connect_dialog_family_opened: { family: string }; integration_added: { plugin_key: string; integration_slug?: string }; integration_add_cancelled: { plugin_key: string }; integration_removed: { integration_slug: string; success: boolean }; diff --git a/packages/react/src/components/dialog.tsx b/packages/react/src/components/dialog.tsx index 51020ea2d..d557da318 100644 --- a/packages/react/src/components/dialog.tsx +++ b/packages/react/src/components/dialog.tsx @@ -83,7 +83,7 @@ function DialogContent({ {showCloseButton && ( Close diff --git a/packages/react/src/components/filter-tabs.tsx b/packages/react/src/components/filter-tabs.tsx index b6f9114e0..3bf76f278 100644 --- a/packages/react/src/components/filter-tabs.tsx +++ b/packages/react/src/components/filter-tabs.tsx @@ -14,15 +14,19 @@ interface FilterTabsProps { tabs: FilterTab[]; value: T; onChange: (value: T) => void; + /** For callers that need the row to behave differently when it runs out of + * width — e.g. scroll instead of wrap in a narrow dialog. */ + className?: string; } export function FilterTabs({ tabs, value, onChange, + className, }: FilterTabsProps) { return ( -
+
{tabs.map((tab) => { const isActive = value === tab.value; return ( @@ -32,7 +36,9 @@ export function FilterTabs({ key={tab.value} onClick={() => onChange(tab.value)} className={cn( - "inline-flex items-center justify-center gap-1.5 rounded-full px-2.5 py-1 text-sm font-medium shadow-none transition-transform duration-100 active:scale-[0.98]", + // 32px is a fine mouse target and a poor thumb one, so phones get the + // 44px the touch guidelines ask for. + "inline-flex min-h-11 items-center justify-center gap-1.5 rounded-full px-2.5 py-1 text-sm font-medium shadow-none transition-transform duration-100 active:scale-[0.98] sm:min-h-0", isActive ? "border-border bg-background text-foreground" : "border-transparent bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground", diff --git a/packages/react/src/components/integration-favicon.tsx b/packages/react/src/components/integration-favicon.tsx index cfe12df43..98fd0c82e 100644 --- a/packages/react/src/components/integration-favicon.tsx +++ b/packages/react/src/components/integration-favicon.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import type { IntegrationPlugin } from "@executor-js/sdk/client"; import { getDomain } from "tldts"; +import { pluginKeyForIntegrationKind } from "../lib/integration-plugin-keys"; + // --------------------------------------------------------------------------- // IntegrationFavicon — renders a small favicon derived from an integration URL. // Falls back to a neutral icon if the URL is missing or the image fails to load. @@ -28,13 +30,6 @@ export function integrationLocalIconUrl(integrationId: string | undefined): stri return "/favicon-32.png"; } -const KIND_TO_PLUGIN_KEY: Record = { - openapi: "openapi", - mcp: "mcp", - graphql: "graphql", - googleDiscovery: "google", -}; - const normalizeUrl = (url: string | undefined): string | null => { if (!url) return null; try { @@ -104,7 +99,7 @@ export function integrationPresetIconUrl( }, integrationPlugins: readonly IntegrationPlugin[], ): string | null { - const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind; + const pluginKey = pluginKeyForIntegrationKind(integration.kind); const plugin = integrationPlugins.find((p) => p.key === pluginKey); const presets = plugin?.presets ?? []; const exactSlugIcon = presets.find((p) => p.defaultSlug === integration.id)?.icon; diff --git a/packages/react/src/lib/integration-grouping.ts b/packages/react/src/lib/integration-grouping.ts index 1f0bd05d1..0c5817b5c 100644 --- a/packages/react/src/lib/integration-grouping.ts +++ b/packages/react/src/lib/integration-grouping.ts @@ -10,11 +10,17 @@ const FAMILY_LABELS: Record = { export const familyLabel = (family: string): string => FAMILY_LABELS[family] ?? family.charAt(0).toUpperCase() + family.slice(1); -export const integrationFamily = (integration: Integration): string | null => { - const family = integration.family?.trim(); - return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null; +/** The curated family a value names, or `null` when it isn't one we group. + * The connect picker and the integrations grid ask this of different shapes — + * a preset and a stored integration — so the rule itself lives in one place. */ +export const curatedFamily = (family: string | undefined): string | null => { + const trimmed = family?.trim(); + return trimmed && MULTI_SERVICE_FAMILIES.has(trimmed) ? trimmed : null; }; +export const integrationFamily = (integration: Integration): string | null => + curatedFamily(integration.family); + export interface IntegrationFamilyGroup { readonly type: "group"; readonly family: string; diff --git a/packages/react/src/lib/integration-plugin-keys.ts b/packages/react/src/lib/integration-plugin-keys.ts new file mode 100644 index 000000000..71365b05c --- /dev/null +++ b/packages/react/src/lib/integration-plugin-keys.ts @@ -0,0 +1,13 @@ +// An integration's stored `kind` mostly matches the plugin key that owns its +// add/edit surfaces, except where a provider ships under a protocol plugin +// (Google Discovery specs are served by the OpenAPI plugin's Google provider). +// The picker, the grid, and the favicon resolver all need the same answer. +const KIND_TO_PLUGIN_KEY: Record = { + openapi: "openapi", + mcp: "mcp", + graphql: "graphql", + googleDiscovery: "google", +}; + +export const pluginKeyForIntegrationKind = (kind: string): string => + KIND_TO_PLUGIN_KEY[kind] ?? kind; diff --git a/packages/react/src/lib/preset-catalog.test.ts b/packages/react/src/lib/preset-catalog.test.ts new file mode 100644 index 000000000..ca019776e --- /dev/null +++ b/packages/react/src/lib/preset-catalog.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + familyDrillDownItems, + presetCatalogItems, + filterPresetEntries, + groupPresetEntriesByFamily, + presetCatalogEntries, + presetTypeFacets, + type PresetSourcePlugin, +} from "./preset-catalog"; + +// A realistic slice of the shipped catalog: OpenAPI carries both standalone +// presets and the two multi-service provider families, MCP repeats some of the +// same vendors under a different protocol, GraphQL contributes one. +const plugins: readonly PresetSourcePlugin[] = [ + { + key: "openapi", + label: "OpenAPI", + presets: [ + { id: "stripe", name: "Stripe", summary: "Payments, subscriptions, and invoices." }, + { + id: "google-gmail", + name: "Gmail", + summary: "Read and send mail.", + family: "google", + featured: true, + }, + { id: "google-drive", name: "Google Drive", summary: "Files and folders.", family: "google" }, + { id: "google-chat", name: "Google Chat", summary: "Spaces and messages.", family: "google" }, + { id: "microsoft-mail", name: "Outlook Mail", summary: "Mail.", family: "microsoft" }, + { + id: "microsoft-calendar", + name: "Outlook Calendar", + summary: "Events.", + family: "microsoft", + }, + { id: "microsoft-users", name: "Users", summary: "Directory users.", family: "microsoft" }, + ], + }, + { + key: "mcp", + label: "MCP", + presets: [ + { id: "linear-mcp", name: "Linear", summary: "Issues and projects.", featured: true }, + { id: "stripe-mcp", name: "Stripe", summary: "Payments over MCP." }, + ], + }, + { + key: "graphql", + label: "GraphQL", + presets: [{ id: "anilist", name: "AniList", summary: "Anime and manga." }], + }, +]; + +const entries = presetCatalogEntries(plugins); + +const titles = (items: ReturnType): readonly string[] => + items.map((item) => (item.type === "family" ? item.label : item.entry.preset.name)); + +describe("preset catalog", () => { + it("collapses a multi-service provider into one card and leaves standalone presets alone", () => { + const items = groupPresetEntriesByFamily(entries); + + // Ten presets, but a browsable six cards: Google and Microsoft each + // collapse to one, in the position of their first member. (Raw grouping + // keeps catalog order; `presetCatalogItems` is what re-sorts for display.) + expect(titles(items)).toEqual(["Stripe", "Google", "Microsoft", "Linear", "Stripe", "AniList"]); + + const google = items.find((item) => item.type === "family" && item.family === "google"); + expect(google?.type === "family" && google.members.length).toBe(3); + }); + + it("only collapses the curated families, not any provider that sets `family`", () => { + // `MULTI_SERVICE_FAMILIES` is the one rule for "browses as a provider card", + // shared with the connected-integrations grid. A plugin that tags presets + // with a family nobody curated lists them as themselves rather than + // inventing a card the rest of the app won't group behind. + const uncurated = presetCatalogEntries([ + { + key: "openapi", + label: "OpenAPI", + presets: [ + { id: "acme-billing", name: "Acme Billing", summary: "Invoices.", family: "acme" }, + { id: "acme-crm", name: "Acme CRM", summary: "Contacts.", family: "acme" }, + ], + }, + ]); + + expect(titles(groupPresetEntriesByFamily(uncurated))).toEqual(["Acme Billing", "Acme CRM"]); + }); + + it("keeps a family with a single service as an ordinary card", () => { + const solo = presetCatalogEntries([ + { + key: "openapi", + label: "OpenAPI", + presets: [{ id: "google-gmail", name: "Gmail", summary: "Mail.", family: "google" }], + }, + ]); + + expect(titles(groupPresetEntriesByFamily(solo))).toEqual(["Gmail"]); + }); + + it("searches inside families so buried services surface as themselves", () => { + // "Outlook Mail" is one of 26 Microsoft services — invisible behind the + // family card until searched for. Matching siblings must NOT re-collapse + // into that same card, or the search returns you to where you started. + expect(titles(presetCatalogItems(entries, { query: "outlook" }))).toEqual([ + "Outlook Mail", + "Outlook Calendar", + ]); + }); + + it("floats curated favourites to the front, provider cards included", () => { + // Linear (MCP) is flagged featured, and Google's services are too, so both + // lead — otherwise the two providers hiding 6 of the 10 presets sort to + // wherever their plugin happened to be registered. + expect(titles(presetCatalogItems(entries, {}))).toEqual([ + "Google", + "Linear", + "Stripe", + "Microsoft", + "Stripe", + "AniList", + ]); + }); + + it("groups providers while browsing and ungroups them while searching", () => { + expect(titles(presetCatalogItems(entries, { query: "google" }))).toEqual([ + "Gmail", + "Google Drive", + "Google Chat", + ]); + }); + + it("matches the summary and the provider name, not just the preset name", () => { + expect(titles(presetCatalogItems(entries, { query: "payments" }))).toEqual([ + "Stripe", + "Stripe", + ]); + + // Typing the provider name finds its services even though no preset is + // literally called "Google". + const google = filterPresetEntries(entries, { query: "google" }); + expect(google.map((entry) => entry.preset.name)).toEqual([ + "Gmail", + "Google Drive", + "Google Chat", + ]); + }); + + it("narrows to one protocol and counts the cards each protocol contributes", () => { + expect(presetTypeFacets(entries, "")).toEqual([ + { key: null, label: "All", count: 6 }, + { key: "openapi", label: "OpenAPI", count: 3 }, + { key: "mcp", label: "MCP", count: 2 }, + { key: "graphql", label: "GraphQL", count: 1 }, + ]); + + const mcpOnly = filterPresetEntries(entries, { pluginKey: "mcp" }); + expect(mcpOnly.map((entry) => entry.preset.name)).toEqual(["Linear", "Stripe"]); + }); + + it("composes search with the protocol filter and recounts the facets", () => { + const stripeMcp = filterPresetEntries(entries, { query: "stripe", pluginKey: "mcp" }); + expect(stripeMcp.map((entry) => entry.pluginKey)).toEqual(["mcp"]); + + // Facet counts follow the query so a protocol that can't serve it reads 0. + expect(presetTypeFacets(entries, "outlook")).toEqual([ + { key: null, label: "All", count: 2 }, + { key: "openapi", label: "OpenAPI", count: 2 }, + { key: "mcp", label: "MCP", count: 0 }, + { key: "graphql", label: "GraphQL", count: 0 }, + ]); + }); + + it("drills into a family and lists only that provider's services", () => { + // Every member browses as itself: the card you opened is the one thing the + // drill-down must not show you again. + expect(titles(familyDrillDownItems(entries, "google", null))).toEqual([ + "Gmail", + "Google Drive", + "Google Chat", + ]); + expect(familyDrillDownItems(entries, "nope", null)).toEqual([]); + }); + + it("keeps the protocol filter while drilled in, even where a provider spans two", () => { + // No shipped family mixes protocols yet, but the facet chips stay live + // inside the drill-down, so a provider that gained an MCP service must + // still narrow to the chip the user is holding. + const mixed = presetCatalogEntries([ + { + key: "openapi", + label: "OpenAPI", + presets: [ + { id: "google-gmail", name: "Gmail", summary: "Mail.", family: "google" }, + { id: "google-drive", name: "Google Drive", summary: "Files.", family: "google" }, + ], + }, + { + key: "mcp", + label: "MCP", + presets: [ + { id: "google-chat-mcp", name: "Google Chat", summary: "Spaces.", family: "google" }, + ], + }, + ]); + + expect(titles(familyDrillDownItems(mixed, "google", "openapi"))).toEqual([ + "Gmail", + "Google Drive", + ]); + expect(titles(familyDrillDownItems(mixed, "google", "mcp"))).toEqual(["Google Chat"]); + expect(titles(familyDrillDownItems(mixed, "google", null))).toEqual([ + "Gmail", + "Google Drive", + "Google Chat", + ]); + }); +}); diff --git a/packages/react/src/lib/preset-catalog.ts b/packages/react/src/lib/preset-catalog.ts new file mode 100644 index 000000000..a3d634b4e --- /dev/null +++ b/packages/react/src/lib/preset-catalog.ts @@ -0,0 +1,197 @@ +import type { IntegrationPreset } from "@executor-js/sdk/client"; + +import { curatedFamily, familyLabel } from "./integration-grouping"; + +// --------------------------------------------------------------------------- +// The connect picker's browsable catalog. +// +// Plugins contribute a flat preset list each, which adds up to ~85 entries — +// half of them services of two providers ("Users", "Directory", "Profile" mean +// nothing on their own). Browsing wants those collapsed per provider; searching +// wants them flat, because someone typing "outlook" is looking for the service, +// not the provider card hiding it. +// +// Everything here is pure so the picker's behavior is testable without a DOM. +// --------------------------------------------------------------------------- + +/** The slice of `IntegrationPlugin` the catalog reads. */ +export interface PresetSourcePlugin { + readonly key: string; + readonly label: string; + readonly presets?: readonly IntegrationPreset[]; +} + +export interface PresetEntry { + readonly preset: IntegrationPreset; + readonly pluginKey: string; + readonly pluginLabel: string; +} + +export interface PresetFamilyCard { + readonly type: "family"; + readonly family: string; + readonly label: string; + readonly members: readonly PresetEntry[]; +} + +export interface PresetSingleCard { + readonly type: "single"; + readonly entry: PresetEntry; +} + +export type PresetCatalogItem = PresetFamilyCard | PresetSingleCard; + +export interface PresetTypeFacet { + /** `null` is the "All" facet. */ + readonly key: string | null; + readonly label: string; + /** Cards this protocol contributes for the active query. */ + readonly count: number; +} + +export interface PresetFilter { + readonly query?: string; + /** Plugin key, or `null`/absent for every protocol. */ + readonly pluginKey?: string | null; +} + +/** Flatten every plugin's presets, keeping the curated order plugins ship. */ +export const presetCatalogEntries = ( + plugins: readonly PresetSourcePlugin[], +): readonly PresetEntry[] => + plugins.flatMap((plugin) => + (plugin.presets ?? []).map((preset) => ({ + preset, + pluginKey: plugin.key, + pluginLabel: plugin.label, + })), + ); + +/** The searchable text for one entry: what it is, what it does, whose it is, + * and how it connects. */ +const searchCorpus = (entry: PresetEntry): string => { + const { preset } = entry; + const family = preset.family ? `${preset.family} ${familyLabel(preset.family)}` : ""; + return `${preset.name} ${preset.summary} ${family} ${preset.specFormat ?? ""} ${entry.pluginLabel}`.toLowerCase(); +}; + +export const filterPresetEntries = ( + entries: readonly PresetEntry[], + filter: PresetFilter, +): readonly PresetEntry[] => { + const query = (filter.query ?? "").trim().toLowerCase(); + const pluginKey = filter.pluginKey ?? null; + + return entries.filter((entry) => { + if (pluginKey !== null && entry.pluginKey !== pluginKey) return false; + return query.length === 0 || searchCorpus(entry).includes(query); + }); +}; + +/** Collapse each curated provider with more than one service into a single + * card, in the position of its first member. A family of one browses better as + * itself, and a family the app doesn't group elsewhere isn't grouped here. */ +export const groupPresetEntriesByFamily = ( + entries: readonly PresetEntry[], +): readonly PresetCatalogItem[] => { + const counts = new Map(); + for (const entry of entries) { + const family = curatedFamily(entry.preset.family); + if (family) counts.set(family, (counts.get(family) ?? 0) + 1); + } + + const items: PresetCatalogItem[] = []; + const indexByFamily = new Map(); + + for (const entry of entries) { + const family = curatedFamily(entry.preset.family); + if (!family || (counts.get(family) ?? 0) < 2) { + items.push({ type: "single", entry }); + continue; + } + + const at = indexByFamily.get(family); + if (at === undefined) { + indexByFamily.set(family, items.length); + items.push({ type: "family", family, label: familyLabel(family), members: [entry] }); + } else { + const card = items[at] as PresetFamilyCard; + items[at] = { ...card, members: [...card.members, entry] }; + } + } + + return items; +}; + +const isFeaturedCard = (item: PresetCatalogItem): boolean => + item.type === "family" + ? item.members.some((member) => member.preset.featured === true) + : item.entry.preset.featured === true; + +/** Curated favourites first, everything else in catalog order. Plugins are + * registered in an order nobody chose for browsing, so without this the two + * provider cards standing in for half the library sort into the middle. */ +const featuredFirst = (items: readonly PresetCatalogItem[]): readonly PresetCatalogItem[] => [ + ...items.filter(isFeaturedCard), + ...items.filter((item) => !isFeaturedCard(item)), +]; + +/** What the picker shows for the current search and protocol filter. + * + * Browsing groups a provider's services behind one card. Searching does NOT: + * someone who typed "outlook" has already told us they want the service, and + * re-collapsing the matches into the Microsoft card they were trying to look + * past hands back the same haystack. */ +export const presetCatalogItems = ( + entries: readonly PresetEntry[], + filter: PresetFilter, +): readonly PresetCatalogItem[] => { + const matching = filterPresetEntries(entries, filter); + const searching = (filter.query ?? "").trim().length > 0; + // Search results keep relevance-neutral catalog order: the query already + // ranked them, and reshuffling by "featured" fights what was typed. + return searching + ? matching.map((entry) => ({ type: "single", entry })) + : featuredFirst(groupPresetEntriesByFamily(matching)); +}; + +/** "All" plus one facet per protocol, each counting the CARDS it contributes + * for the active query — the number of results the chip actually reveals. */ +export const presetTypeFacets = ( + entries: readonly PresetEntry[], + query: string, +): readonly PresetTypeFacet[] => { + const labels = new Map(); + for (const entry of entries) { + if (!labels.has(entry.pluginKey)) labels.set(entry.pluginKey, entry.pluginLabel); + } + + const cardCount = (pluginKey: string | null): number => + presetCatalogItems(entries, { query, pluginKey }).length; + + return [ + { key: null, label: "All", count: cardCount(null) }, + ...[...labels].map(([key, label]) => ({ key, label, count: cardCount(key) })), + ]; +}; + +/** The services behind one provider card, for the drill-down view. */ +const familyMemberEntries = ( + entries: readonly PresetEntry[], + family: string, +): readonly PresetEntry[] => entries.filter((entry) => entry.preset.family === family); + +/** What the picker shows once a provider card is open: its services, each as + * itself, narrowed by the protocol chip still on screen. + * + * There is no query here on purpose. Typing exits the drill-down, because a + * search scoped to the open provider would quietly hide the rest of the + * catalog from someone who asked it a question. */ +export const familyDrillDownItems = ( + entries: readonly PresetEntry[], + family: string, + pluginKey: string | null, +): readonly PresetCatalogItem[] => + familyMemberEntries(entries, family) + .filter((entry) => pluginKey === null || entry.pluginKey === pluginKey) + .map((entry) => ({ type: "single", entry })); diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index f1780b75c..38da4e5a6 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -1,15 +1,11 @@ -import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; -import { PlusIcon } from "lucide-react"; +import { ArrowLeftIcon, PlusIcon, SearchIcon } from "lucide-react"; import type { Integration, IntegrationDetectionResult } from "@executor-js/sdk/shared"; -import { - useIntegrationPlugins, - type IntegrationPlugin, - type IntegrationPreset, -} from "@executor-js/sdk/client"; +import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/client"; import { detectIntegration, integrationsOptimisticAtom } from "../api/atoms"; import { trackEvent } from "../api/analytics"; import { McpInstallCard } from "../components/mcp-install-card"; @@ -17,6 +13,13 @@ import { Button } from "../components/button"; import { PageContainer, PageHeader } from "../components/page"; import { Badge } from "../components/badge"; import { Input } from "../components/input"; +import { FilterTabs } from "../components/filter-tabs"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../components/dropdown-menu"; import { Dialog, DialogContent, @@ -31,7 +34,6 @@ import { CardStackEntryActions, CardStackEntryContent, CardStackEntryDescription, - CardStackEntryMedia, CardStackEntryTitle, CardStackHeader, } from "../components/card-stack"; @@ -40,31 +42,25 @@ import { integrationInferredUrl, integrationPresetIconUrl, } from "../components/integration-favicon"; -import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping"; +import { + familyLabel, + groupIntegrations, + type IntegrationFamilyGroup, +} from "../lib/integration-grouping"; import { IntegrationHealthSummary } from "../components/integration-health-summary"; import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; import { Skeleton } from "../components/skeleton"; import { useExecutorDocumentTitle } from "../lib/document-title"; import { ErrorState } from "../components/error-state"; import { isAsyncResultLoading } from "../lib/async-result"; - -const KIND_TO_PLUGIN_KEY: Record = { - openapi: "openapi", - mcp: "mcp", - graphql: "graphql", - googleDiscovery: "google", -}; - -const detectionRank: Record = { - high: 3, - medium: 2, - low: 1, -}; - -const bestDetection = ( - results: readonly IntegrationDetectionResult[], -): IntegrationDetectionResult | undefined => - [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; +import { pluginKeyForIntegrationKind } from "../lib/integration-plugin-keys"; +import { + familyDrillDownItems, + presetCatalogEntries, + presetCatalogItems, + presetTypeFacets, + type PresetEntry, +} from "../lib/preset-catalog"; // --------------------------------------------------------------------------- // Page @@ -131,17 +127,24 @@ export function IntegrationsPage() { }) )} - + ); } -// --------------------------------------------------------------------------- -// Connect dialog — URL detection + manual plugin chooser + presets -// --------------------------------------------------------------------------- +const detectionRank: Record = { + high: 3, + medium: 2, + low: 1, +}; + +const bestDetection = ( + results: readonly IntegrationDetectionResult[], +): IntegrationDetectionResult | undefined => + [...results].sort((a, b) => detectionRank[b.confidence] - detectionRank[a.confidence])[0]; // Heuristic: the input either looks like a URL (auto-detect) or a free-text -// search query (filter the preset list). Anything with a scheme, slash, or +// search query (filter the catalog). Anything with a scheme, slash, or // host-with-TLD is treated as a URL; everything else is search. const looksLikeUrl = (raw: string): boolean => { const v = raw.trim(); @@ -152,24 +155,107 @@ const looksLikeUrl = (raw: string): boolean => { return false; }; -function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { +/** The route a preset card links to: the plugin's add flow, pre-filled. */ +const presetLinkSearch = (entry: PresetEntry): Record => { + const search: Record = { preset: entry.preset.id }; + if (entry.preset.url) search.url = entry.preset.url; + return search; +}; + +const PresetIcon = (props: { src?: string; className: string }) => + props.src ? ( + + ) : ( + + + + ); + +// --------------------------------------------------------------------------- +// Connect dialog — search/detect, protocol facets, and a browsable catalog +// where multi-service providers collapse into one card you can open. +// --------------------------------------------------------------------------- + +/** `FilterTabs` needs a string per tab, and the "every protocol" tab is not a + * plugin — a key no plugin can hold keeps the two apart. */ +const ALL_PROTOCOLS = "__all__"; + +interface ConnectIntegrationDialogProps { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +} + +/** The connect dialog is self-contained: the search text, the protocol facet, + * the open provider card, and the in-flight URL detection all live in + * `ConnectIntegrationDialogView`, so closing genuinely unmounts them rather + * than hand-resetting a list that grows every time the dialog gains a control. + * The page owns only whether it is open. */ +function ConnectIntegrationDialog(props: ConnectIntegrationDialogProps) { + return props.open ? : null; +} + +function ConnectIntegrationDialogView(props: ConnectIntegrationDialogProps) { const integrationPlugins = useIntegrationPlugins(); const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" }); const navigate = useNavigate(); const [query, setQuery] = useState(""); + const [pluginFilter, setPluginFilter] = useState(ALL_PROTOCOLS); + const [openFamily, setOpenFamily] = useState(null); const [detecting, setDetecting] = useState(false); const [error, setError] = useState(null); const isUrl = looksLikeUrl(query); const presetSearch = isUrl ? "" : query; - const closeAndReset = useCallback(() => { - setQuery(""); - setError(null); - setDetecting(false); - props.onOpenChange(false); - }, [props]); + const entries = useMemo(() => presetCatalogEntries(integrationPlugins), [integrationPlugins]); + const facets = useMemo(() => presetTypeFacets(entries, presetSearch), [entries, presetSearch]); + + // Browsing groups providers; opening one drills into its services. Searching + // or switching protocol leaves the drill-down, so a query always searches the + // whole catalog rather than silently scoping to the open provider. + const items = useMemo(() => { + const pluginKey = pluginFilter === ALL_PROTOCOLS ? null : pluginFilter; + return openFamily === null + ? presetCatalogItems(entries, { query: presetSearch, pluginKey }) + : familyDrillDownItems(entries, openFamily, pluginKey); + }, [entries, presetSearch, pluginFilter, openFamily]); + + const openFamilyLabel = openFamily === null ? null : familyLabel(openFamily); + + const resultsRef = useRef(null); + const scrollResultsToTop = () => resultsRef.current?.scrollTo({ top: 0 }); + + // Just ask the page to close. Reopening remounts this view (see + // `ConnectIntegrationDialog`), so there is nothing to hand-reset — the query, + // the facet, and the open provider die with this instance. + const closeDialog = useCallback(() => props.onOpenChange(false), [props]); + + // Unmounting cannot undo one thing: `handleDetect`'s continuation runs to + // completion whatever happens to this view, and it navigates. Closing the + // dialog withdraws the question, so the answer must land nowhere. + const detectionWanted = useRef(true); + useEffect( + () => () => { + detectionWanted.current = false; + }, + [], + ); + + /** One "add this protocol by hand" link, worn as a button on a wide dialog + * and as a menu item on a narrow one. */ + const manualAddLink = (plugin: IntegrationPlugin) => ( + { + trackEvent("integration_add_started", { plugin_key: plugin.key, via: "manual" }); + closeDialog(); + }} + > + {plugin.label} + + ); const handleDetect = useCallback(async () => { const trimmed = query.trim(); @@ -178,24 +264,15 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => setError(null); // Detection is read-only — it inspects a URL and returns candidates without // mutating the catalog, so it invalidates nothing. - const exit = await doDetect({ - payload: { url: trimmed }, - reactivityKeys: [], - }); + const exit = await doDetect({ payload: { url: trimmed }, reactivityKeys: [] }); + if (!detectionWanted.current) return; if (Exit.isFailure(exit)) { trackEvent("integration_detect_submitted", { success: false }); setError("Detection failed. Try adding an integration manually."); setDetecting(false); return; } - const results = exit.value; - if (results.length === 0) { - trackEvent("integration_detect_submitted", { success: false }); - setError("Could not detect an integration type from this URL. Try adding manually."); - setDetecting(false); - return; - } - const detected = bestDetection(results); + const detected = bestDetection(exit.value); if (!detected) { trackEvent("integration_detect_submitted", { success: false }); setError("Could not detect an integration type from this URL. Try adding manually."); @@ -207,10 +284,10 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => detected_kind: detected.kind, confidence: detected.confidence, }); - const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind] ?? detected.kind; + const pluginKey = pluginKeyForIntegrationKind(detected.kind); if (integrationPlugins.some((p) => p.key === pluginKey)) { trackEvent("integration_add_started", { plugin_key: pluginKey, via: "detect" }); - closeAndReset(); + closeDialog(); void navigate({ to: "/{-$orgSlug}/integrations/add/$pluginKey", params: { pluginKey }, @@ -220,75 +297,216 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => setError(`Detected integration type "${detected.kind}" but no plugin is available for it.`); setDetecting(false); } - }, [query, doDetect, navigate, integrationPlugins, closeAndReset]); + }, [query, doDetect, navigate, integrationPlugins, closeDialog]); return ( { - if (!open) closeAndReset(); - else props.onOpenChange(open); + if (!open) closeDialog(); }} > - + Connect an integration - - Search the preset library, or paste a URL to auto-detect. - + Search the library, or paste a URL to auto-detect. -
-
-
+
+
+
+ { setQuery((e.target as HTMLInputElement).value); + setOpenFamily(null); setError(null); + scrollResultsToTop(); }} onKeyDown={(e) => { if (e.key === "Enter" && isUrl) void handleDetect(); }} placeholder="Search or paste a URL…" disabled={detecting} - className="flex-1" + className="h-11 pl-9 sm:h-9" /> - {isUrl && ( - - )}
- {error &&

{error}

} + {isUrl && ( + + )}
+ {error &&

{error}

} +
-
-

Or add manually

-
- {integrationPlugins.map((p) => ( - { - trackEvent("integration_add_started", { plugin_key: p.key, via: "manual" }); - closeAndReset(); - }} - className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted" - > - {p.label} - - ))} -
+ {/* Inside a provider the facets would count the whole catalog over a + * list that isn't it, contradicting the "N services" line below. */} + {openFamily === null && ( + ({ + label: facet.label, + value: facet.key ?? ALL_PROTOCOLS, + count: facet.count, + }))} + value={pluginFilter} + onChange={(value) => { + setPluginFilter(value); + setOpenFamily(null); + scrollResultsToTop(); + }} + /> + )} + + {openFamilyLabel !== null && ( +
+ + {openFamilyLabel} + + {items.length} {items.length === 1 ? "service" : "services"} +
+ )} + + {/* A fixed height, not flex-1: sizing to the results made the dialog + * shrink as you filtered, walking the facet chips out from under the + * cursor that was clicking them. */} +
+ {items.length === 0 ? ( +
+

No matching integrations

+

+ Paste a URL above to auto-detect, or add one manually below. +

+
+ ) : ( +
+ {items.map((item) => + item.type === "family" ? ( + + ) : ( + { + trackEvent("integration_add_started", { + plugin_key: item.entry.pluginKey, + via: "preset", + preset_id: item.entry.preset.id, + }); + closeDialog(); + }} + className="flex items-center gap-3 bg-background px-4 py-3 transition-colors hover:bg-muted" + > + +
+

{item.entry.preset.name}

+

+ {item.entry.preset.summary} +

+
+ + {item.entry.pluginLabel} + + + ), + )} + {items.length % 2 === 1 &&
} +
+ )} +
- + {/* Pointing your own spec, server, or endpoint at Executor is a first- + * class way in, not a footnote to the library — so this reads as real + * actions. The lead-in carries the verb for all three, which is what + * keeps a bare "MCP" from reading as the "MCP 13" facet above; a verb + * and a plus on each button restated one idea three times. These link + * to a form rather than creating anything inline, so no plus earns its + * place here. Narrow enough and one row of them would wrap into two, + * so there the whole sentence collapses into a single menu instead. */} +
+

+ Not in the library? Add your own +

+
+ {integrationPlugins.map((plugin) => ( + + ))} +
+ + + + + + {integrationPlugins.map((plugin) => ( + + {manualAddLink(plugin)} + + ))} + +
@@ -317,115 +535,6 @@ function EmptyIntegrations(props: { onConnect: () => void }) { ); } -// --------------------------------------------------------------------------- -// Preset grid (for inside the Connect dialog) -// --------------------------------------------------------------------------- - -type PresetEntry = { - preset: IntegrationPreset; - pluginKey: string; - pluginLabel: string; -}; - -function PresetGrid(props: { - plugins: readonly IntegrationPlugin[]; - onPick: () => void; - /** Controlled filter query forwarded from the dialog's unified - * search/URL input. Empty string disables filtering. */ - searchQuery?: string; -}) { - const allPresets = useMemo(() => { - const entries: PresetEntry[] = []; - for (const plugin of props.plugins) { - for (const preset of plugin.presets ?? []) { - entries.push({ - preset, - pluginKey: plugin.key, - pluginLabel: plugin.label, - }); - } - } - return entries; - }, [props.plugins]); - - const filtered = useMemo(() => { - const q = (props.searchQuery ?? "").trim().toLowerCase(); - if (q.length === 0) return allPresets; - return allPresets.filter(({ preset, pluginLabel }) => { - const corpus = - `${preset.name} ${preset.summary ?? ""} ${preset.family ?? ""} ${preset.specFormat ?? ""} ${pluginLabel}`.toLowerCase(); - return corpus.includes(q); - }); - }, [allPresets, props.searchQuery]); - - if (allPresets.length === 0) return null; - - return ( -
-

Popular integrations

- - {/* Fixed height keeps the dialog stable as the user filters; the - * inner area scrolls when the list overflows and shows an empty - * state when no presets match. */} - - {filtered.length === 0 ? ( -
-

No matching presets

-

- Paste a URL above to auto-detect, or pick an integration type manually. -

-
- ) : ( - filtered.map(({ preset, pluginKey, pluginLabel }) => { - const search: Record = { preset: preset.id }; - if (preset.url) search.url = preset.url; - return ( - - { - trackEvent("integration_add_started", { - plugin_key: pluginKey, - via: "preset", - preset_id: preset.id, - }); - props.onPick(); - }} - > - - {preset.icon ? ( - - ) : ( - - - - )} - - - {preset.name} - {preset.summary} - - - {pluginLabel} - - - - ); - }) - )} -
-
-
- ); -} - // --------------------------------------------------------------------------- // Integration grid — flat list of catalog integrations, click-through to detail // --------------------------------------------------------------------------- @@ -441,7 +550,7 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { const items = useMemo(() => groupIntegrations(props.integrations), [props.integrations]); const renderEntry = (integration: Integration) => { - const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind; + const pluginKey = pluginKeyForIntegrationKind(integration.kind); const plugin = pluginByKind.get(pluginKey); const SummaryComponent = plugin?.summary; const slug = String(integration.slug);