164 lines · 6.1 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 { FloatActions } from "@executor-js/react/components/float-actions";import {  addIntegrationErrorMessage,  FormErrorAlert,  SlugCollisionAlert,  useSlugAlreadyExists,} from "@executor-js/react/lib/integration-add"; import { addGraphqlIntegrationOptimistic } from "./atoms";import { GraphqlSourceFields } from "./GraphqlSourceFields";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 AddGraphqlSource(props: {  onComplete: (slug?: string) => void;  onCancel: () => void;  initialUrl?: string;}) {  const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");  const [description, setDescription] = useState("");  const identity = useIntegrationIdentity({    fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "",  });  const authMethodList = useAuthMethodList(NO_SEEDS);  const [adding, setAdding] = useState(false);  const [addError, setAddError] = useState<string | null>(null);   const doAddIntegration = useAtomSet(addGraphqlIntegrationOptimistic, {    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 sourceIdentity = 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 } = sourceIdentity();     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 source"));      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>       <GraphqlSourceFields        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="Every method here is registered with the integration. Connect an account from the integration page after adding."      />       {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>  );}