diff --git a/e2e/scenarios/integrations-grid-grouping-ui.test.ts b/e2e/scenarios/integrations-grid-grouping-ui.test.ts new file mode 100644 index 000000000..1f0223e0e --- /dev/null +++ b/e2e/scenarios/integrations-grid-grouping-ui.test.ts @@ -0,0 +1,222 @@ +// Integrations grid grouping: a provider whose plugin fans out into several +// per-service integrations (Google -> Calendar, Gmail, Drive, plus a custom +// Discovery URL) collapses those siblings under ONE provider umbrella on the +// integrations list, while every non-family integration (the built-in +// "executor" source) stays flat. Each per-service row keeps its OWN product +// glyph, so the icons in the group are visibly distinct rather than one +// repeated Google logo. +// +// This drives the browser path end to end: +// 1. Fan out four Google integrations through the Google add flow (the same +// real Discovery add path the google-per-service spec exercises). +// 2. On /integrations, assert a single "Google" umbrella (data-testid +// `integration-group-google`) contains all four per-service entries. +// 3. Assert the non-family "executor" integration renders OUTSIDE any group +// umbrella. +// 4. Assert Calendar, Gmail, and Drive resolve to three DISTINCT icon srcs +// (per-service favicons), not one shared provider logo. +// +// OUTBOUND DISCOVERY: like google-per-service-add-ui, the add step fetches real +// Google Discovery documents (www.googleapis.com, read-only, no credentials). +// The grouping assertions themselves are pure DOM and touch no external state. +// +// Text lookups are scoped to getByRole("main"): selfhost shares a +// bootstrap-admin identity and the shell sidebar also lists these integrations, +// so a page-wide getByText would match the sidebar copy. +// +// SHARED-STATE ROBUSTNESS: on selfhost every scenario acts as the same +// bootstrap admin, so google-per-service-add-ui may already have created these +// integrations before this file runs (or vice versa, in either order). This +// spec first checks the grid, adds only what is missing (re-submitting an +// existing service is a harmless "skipped" row), and removes ONLY what it +// created in an `ensuring` finalizer so the google-per-service spec finds a +// clean slate if it runs after this file. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { clearCheckedPresets, setPresetChecked } from "./support/picker"; + +const coreApi = composePluginApi([] as const); + +scenario( + "Integrations · per-service integrations group under one provider umbrella with distinct icons", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client } = yield* Api; + const identity = yield* target.newIdentity(); + const api = yield* client(coreApi, identity); + const customDiscoveryUrl = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; + // Slugs this test creates (as opposed to found already existing); the + // finalizer removes exactly these so no state leaks into the shared + // selfhost instance, and pre-existing integrations are left alone. + const createdSlugs: string[] = []; + + const session = browser.session(identity, async ({ page, step }) => { + await step( + "Ensure the four Google integrations exist (Calendar, Gmail, Drive, custom)", + async () => { + // This step is about ensuring grid preconditions, not about the add + // path (google-per-service-add-ui owns that): it adds only the missing + // integrations and tolerates "skipped" rows. The real grouping/icon + // assertions live in the steps below. + // + // preset id -> integration slug (dashes become underscores). The + // custom Tasks Discovery URL derives its identity from the fetched + // Discovery doc: slug google_tasks, name "Google Tasks". + const presetToSlug: Record = { + "google-calendar": "google_calendar", + "google-gmail": "google_gmail", + "google-drive": "google_drive", + }; + const customSlug = "google_tasks"; + + await page.goto("/integrations", { waitUntil: "networkidle" }); + const existingMain = page.getByRole("main"); + const slugExists = async (slug: string): Promise => + (await existingMain.getByTestId(`integration-entry-${slug}`).count()) > 0; + + const missingPresetIds: string[] = []; + for (const [presetId, slug] of Object.entries(presetToSlug)) { + if (!(await slugExists(slug))) missingPresetIds.push(presetId); + } + const customMissing = !(await slugExists(customSlug)); + + // Everything the grid asserts on already exists: the add flow's submit + // would be disabled (custom slug collision), so skip it entirely and + // go straight to the grid. + if (missingPresetIds.length === 0 && !customMissing) return; + + // Record intent BEFORE submitting so a mid-add failure still gets its + // partial state cleaned up by the finalizer. + for (const presetId of missingPresetIds) createdSlugs.push(presetToSlug[presetId]!); + if (customMissing) createdSlugs.push(customSlug); + + await page.goto("/integrations/add/google", { + waitUntil: "domcontentloaded", + }); + await page.getByText("Customize your Google connection").waitFor(); + await page.getByTestId("preset-checkbox-google-calendar").waitFor(); + + // Clear the featured defaults, then select exactly the missing presets: + // submitting an already-existing preset is harmless ("skipped") but + // leaving defaults checked would create integrations this test never + // asserts on. + await clearCheckedPresets(page); + for (const presetId of missingPresetIds) { + await setPresetChecked(page, presetId, true); + } + + // Only add the custom Discovery URL when google_tasks is missing + // (re-adding would just produce a "skipped" row, but it is state this + // test would then wrongly clean up as its own). + if (customMissing) { + const customField = page.getByPlaceholder( + "https://www.googleapis.com/discovery/v1/apis///rest", + ); + await customField.fill(customDiscoveryUrl); + await customField.press("Enter"); + await page.getByText(customDiscoveryUrl).waitFor(); + } + + await page.getByTestId("google-add-submit").click(); + const results = page.getByTestId("google-add-results"); + await results.waitFor({ timeout: 120_000 }); + + // Each submitted entry resolves to a row that is either freshly + // "added" or "skipped" (raced with another writer) - both leave the + // integration present, which is all the grid needs. The custom Tasks + // URL reports under its derived slug. + const expectedRowIds = [...missingPresetIds, ...(customMissing ? ["google_tasks"] : [])]; + for (const rowId of expectedRowIds) { + const row = page.getByTestId(`add-result-row-${rowId}`); + await row.waitFor({ timeout: 120_000 }); + expect(["added", "skipped"], `${rowId} present (added or already existed)`).toContain( + await row.getAttribute("data-state"), + ); + } + }, + ); + + await step("The four Google integrations sit under one Google umbrella", async () => { + await page.goto("/integrations", { waitUntil: "networkidle" }); + const main = page.getByRole("main"); + + // Exactly one provider umbrella, and it is the Google one. + const group = main.getByTestId("integration-group-google"); + await group.waitFor({ timeout: 20_000 }); + expect(await main.getByTestId("integration-group-google").count()).toBe(1); + + // The umbrella header carries the provider name. + await group + .getByRole("button", { name: /Google/ }) + .first() + .waitFor({ state: "visible", timeout: 20_000 }); + + // Every per-service integration (including the custom one) is an entry + // INSIDE the Google umbrella, not a flat sibling. + for (const slug of ["google_calendar", "google_gmail", "google_drive", "google_tasks"]) { + const entry = group.getByTestId(`integration-entry-${slug}`); + await entry.waitFor({ timeout: 20_000 }); + expect(await group.getByTestId(`integration-entry-${slug}`).count(), slug).toBe(1); + } + }); + + await step("A non-family integration (executor) stays outside every group", async () => { + const main = page.getByRole("main"); + const executor = main.getByTestId("integration-entry-executor"); + await executor.waitFor({ timeout: 20_000 }); + + // The built-in executor source exists in the catalog... + expect(await main.getByTestId("integration-entry-executor").count()).toBe(1); + // ...but not nested within the Google provider umbrella. + const group = main.getByTestId("integration-group-google"); + expect( + await group.getByTestId("integration-entry-executor").count(), + "executor is not grouped under Google", + ).toBe(0); + }); + + await step("Per-service integrations render distinct product icons", async () => { + const group = page.getByRole("main").getByTestId("integration-group-google"); + const iconSrc = async (slug: string): Promise => + group.getByTestId(`integration-entry-${slug}`).locator("img").first().getAttribute("src"); + + const calendar = await iconSrc("google_calendar"); + const gmail = await iconSrc("google_gmail"); + const drive = await iconSrc("google_drive"); + + // Each service resolves to its own glyph URL (assert on src, not pixels). + expect(calendar, "calendar has an icon").toBeTruthy(); + expect(gmail, "gmail has an icon").toBeTruthy(); + expect(drive, "drive has an icon").toBeTruthy(); + expect( + new Set([calendar, gmail, drive]).size, + "the three per-service icons are all distinct, not one repeated provider logo", + ).toBe(3); + }); + }); + + yield* session.pipe( + Effect.ensuring( + // Remove ONLY the integrations this test created (never pre-existing + // ones): on selfhost the next scenario acts as the same admin, and a + // leaked google_tasks would show up as an unexpected "skipped" row in + // the google-per-service spec's results. + Effect.forEach( + createdSlugs, + (slug) => + api.integrations + .remove({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + { discard: true }, + ), + ), + ); + }), +); diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index 5377b6553..ec13e236f 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -197,6 +197,14 @@ export interface IntegrationPlugin { readonly accountHandoff?: IntegrationAccountHandoff | null; }>; readonly presets?: readonly IntegrationPreset[]; + /** Per-service icon overrides for provider plugins that fan out into several + * per-service integrations (e.g. Google → google_calendar, google_gmail). + * Keyed by the integration slug the fan-out registers; the catalog favicon + * resolver consults this before falling back to a URL-derived favicon, so a + * Gmail integration shows the Gmail glyph rather than a generic Google logo. + * Kept separate from `presets` so the connect picker's popular-integration + * list is not affected. */ + readonly serviceIcons?: readonly { readonly slug: string; readonly icon: string }[]; /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.). * Call from the host on intent (hover/focus) so the chunks land before the * user navigates into the add page. Idempotent. */ diff --git a/packages/plugins/google/src/react/source-plugin.ts b/packages/plugins/google/src/react/source-plugin.ts index 88df75282..d32008ae2 100644 --- a/packages/plugins/google/src/react/source-plugin.ts +++ b/packages/plugins/google/src/react/source-plugin.ts @@ -1,16 +1,28 @@ import { lazy } from "react"; import type { IntegrationPlugin } from "@executor-js/sdk/client"; -import { googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset } from "../sdk/presets"; +import { + googleOpenApiBundlePreset, + googleOpenApiPresets, + googlePhotosOpenApiBundlePreset, + googleServiceSlug, +} from "../sdk/presets"; const importAdd = () => import("./AddGoogleSource"); const importAccounts = () => import("./GoogleAccountsPanel"); +// Each per-service integration (google_calendar, google_gmail, …) is keyed by +// the slug the fan-out registers, mapped to the preset's own product glyph. +const googleServiceIcons = googleOpenApiPresets.flatMap((preset) => + preset.icon ? [{ slug: googleServiceSlug(preset.id), icon: preset.icon }] : [], +); + export const googleIntegrationPlugin: IntegrationPlugin = { key: "google", label: "Google", add: lazy(importAdd), accounts: lazy(importAccounts), presets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset], + serviceIcons: googleServiceIcons, preload: () => { void importAdd(); void importAccounts(); diff --git a/packages/plugins/microsoft/src/react/source-plugin.ts b/packages/plugins/microsoft/src/react/source-plugin.ts index cbfb4dfd1..45c4e51b9 100644 --- a/packages/plugins/microsoft/src/react/source-plugin.ts +++ b/packages/plugins/microsoft/src/react/source-plugin.ts @@ -1,16 +1,27 @@ import { lazy } from "react"; import type { IntegrationPlugin } from "@executor-js/sdk/client"; -import { microsoftGraphPreset } from "../sdk/presets"; +import { + microsoftGraphPreset, + microsoftGraphScopePresets, + microsoftServiceSlug, +} from "../sdk/presets"; const importAdd = () => import("./AddMicrosoftSource"); const importAccounts = () => import("./MicrosoftAccountsPanel"); +// Each per-workload integration (microsoft_mail, microsoft_calendar, …) is +// keyed by the slug the fan-out registers, mapped to the workload's own glyph. +const microsoftServiceIcons = microsoftGraphScopePresets.flatMap((preset) => + preset.icon ? [{ slug: microsoftServiceSlug(preset.id), icon: preset.icon }] : [], +); + export const microsoftIntegrationPlugin: IntegrationPlugin = { key: "microsoft", label: "Microsoft", add: lazy(importAdd), accounts: lazy(importAccounts), presets: [microsoftGraphPreset], + serviceIcons: microsoftServiceIcons, preload: () => { void importAdd(); void importAccounts(); diff --git a/packages/react/src/components/integration-favicon.test.tsx b/packages/react/src/components/integration-favicon.test.tsx index fc786d511..cf64d70c0 100644 --- a/packages/react/src/components/integration-favicon.test.tsx +++ b/packages/react/src/components/integration-favicon.test.tsx @@ -70,6 +70,68 @@ describe("IntegrationFavicon", () => { expect(integrationFaviconSrc({ icon, sourceId: slug, url, size: 16 })).toBe("/favicon-32.png"); }); + it("prefers a plugin's per-service icon (keyed by slug) over the preset cascade", () => { + // A provider that fans out into per-service integrations declares a + // slug->icon map; a Gmail integration resolves to the Gmail glyph even + // though every Google service shares the same displayUrl host. + expect( + integrationPresetIconUrl( + { + id: "google_gmail", + kind: "google", + name: "Gmail", + url: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", + }, + [ + { + key: "google", + label: "Google", + add: () => null, + edit: () => null, + presets: [ + { id: "google", name: "Google", summary: "Bundle.", icon: "https://x/g.svg" }, + ], + serviceIcons: [ + { slug: "google_gmail", icon: "https://x/gmail.png" }, + { slug: "google_calendar", icon: "https://x/calendar.svg" }, + ], + }, + ], + ), + ).toBe("https://x/gmail.png"); + }); + + it("falls through to the preset cascade when no per-service icon matches the slug", () => { + expect( + integrationPresetIconUrl( + { + id: "google_sheets", + kind: "google", + name: "Google Sheets", + url: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", + }, + [ + { + key: "google", + label: "Google", + add: () => null, + edit: () => null, + presets: [ + { + id: "google-sheets", + name: "Google Sheets", + summary: "Spreadsheets.", + url: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", + icon: "https://x/sheets.svg", + }, + ], + serviceIcons: [{ slug: "google_gmail", icon: "https://x/gmail.png" }], + }, + ], + ), + ).toBe("https://x/sheets.svg"); + }); + it("finds preset icons from a source URL", () => { expect( integrationPresetIconUrl( diff --git a/packages/react/src/components/integration-favicon.tsx b/packages/react/src/components/integration-favicon.tsx index 648303a6b..58612e24f 100644 --- a/packages/react/src/components/integration-favicon.tsx +++ b/packages/react/src/components/integration-favicon.tsx @@ -156,6 +156,14 @@ export function integrationPresetIconUrl( ): string | null { const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind; const plugin = integrationPlugins.find((p) => p.key === pluginKey); + + // Per-service integrations (google_calendar, microsoft_mail, …) carry their + // own product glyph, keyed by the exact slug the fan-out registered. This is + // the most specific match, so it wins over the URL/token preset cascade below + // (a provider's per-service integrations often share one displayUrl). + const serviceIcon = plugin?.serviceIcons?.find((entry) => entry.slug === source.id); + if (serviceIcon) return serviceIcon.icon; + const presets = plugin?.presets ?? []; const sourceUrl = normalizeUrl(source.url); const sourceGoogleService = googleApiServiceFromUrl(source.url); diff --git a/packages/react/src/lib/integration-grouping.test.ts b/packages/react/src/lib/integration-grouping.test.ts new file mode 100644 index 000000000..3aad8152e --- /dev/null +++ b/packages/react/src/lib/integration-grouping.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "@effect/vitest"; +import { type Integration, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { + groupIntegrations, + isMultiServiceFamily, + type IntegrationFamilyGroup, + type IntegrationSingle, +} from "./integration-grouping"; + +const integration = (slug: string, kind: string, name = slug): Integration => ({ + slug: IntegrationSlug.make(slug), + name, + description: name, + kind, + canRemove: true, + canRefresh: true, + authMethods: [], +}); + +describe("groupIntegrations", () => { + it("collapses a multi-member family under one umbrella", () => { + const items = groupIntegrations([ + integration("google_calendar", "google", "Google Calendar"), + integration("google_gmail", "google", "Gmail"), + integration("google_drive", "google", "Google Drive"), + ]); + + expect(items).toHaveLength(1); + const group = items[0] as IntegrationFamilyGroup; + expect(group.type).toBe("group"); + expect(group.kind).toBe("google"); + expect(group.label).toBe("Google"); + expect(group.members.map((m) => String(m.slug))).toEqual([ + "google_calendar", + "google_gmail", + "google_drive", + ]); + }); + + it("leaves non-family integrations ungrouped", () => { + const items = groupIntegrations([ + integration("stripe", "openapi", "Stripe"), + integration("sentry", "mcp", "Sentry"), + ]); + expect(items).toHaveLength(2); + expect(items.every((i) => i.type === "single")).toBe(true); + }); + + it("renders a lone family member flat, not as a one-item umbrella", () => { + const items = groupIntegrations([integration("google_calendar", "google", "Google Calendar")]); + expect(items).toHaveLength(1); + expect((items[0] as IntegrationSingle).type).toBe("single"); + }); + + it("places each group where its first member first appeared, keeping order stable", () => { + const items = groupIntegrations([ + integration("stripe", "openapi", "Stripe"), + integration("google_calendar", "google", "Google Calendar"), + integration("microsoft_mail", "microsoft", "Outlook Mail"), + integration("google_gmail", "google", "Gmail"), + integration("microsoft_calendar", "microsoft", "Outlook Calendar"), + integration("sentry", "mcp", "Sentry"), + ]); + + // stripe (single) · Google group (first at index 1) · Microsoft group + // (first at index 2) · sentry (single). Later family members fold back into + // their group rather than re-appearing. + expect(items.map((i) => (i.type === "group" ? `group:${i.kind}` : "single"))).toEqual([ + "single", + "group:google", + "group:microsoft", + "single", + ]); + const google = items[1] as IntegrationFamilyGroup; + const microsoft = items[2] as IntegrationFamilyGroup; + expect(google.members.map((m) => String(m.slug))).toEqual(["google_calendar", "google_gmail"]); + expect(microsoft.members.map((m) => String(m.slug))).toEqual([ + "microsoft_mail", + "microsoft_calendar", + ]); + }); + + it("keeps custom per-service integrations inside their family group", () => { + const items = groupIntegrations([ + integration("google_calendar", "google", "Google Calendar"), + integration("google_custom", "google", "Custom Google API"), + ]); + expect(items).toHaveLength(1); + const group = items[0] as IntegrationFamilyGroup; + expect(group.members.map((m) => String(m.slug))).toEqual(["google_calendar", "google_custom"]); + }); + + it("recognizes the multi-service family allowlist", () => { + expect(isMultiServiceFamily("google")).toBe(true); + expect(isMultiServiceFamily("microsoft")).toBe(true); + expect(isMultiServiceFamily("openapi")).toBe(false); + }); +}); diff --git a/packages/react/src/lib/integration-grouping.ts b/packages/react/src/lib/integration-grouping.ts new file mode 100644 index 000000000..53e95bd3e --- /dev/null +++ b/packages/react/src/lib/integration-grouping.ts @@ -0,0 +1,92 @@ +import type { Integration } from "@executor-js/sdk/shared"; + +// --------------------------------------------------------------------------- +// Integration grid grouping — a provider whose plugin fans out into several +// per-service integrations (Google → Calendar, Gmail, Drive; Microsoft → Mail, +// Calendar, Teams) can leave the catalog with 6-12 flat sibling rows. Those +// families collapse under a single provider umbrella; every other integration +// stays exactly where it was. +// --------------------------------------------------------------------------- + +/** Plugin kinds whose integrations are collapsed under a provider umbrella. + * These are the multi-service provider plugins: one plugin owns many + * per-service integrations that share a provider identity. */ +export const MULTI_SERVICE_FAMILIES: ReadonlySet = new Set(["google", "microsoft"]); + +/** Human label for a family umbrella, keyed by plugin kind. Falls back to a + * title-cased kind so a new family is never unlabeled. */ +const FAMILY_LABELS: Record = { + google: "Google", + microsoft: "Microsoft", +}; + +export const familyLabel = (kind: string): string => + FAMILY_LABELS[kind] ?? kind.charAt(0).toUpperCase() + kind.slice(1); + +export const isMultiServiceFamily = (kind: string): boolean => MULTI_SERVICE_FAMILIES.has(kind); + +/** A run of integrations sharing a provider family, rendered under one umbrella + * header. Only families with more than one member group; a lone family member + * renders flat (see `groupIntegrations`). */ +export interface IntegrationFamilyGroup { + readonly type: "group"; + readonly kind: string; + readonly label: string; + readonly members: readonly Integration[]; +} + +export interface IntegrationSingle { + readonly type: "single"; + readonly integration: Integration; +} + +export type IntegrationGridItem = IntegrationFamilyGroup | IntegrationSingle; + +/** + * Partition the flat integration list into an ordered mix of family groups and + * standalone integrations. Ordering is stable: a group appears where its FIRST + * member would have appeared in the original list, and non-family integrations + * keep their original position relative to it. A family with a single member is + * emitted as a standalone integration (a one-item umbrella adds chrome without + * grouping anything). + */ +export function groupIntegrations( + integrations: readonly Integration[], +): readonly IntegrationGridItem[] { + // First pass: count members per family so singletons can be rendered flat. + const familyCounts = new Map(); + for (const integration of integrations) { + if (isMultiServiceFamily(integration.kind)) { + familyCounts.set(integration.kind, (familyCounts.get(integration.kind) ?? 0) + 1); + } + } + + const items: IntegrationGridItem[] = []; + const groupIndexByKind = new Map(); + + for (const integration of integrations) { + const grouped = + isMultiServiceFamily(integration.kind) && (familyCounts.get(integration.kind) ?? 0) > 1; + + if (!grouped) { + items.push({ type: "single", integration }); + continue; + } + + const existing = groupIndexByKind.get(integration.kind); + if (existing === undefined) { + groupIndexByKind.set(integration.kind, items.length); + items.push({ + type: "group", + kind: integration.kind, + label: familyLabel(integration.kind), + members: [integration], + }); + } else { + const group = items[existing] as IntegrationFamilyGroup; + items[existing] = { ...group, members: [...group.members, integration] }; + } + } + + return items; +} diff --git a/packages/react/src/pages/integrations.tsx b/packages/react/src/pages/integrations.tsx index 16def361a..1d94c016b 100644 --- a/packages/react/src/pages/integrations.tsx +++ b/packages/react/src/pages/integrations.tsx @@ -1,4 +1,4 @@ -import { Suspense, useCallback, useMemo, useState } from "react"; +import { Suspense, useCallback, useMemo, 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"; @@ -33,11 +33,14 @@ import { CardStackEntryDescription, CardStackEntryMedia, CardStackEntryTitle, + CardStackHeader, } from "../components/card-stack"; import { + IntegrationFavicon, integrationInferredUrl, integrationPresetIconUrl, } from "../components/integration-favicon"; +import { 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"; @@ -434,47 +437,110 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { return out; }, [integrationPlugins]); + // Multi-service providers (Google, Microsoft) collapse their per-service + // integrations under one umbrella; everything else renders flat. Groups keep + // the position of their first member so the list order is stable. + const items = useMemo(() => groupIntegrations(props.integrations), [props.integrations]); + + const renderEntry = (integration: Integration) => { + const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind; + const plugin = pluginByKind.get(pluginKey); + const SummaryComponent = plugin?.summary; + const slug = String(integration.slug); + const name = integration.name || slug; + return ( + + + + + {name} + {slug} + + + {SummaryComponent && ( + + + + )} + + + + + ); + }; + + // Runs of standalone (non-family) integrations share one searchable card so + // the flat catalog keeps its familiar search box; family groups render as + // their own collapsible provider umbrellas interleaved at the right position. + const rendered: ReactNode[] = []; + let flatRun: Integration[] = []; + const flushFlat = () => { + if (flatRun.length === 0) return; + const run = flatRun; + flatRun = []; + rendered.push( + + {run.map(renderEntry)} + , + ); + }; + + for (const item of items) { + if (item.type === "single") { + flatRun.push(item.integration); + continue; + } + flushFlat(); + rendered.push( + , + ); + } + flushFlat(); + + return
{rendered}
; +} + +// A provider umbrella: a collapsible card whose header carries the provider +// icon + name and a count, containing that provider's per-service integrations. +function IntegrationFamilyGroupCard(props: { + group: IntegrationFamilyGroup; + plugin: IntegrationPlugin | undefined; + renderEntry: (integration: Integration) => ReactNode; +}) { + const { group, plugin, renderEntry } = props; + // The provider's own logo lives on its bundle preset (the "Google" / + // "Microsoft Graph" entry); fall back to a neutral glyph when absent. + const headerIcon = plugin?.presets?.find((preset) => preset.icon)?.icon ?? null; return ( - - - {props.integrations.map((integration) => { - const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind; - const plugin = pluginByKind.get(pluginKey); - const SummaryComponent = plugin?.summary; - const slug = String(integration.slug); - const name = integration.name || slug; - return ( - - - - - {name} - {slug} - - - {SummaryComponent && ( - - - - )} - - - - - ); - })} - + + + + + + + {group.label} + + {group.members.length} + + + + {group.members.map(renderEntry)} ); }