From a0f4e3849c07060f111bb3e145c44b2ce2ad2a38 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:43:20 -0700 Subject: [PATCH 1/5] Give per-service integrations their own service icons Provider plugins that fan out into per-service integrations (Google -> Calendar, Gmail, Drive; Microsoft -> Mail, Calendar, Teams) now declare a per-service slug->icon map. The catalog favicon resolver consults it by the integration slug before the URL/token preset cascade, so a Gmail source shows the Gmail glyph rather than a generic Google logo. Kept separate from presets so the connect picker's popular list is untouched. --- packages/core/sdk/src/client.ts | 8 +++ .../plugins/google/src/react/source-plugin.ts | 14 ++++- .../microsoft/src/react/source-plugin.ts | 13 +++- .../components/integration-favicon.test.tsx | 62 +++++++++++++++++++ .../src/components/integration-favicon.tsx | 8 +++ 5 files changed, 103 insertions(+), 2 deletions(-) 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); From 112b4370f86a917c1e049130eae429c1e9dd3005 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:43:35 -0700 Subject: [PATCH 2/5] Group multi-service integrations under a provider umbrella Integrations whose kind is in the multi-service family allowlist (google, microsoft) now collapse under one collapsible provider card on the integrations list: a Google umbrella carrying the Calendar, Gmail, Drive, and custom rows, keyed by kind. A family with a single member and every non-family integration render flat as before, and groups appear where their first member would have. Adds an e2e spec asserting the grouping, that a non-family integration stays ungrouped, and that the per-service icons are distinct. --- .../integrations-grid-grouping-ui.test.ts | 133 ++++++++++++++++ .../src/lib/integration-grouping.test.ts | 99 ++++++++++++ .../react/src/lib/integration-grouping.ts | 92 +++++++++++ packages/react/src/pages/integrations.tsx | 148 +++++++++++++----- 4 files changed, 431 insertions(+), 41 deletions(-) create mode 100644 e2e/scenarios/integrations-grid-grouping-ui.test.ts create mode 100644 packages/react/src/lib/integration-grouping.test.ts create mode 100644 packages/react/src/lib/integration-grouping.ts 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..bb735657e --- /dev/null +++ b/e2e/scenarios/integrations-grid-grouping-ui.test.ts @@ -0,0 +1,133 @@ +// 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. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { clearCheckedPresets, setPresetChecked } from "./support/picker"; + +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 identity = yield* target.newIdentity(); + const customDiscoveryUrl = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; + + yield* browser.session(identity, async ({ page, step }) => { + await step("Fan out four Google integrations (Calendar, Gmail, Drive, custom)", async () => { + await page.goto("/integrations/add/google", { waitUntil: "domcontentloaded" }); + await page.getByText("Customize your Google connection").waitFor(); + await page.getByTestId("preset-checkbox-google-calendar").waitFor(); + + await clearCheckedPresets(page); + for (const presetId of ["google-calendar", "google-gmail", "google-drive"]) { + await setPresetChecked(page, presetId, true); + } + 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 }); + for (const presetId of ["google-calendar", "google-gmail", "google-drive"]) { + const row = page.getByTestId(`add-result-row-${presetId}`); + await row.waitFor({ timeout: 120_000 }); + expect(await row.getAttribute("data-state"), `${presetId} added`).toBe("added"); + } + const customRow = page.getByTestId("add-result-row-custom"); + await customRow.waitFor({ timeout: 120_000 }); + expect(await customRow.getAttribute("data-state"), "custom Google service added").toBe( + "added", + ); + }); + + 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_custom"]) { + 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); + }); + }); + }), +); 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)} ); } From 2e35c79055a721728cc92adcf9f66bb6e537dfa7 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:06:50 -0700 Subject: [PATCH 3/5] e2e: make grid grouping spec robust to pre-existing Google integrations On selfhost every scenario shares the bootstrap admin, so the per-service add specs that run earlier in the job leave google_custom (and the preset integrations) behind. The Google add flow disables its submit button when the custom slug already exists, so the grid spec timed out clicking it. Check the grid first and only run the add flow for integrations that are actually missing, tolerating skipped rows, and remove what the spec created in a finalizer so no state leaks into later scenarios. --- .../integrations-grid-grouping-ui.test.ts | 156 ++++++++++++++---- 1 file changed, 125 insertions(+), 31 deletions(-) diff --git a/e2e/scenarios/integrations-grid-grouping-ui.test.ts b/e2e/scenarios/integrations-grid-grouping-ui.test.ts index bb735657e..5425454d1 100644 --- a/e2e/scenarios/integrations-grid-grouping-ui.test.ts +++ b/e2e/scenarios/integrations-grid-grouping-ui.test.ts @@ -23,53 +23,131 @@ // 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). The add +// flow's submit button is DISABLED when the custom slug (`google_custom`) +// already exists (`customSlugAlreadyExists` gates `canAdd` in +// AddGoogleSource), so this spec first checks the grid, adds only what is +// missing, 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 { Browser, Target } from "../src/services"; +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[] = []; - yield* browser.session(identity, async ({ page, step }) => { - await step("Fan out four Google integrations (Calendar, Gmail, Drive, custom)", async () => { - await page.goto("/integrations/add/google", { waitUntil: "domcontentloaded" }); - await page.getByText("Customize your Google connection").waitFor(); - await page.getByTestId("preset-checkbox-google-calendar").waitFor(); + 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; custom -> google_custom) + const presetToSlug: Record = { + "google-calendar": "google_calendar", + "google-gmail": "google_gmail", + "google-drive": "google_drive", + }; + const customSlug = "google_custom"; - await clearCheckedPresets(page); - for (const presetId of ["google-calendar", "google-gmail", "google-drive"]) { - await setPresetChecked(page, presetId, true); - } - 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 }); - for (const presetId of ["google-calendar", "google-gmail", "google-drive"]) { - const row = page.getByTestId(`add-result-row-${presetId}`); - await row.waitFor({ timeout: 120_000 }); - expect(await row.getAttribute("data-state"), `${presetId} added`).toBe("added"); - } - const customRow = page.getByTestId("add-result-row-custom"); - await customRow.waitFor({ timeout: 120_000 }); - expect(await customRow.getAttribute("data-state"), "custom Google service added").toBe( - "added", - ); - }); + 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_custom is missing: if + // it already exists, filling it flips customSlugAlreadyExists true and + // disables the submit button. + 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 preset 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. + for (const presetId of missingPresetIds) { + const row = page.getByTestId(`add-result-row-${presetId}`); + await row.waitFor({ timeout: 120_000 }); + expect( + ["added", "skipped"], + `${presetId} present (added or already existed)`, + ).toContain(await row.getAttribute("data-state")); + } + if (customMissing) { + const customRow = page.getByTestId("add-result-row-custom"); + await customRow.waitFor({ timeout: 120_000 }); + expect( + ["added", "skipped"], + "custom Google service present (added or already existed)", + ).toContain(await customRow.getAttribute("data-state")); + } + }, + ); await step("The four Google integrations sit under one Google umbrella", async () => { await page.goto("/integrations", { waitUntil: "networkidle" }); @@ -129,5 +207,21 @@ scenario( ).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_custom disables the Google add flow's submit button. + Effect.forEach( + createdSlugs, + (slug) => + api.integrations + .remove({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + { discard: true }, + ), + ), + ); }), ); From 0e7169f1882fd76329bff44079fcaa668b55e5f3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:07:29 -0700 Subject: [PATCH 4/5] Track derived google_tasks slug in grid grouping spec --- .../integrations-grid-grouping-ui.test.ts | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/e2e/scenarios/integrations-grid-grouping-ui.test.ts b/e2e/scenarios/integrations-grid-grouping-ui.test.ts index 5425454d1..d1a286581 100644 --- a/e2e/scenarios/integrations-grid-grouping-ui.test.ts +++ b/e2e/scenarios/integrations-grid-grouping-ui.test.ts @@ -26,12 +26,11 @@ // // 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). The add -// flow's submit button is DISABLED when the custom slug (`google_custom`) -// already exists (`customSlugAlreadyExists` gates `canAdd` in -// AddGoogleSource), so this spec first checks the grid, adds only what is -// missing, 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. +// 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"; @@ -67,13 +66,15 @@ scenario( // integrations and tolerates "skipped" rows. The real grouping/icon // assertions live in the steps below. // - // preset id -> integration slug (dashes become underscores; custom -> google_custom) + // 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_custom"; + const customSlug = "google_tasks"; await page.goto("/integrations", { waitUntil: "networkidle" }); const existingMain = page.getByRole("main"); @@ -111,9 +112,9 @@ scenario( await setPresetChecked(page, presetId, true); } - // Only add the custom Discovery URL when google_custom is missing: if - // it already exists, filling it flips customSlugAlreadyExists true and - // disables the submit button. + // 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", @@ -139,7 +140,7 @@ scenario( ).toContain(await row.getAttribute("data-state")); } if (customMissing) { - const customRow = page.getByTestId("add-result-row-custom"); + const customRow = page.getByTestId("add-result-row-google_tasks"); await customRow.waitFor({ timeout: 120_000 }); expect( ["added", "skipped"], @@ -166,7 +167,7 @@ scenario( // 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_custom"]) { + 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); @@ -212,7 +213,8 @@ scenario( 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_custom disables the Google add flow's submit button. + // leaked google_tasks would show up as an unexpected "skipped" row in + // the google-per-service spec's results. Effect.forEach( createdSlugs, (slug) => From cb4c97a041f2091eaf888d70d3f959ae6d802306 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:19:31 -0700 Subject: [PATCH 5/5] Assert add result rows in one pass --- .../integrations-grid-grouping-ui.test.ts | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/e2e/scenarios/integrations-grid-grouping-ui.test.ts b/e2e/scenarios/integrations-grid-grouping-ui.test.ts index d1a286581..1f0223e0e 100644 --- a/e2e/scenarios/integrations-grid-grouping-ui.test.ts +++ b/e2e/scenarios/integrations-grid-grouping-ui.test.ts @@ -128,24 +128,17 @@ scenario( const results = page.getByTestId("google-add-results"); await results.waitFor({ timeout: 120_000 }); - // Each submitted preset resolves to a row that is either freshly + // 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. - for (const presetId of missingPresetIds) { - const row = page.getByTestId(`add-result-row-${presetId}`); + // 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"], - `${presetId} present (added or already existed)`, - ).toContain(await row.getAttribute("data-state")); - } - if (customMissing) { - const customRow = page.getByTestId("add-result-row-google_tasks"); - await customRow.waitFor({ timeout: 120_000 }); - expect( - ["added", "skipped"], - "custom Google service present (added or already existed)", - ).toContain(await customRow.getAttribute("data-state")); + expect(["added", "skipped"], `${rowId} present (added or already existed)`).toContain( + await row.getAttribute("data-state"), + ); } }, );