181 lines · 7.0 KB · TypeScript
RawDownload
import { useCallback, useMemo, useState } from "react";import { useAtomSet } from "@effect/atom-react";import * as Exit from "effect/Exit"; import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";import {  integrationDisplayNameFromUrl,  slugifyNamespace,  useIntegrationIdentity,} from "@executor-js/react/plugins/integration-identity";import { Button } from "@executor-js/react/components/button";import {  AuthMethodListEditor,  useAuthMethodList,  type AuthMethodRow,  type AuthMethodSeed,} from "@executor-js/react/components/auth-method-list-editor";import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";import { FloatActions } from "@executor-js/react/components/float-actions";import {  addIntegrationErrorMessage,  FormErrorAlert,  SlugCollisionAlert,  useSlugAlreadyExists,} from "@executor-js/react/lib/integration-add"; import { createGraphqlIntegrationOptimistic } from "./atoms";import { GraphqlIntegrationFields } from "./GraphqlIntegrationFields";import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config";import type { GraphqlAuthMethodInput } from "../sdk/types"; // v2 GraphQL add flow: register the integration with its declared auth-method// LIST (the shared `AuthMethodListEditor` — GraphQL stays header/query apiKey;// OAuth is hidden), then route to the integration's detail hub. Connection// creation is no longer part of the add flow — accounts are added from the hub// (P6: add without auth, connect later). // GraphQL has no add-time detection, so the list starts empty (module constant// — a fresh [] every render would re-seed the list each render).const NO_SEEDS: readonly AuthMethodSeed[] = []; export default function AddGraphqlIntegration(props: {  onComplete: (slug?: string) => void;  onCancel: () => void;  initialUrl?: string;  initialAuthHeader?: string;  initialAuthNote?: string;}) {  const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");  const [description, setDescription] = useState("");  const identity = useIntegrationIdentity({    fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "",  });  // GraphQL has no add-time detection, but the registry can declare the  // credential placement ("Authorization: {api_key}" — Linear's no-Bearer  // personal keys). A declared placement seeds the method list the same way a  // spec's security scheme would.  const registrySeeds = useMemo<readonly AuthMethodSeed[]>(() => {    const placement = props.initialAuthHeader      ? placementFromHeaderPattern(props.initialAuthHeader)      : null;    return placement ? [{ value: { kind: "apikey", placements: [placement] } }] : NO_SEEDS;  }, [props.initialAuthHeader]);  const authMethodList = useAuthMethodList(registrySeeds);  const [adding, setAdding] = useState(false);  const [addError, setAddError] = useState<string | null>(null);   const doAddIntegration = useAtomSet(createGraphqlIntegrationOptimistic, {    mode: "promiseExit",  });   // The methods to register: each apikey row declares ONE method carrying  // every named placement (header + query mix in a single method). Inputs  // omit slugs — the backend assigns carrier-derived ones. `none` rows  // register nothing.  const authenticationTemplate = useMemo<readonly GraphqlAuthMethodInput[]>(    () =>      authMethodList.rows.flatMap((row: AuthMethodRow) =>        row.value.kind === "apikey"          ? graphqlAuthMethodInputsFromPlacements(row.value.placements)          : [],      ),    [authMethodList.rows],  );   // Every apikey row needs at least one named placement; `none` rows are  // always valid.  const apiKeyComplete = authMethodList.rows.every(    (row: AuthMethodRow) =>      row.value.kind !== "apikey" ||      row.value.placements.some((placement) => placement.name.trim().length > 0),  );   const resolvedSlug = useMemo(    () =>      slugifyNamespace(identity.namespace) ||      slugifyNamespace(integrationDisplayNameFromUrl(endpoint.trim(), "GraphQL") ?? "") ||      "graphql",    [endpoint, identity.namespace],  );   // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration  // whose slug already exists clobbers the existing one's connections/policies,  // so the API blocks it. Surface that here from the tenant-scoped catalog list.  const slugAlreadyExists = useSlugAlreadyExists(resolvedSlug);   const canAdd = endpoint.trim().length > 0 && apiKeyComplete && !adding && !slugAlreadyExists;   const integrationIdentity = useCallback(() => {    const trimmedEndpoint = endpoint.trim();    const slug = resolvedSlug;    const displayName =      identity.name.trim() || integrationDisplayNameFromUrl(trimmedEndpoint, "GraphQL") || slug;    return { trimmedEndpoint, slug, displayName };  }, [endpoint, identity.name, resolvedSlug]);   const handleAdd = async (): Promise<void> => {    setAdding(true);    setAddError(null);    const { trimmedEndpoint, slug, displayName } = integrationIdentity();     const integrationExit = await doAddIntegration({      payload: {        endpoint: trimmedEndpoint,        slug,        name: displayName,        ...(description.trim().length > 0 ? { description: description.trim() } : {}),        ...(authenticationTemplate.length > 0          ? { authenticationTemplate: [...authenticationTemplate] }          : {}),      },      reactivityKeys: integrationWriteKeys,    });    if (Exit.isFailure(integrationExit)) {      setAddError(addIntegrationErrorMessage(integrationExit, slug, "Failed to add integration"));      setAdding(false);      return;    }    const registeredSlug = integrationExit.value.slug;     props.onComplete(String(registeredSlug));  };   return (    <div className="flex flex-1 flex-col gap-6">      <h1 className="text-xl font-semibold text-foreground">Add GraphQL integration</h1>       <GraphqlIntegrationFields        endpoint={endpoint}        onEndpointChange={setEndpoint}        identity={identity}        description={description}        onDescriptionChange={setDescription}      />       <AuthMethodListEditor        list={authMethodList}        allowedKinds={["none", "apikey"]}        emptyHint="No authentication declared. Add a method, or add the integration without auth and connect an account from the integration page later."        footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page."      />       {props.initialAuthNote ? (        <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p>      ) : null}       {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />}       {addError && <FormErrorAlert message={addError} />}       <FloatActions>        <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>          Cancel        </Button>        <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}>          Add integration        </Button>      </FloatActions>    </div>  );}