283 lines · 10.1 KB · TypeScript
RawDownload
import { Suspense, useMemo, type ReactNode } from "react";import { Link } from "@tanstack/react-router";import { useAtomRefresh, useAtomValue } from "@effect/atom-react";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import { PlusIcon } from "lucide-react";import type { Integration } from "@executor-js/sdk/shared";import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/client";import { integrationsOptimisticAtom } from "../api/atoms";import { trackEvent } from "../api/analytics";import { McpInstallCard } from "../components/mcp-install-card";import { WorkspaceAdminHint } from "../components/workspace-admin-hint";import { Button } from "../components/button";import { PageContainer, PageHeader } from "../components/page";import {  CardStack,  CardStackContent,  CardStackEntry,  CardStackEntryActions,  CardStackEntryContent,  CardStackEntryDescription,  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";import { useExecutorDocumentTitle } from "../lib/document-title";import { ErrorState } from "../components/error-state";import { isAsyncResultLoading } from "../lib/async-result";import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav"; const KIND_TO_PLUGIN_KEY: Record<string, string> = {  openapi: "openapi",  mcp: "mcp",  graphql: "graphql",  googleDiscovery: "google",}; // ---------------------------------------------------------------------------// Page// --------------------------------------------------------------------------- export function IntegrationsPage() {  useExecutorDocumentTitle("Integrations");  const integrations = useAtomValue(integrationsOptimisticAtom);  const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);  const canCreate = useCanCreateWorkspaceConnections();   return (    <PageContainer>      <PageHeader        title="Integrations"        description="Tool providers available in this workspace."        actions={          canCreate ? (            <Button asChild size="sm" className="gap-1.5">              <Link                to="/{-$orgSlug}/integrations/browse"                onClick={() => trackEvent("integration_browse_opened", { via: "header" })}              >                <PlusIcon className="size-4" />                Add integration              </Link>            </Button>          ) : (            <WorkspaceAdminHint allowed={false}>              <Button disabled size="sm">                <PlusIcon className="size-4" />                Add integration              </Button>            </WorkspaceAdminHint>          )        }      />       <div className="mb-8">        <McpInstallCard />      </div>       <div className="mb-8 border-t border-border/50" />       {isAsyncResultLoading(integrations) ? (        <IntegrationsGridSkeleton />      ) : (        AsyncResult.match(integrations, {          onInitial: () => <IntegrationsGridSkeleton />,          onFailure: () => (            <ErrorState message="Failed to load integrations" onRetry={refreshIntegrations} />          ),          onSuccess: ({ value }) => {            if (value.length === 0) {              return <EmptyIntegrations canCreate={canCreate} />;            }             return (              <div className="mb-8 space-y-3">                <IntegrationGrid integrations={value} />              </div>            );          },        })      )}    </PageContainer>  );} // ---------------------------------------------------------------------------// Empty state// --------------------------------------------------------------------------- function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) {  return (    <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">      <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">        <PlusIcon className="size-5" />      </div>      <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p>      <p className="mb-5 text-[13px] text-muted-foreground/60">        {canCreate          ? "Connect an integration to start curating tools."          : "Ask a workspace admin to add an integration."}      </p>      {canCreate ? (        <Button asChild size="sm" className="gap-1.5">          <Link            to="/{-$orgSlug}/integrations/browse"            onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })}          >            <PlusIcon className="size-4" />            Add an integration          </Link>        </Button>      ) : (        <WorkspaceAdminHint allowed={false}>          <Button disabled size="sm">            <PlusIcon className="size-4" />            Add an integration          </Button>        </WorkspaceAdminHint>      )}    </div>  );} // ---------------------------------------------------------------------------// Integration grid — flat list of catalog integrations, click-through to detail// --------------------------------------------------------------------------- function IntegrationGrid(props: { integrations: readonly Integration[] }) {  const integrationPlugins = useIntegrationPlugins();  const pluginByKind = useMemo(() => {    const out = new Map<string, IntegrationPlugin>();    for (const p of integrationPlugins) out.set(p.key, p);    return out;  }, [integrationPlugins]);   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 (      <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}>        <Link          to="/{-$orgSlug}/integrations/$namespace"          params={{ namespace: slug }}          data-testid={`integration-entry-${slug}`}        >          <IntegrationIconWithAccount            icon={integrationPresetIconUrl(              { id: slug, kind: integration.kind, name, url: integration.displayUrl },              integrationPlugins,            )}            integrationId={slug}            url={integration.displayUrl ?? integrationInferredUrl({ id: slug, name }) ?? undefined}          />          <CardStackEntryContent>            <CardStackEntryTitle>{name}</CardStackEntryTitle>            <CardStackEntryDescription>{slug}</CardStackEntryDescription>          </CardStackEntryContent>          <CardStackEntryActions>            {SummaryComponent && (              <Suspense fallback={null}>                <SummaryComponent integrationId={slug} />              </Suspense>            )}            <IntegrationHealthSummary integration={integration.slug} />          </CardStackEntryActions>        </Link>      </CardStackEntry>    );  };   const rendered: ReactNode[] = [];  let flatRun: Integration[] = [];  const flushFlat = () => {    if (flatRun.length === 0) return;    const run = flatRun;    flatRun = [];    rendered.push(      <CardStack key={`flat-${String(run[0]!.slug)}`} searchable>        <CardStackContent>{run.map(renderEntry)}</CardStackContent>      </CardStack>,    );  };   for (const item of items) {    if (item.type === "single") {      flatRun.push(item.integration);      continue;    }    flushFlat();    rendered.push(      <IntegrationFamilyGroupCard        key={`group-${item.family}`}        group={item}        plugin={pluginByKind.get("openapi")}        renderEntry={renderEntry}      />,    );  }  flushFlat();   return <div className="space-y-3">{rendered}</div>;} function IntegrationFamilyGroupCard(props: {  group: IntegrationFamilyGroup;  plugin: IntegrationPlugin | undefined;  renderEntry: (integration: Integration) => ReactNode;}) {  const { group, plugin, renderEntry } = props;  const headerIcon =    plugin?.presets?.find((preset) => preset.family === group.family && preset.icon)?.icon ?? null;  return (    <CardStack collapsible defaultOpen data-testid={`integration-group-${group.family}`}>      <CardStackHeader>        <span className="flex min-w-0 items-center gap-2">          <span className="flex size-5 shrink-0 items-center justify-center">            <IntegrationFavicon icon={headerIcon} size={16} />          </span>          <span className="truncate">{group.label}</span>          <span className="shrink-0 font-mono text-xs font-normal text-muted-foreground">            {group.members.length}          </span>        </span>      </CardStackHeader>      <CardStackContent>{group.members.map(renderEntry)}</CardStackContent>    </CardStack>  );} // ---------------------------------------------------------------------------// Loading skeleton// --------------------------------------------------------------------------- function IntegrationsGridSkeleton() {  return (    <CardStack>      <CardStackContent>        {Array.from({ length: 4 }).map((_, i) => (          <div key={i} className="flex items-center gap-3 px-4 py-3">            <Skeleton className="size-8 shrink-0 rounded-md" />            <div className="flex min-w-0 flex-1 flex-col gap-1.5">              <Skeleton className="h-4" style={{ width: `${40 + ((i * 11) % 30)}%` }} />              <Skeleton className="h-3" style={{ width: `${25 + ((i * 7) % 20)}%` }} />            </div>            <Skeleton className="h-5 w-16 rounded-full" />          </div>        ))}      </CardStackContent>    </CardStack>  );}