Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/connect-picker-browsable.md
Original file line number Diff line number Diff line change
@@ -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. 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.
97 changes: 97 additions & 0 deletions e2e/scenarios/connect-dialog-abandoned-detection.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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\//);
});
});
}),
);
122 changes: 122 additions & 0 deletions e2e/scenarios/connect-integration-picker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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);
});

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: "Add OpenAPI" }).isVisible()).toBe(false);
await dialog.getByRole("button", { name: "Add manually" }).click();
await page.getByRole("menuitem", { name: "Add GraphQL" }).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: "Add GraphQL" }).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");
});
});
}),
);
3 changes: 3 additions & 0 deletions packages/react/src/api/analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/components/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
className="absolute top-4 right-4 -m-3.5 rounded-xs p-3.5 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
Expand Down
10 changes: 8 additions & 2 deletions packages/react/src/components/filter-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@ interface FilterTabsProps<T extends string = string> {
tabs: FilterTab<T>[];
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<T extends string = string>({
tabs,
value,
onChange,
className,
}: FilterTabsProps<T>) {
return (
<div className="flex flex-wrap items-center gap-1">
<div className={cn("flex flex-wrap items-center gap-1", className)}>
{tabs.map((tab) => {
const isActive = value === tab.value;
return (
Expand All @@ -32,7 +36,9 @@ export function FilterTabs<T extends string = string>({
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",
Expand Down
11 changes: 3 additions & 8 deletions packages/react/src/components/integration-favicon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -28,13 +30,6 @@ export function integrationLocalIconUrl(integrationId: string | undefined): stri
return "/favicon-32.png";
}

const KIND_TO_PLUGIN_KEY: Record<string, string> = {
openapi: "openapi",
mcp: "mcp",
graphql: "graphql",
googleDiscovery: "google",
};

const normalizeUrl = (url: string | undefined): string | null => {
if (!url) return null;
try {
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 9 additions & 3 deletions packages/react/src/lib/integration-grouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,17 @@ const FAMILY_LABELS: Record<string, string> = {
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;
Expand Down
13 changes: 13 additions & 0 deletions packages/react/src/lib/integration-plugin-keys.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
openapi: "openapi",
mcp: "mcp",
graphql: "graphql",
googleDiscovery: "google",
};

export const pluginKeyForIntegrationKind = (kind: string): string =>
KIND_TO_PLUGIN_KEY[kind] ?? kind;
Loading