467 lines · 16.9 KB · TypeScript
RawDownload
import { useEffect, useMemo, useRef, useState } from "react";import { useAtomSet } from "@effect-atom/atom-react";import { Option } from "effect"; import { useScope } from "@executor/react/api/scope-context";import { HeadersList } from "@executor/react/plugins/headers-list";import {  matchPresetKey,  type HeaderState,} from "@executor/react/plugins/secret-header-auth";import {  SourceIdentityFields,  useSourceIdentity,} from "@executor/react/plugins/source-identity";import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";import { Button } from "@executor/react/components/button";import {  CardStack,  CardStackContent,  CardStackEntry,  CardStackEntryContent,  CardStackEntryDescription,  CardStackEntryField,  CardStackEntryTitle,} from "@executor/react/components/card-stack";import { FieldLabel } from "@executor/react/components/field";import { FloatActions } from "@executor/react/components/float-actions";import { Input } from "@executor/react/components/input";import { Label } from "@executor/react/components/label";import { Textarea } from "@executor/react/components/textarea";import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group";import { Skeleton } from "@executor/react/components/skeleton";import { IOSSpinner, Spinner } from "@executor/react/components/spinner";import { previewOpenApiSpec, addOpenApiSpec } from "./atoms";import type { SpecPreview, HeaderPreset } from "../sdk/preview";import type { HeaderValue } from "../sdk/types"; // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- function prefixForHeader(preset: HeaderPreset, headerName: string): string | undefined {  const label = preset.label.toLowerCase();  if (headerName.toLowerCase() === "authorization") {    if (label.includes("bearer")) return "Bearer ";    if (label.includes("basic")) return "Basic ";  }  return undefined;} function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] {  return preset.secretHeaders.map((headerName) => {    const prefix = prefixForHeader(preset, headerName);    return {      name: headerName,      secretId: null,      prefix,      presetKey: matchPresetKey(headerName, prefix),      fromPreset: true,    };  });} // ---------------------------------------------------------------------------// Main component — single progressive form// --------------------------------------------------------------------------- export default function AddOpenApiSource(props: {  onComplete: () => void;  onCancel: () => void;  initialUrl?: string;  initialNamespace?: string;}) {  // Spec input  const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");  const [analyzing, setAnalyzing] = useState(false);  const [analyzeError, setAnalyzeError] = useState<string | null>(null);   // After analysis  const [preview, setPreview] = useState<SpecPreview | null>(null);  const [baseUrl, setBaseUrl] = useState("");  const identity = useSourceIdentity({    fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "",    fallbackNamespace: props.initialNamespace,  });   // Auth  // `selectedStrategy` is an index into `preview.headerPresets`, or -1 for  // "None", or -2 for "Custom" (user-managed headers, no spec preset).  const [selectedStrategy, setSelectedStrategy] = useState<number>(-1);  const [customHeaders, setCustomHeaders] = useState<HeaderState[]>([]);   // Submit  const [adding, setAdding] = useState(false);  const [addError, setAddError] = useState<string | null>(null);   const scopeId = useScope();  const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });  const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });  const secretList = useSecretPickerSecrets();   // Keep the latest handleAnalyze in a ref so the debounced effect doesn't  // need it as a dependency (it closes over fresh state).  const handleAnalyzeRef = useRef<() => void>(() => {});   // Auto-analyze whenever the spec input changes, with a short debounce so  // typing/pasting doesn't fire a request on every keystroke.  useEffect(() => {    const trimmed = specUrl.trim();    if (!trimmed) return;    if (preview) return;    const handle = setTimeout(() => {      handleAnalyzeRef.current();    }, 400);    return () => clearTimeout(handle);  }, [specUrl, preview]);   // ---- Derived state ----   const servers = (preview?.servers ?? []) as Array<{ url?: string }>;   // Derive a favicon URL from the spec URL (if the user entered one — raw  // JSON/YAML content will fail URL parsing and yield null). Uses Google's  // favicon service so we don't depend on the domain serving /favicon.ico.  const faviconUrl = useMemo(() => {    try {      const trimmed = specUrl.trim();      if (!trimmed) return null;      const u = new URL(trimmed);      if (u.protocol !== "http:" && u.protocol !== "https:") return null;      return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=64`;    } catch {      return null;    }  }, [specUrl]);   const [faviconFailed, setFaviconFailed] = useState(false);  useEffect(() => {    setFaviconFailed(false);  }, [faviconUrl]);   const allHeaders: Record<string, HeaderValue> = {};  for (const ch of customHeaders) {    if (ch.name.trim() && ch.secretId) {      allHeaders[ch.name.trim()] = {        secretId: ch.secretId,        ...(ch.prefix ? { prefix: ch.prefix } : {}),      };    }  }  const hasHeaders = Object.keys(allHeaders).length > 0;   const customHeadersValid = customHeaders.every((ch) => ch.name.trim() && ch.secretId);   const canAdd =    preview !== null &&    baseUrl.trim().length > 0 &&    (customHeaders.length === 0 || customHeadersValid);   // ---- Handlers ----   const handleAnalyze = async () => {    setAnalyzing(true);    setAnalyzeError(null);    setAddError(null);    try {      const result = await doPreview({        path: { scopeId },        payload: { spec: specUrl },      });      setPreview(result);       const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url;      if (firstUrl) setBaseUrl(firstUrl);       const firstPreset = result.headerPresets[0];      if (firstPreset) {        setSelectedStrategy(0);        setCustomHeaders(entriesFromSpecPreset(firstPreset));      } else {        setSelectedStrategy(-1);        setCustomHeaders([]);      }    } catch (e) {      setAnalyzeError(e instanceof Error ? e.message : "Failed to parse spec");    } finally {      setAnalyzing(false);    }  };   handleAnalyzeRef.current = handleAnalyze;   const selectStrategy = (index: number) => {    setSelectedStrategy(index);    if (index === -1) {      setCustomHeaders([]);      return;    }    if (index === -2) {      // Drop preset-derived headers, keep user headers (seed one if empty).      const userHeaders = customHeaders.filter((h) => !h.fromPreset);      setCustomHeaders(userHeaders.length > 0 ? userHeaders : []);      return;    }    const preset = preview?.headerPresets[index];    if (!preset) return;    const userHeaders = customHeaders.filter((h) => !h.fromPreset);    setCustomHeaders([...entriesFromSpecPreset(preset), ...userHeaders]);  };   const handleHeadersChange = (next: HeaderState[]) => {    setCustomHeaders(next);    // If user drops all preset-derived headers and adds their own, mark as    // Custom so the strategy picker reflects it.    if (selectedStrategy >= 0 && next.every((h) => !h.fromPreset)) {      setSelectedStrategy(next.length === 0 ? -1 : -2);    }  };   const handleAdd = async () => {    setAdding(true);    setAddError(null);    try {      await doAdd({        path: { scopeId },        payload: {          spec: specUrl,          name: identity.name.trim() || undefined,          namespace: identity.namespace.trim() || undefined,          baseUrl: baseUrl.trim() || undefined,          ...(hasHeaders ? { headers: allHeaders } : {}),        },      });      props.onComplete();    } catch (e) {      setAddError(e instanceof Error ? e.message : "Failed to add source");      setAdding(false);    }  };   // ---- Render ----   return (    <div className="flex flex-1 flex-col gap-6">      <h1 className="text-xl font-semibold text-foreground">Add OpenAPI Source</h1>       {/* ── Spec input ── */}      <CardStack>        <CardStackContent className="border-t-0">          <CardStackEntryField            label="OpenAPI Spec"            hint={!preview ? "Paste a URL or raw JSON/YAML content." : undefined}          >            <div className="relative">              <Textarea                value={specUrl}                onChange={(e) => {                  setSpecUrl((e.target as HTMLTextAreaElement).value);                  if (preview) {                    setPreview(null);                    setBaseUrl("");                    setCustomHeaders([]);                    setSelectedStrategy(-1);                  }                }}                placeholder="https://api.example.com/openapi.json"                rows={3}                maxRows={10}                className="font-mono text-sm"              />              {analyzing && (                <div className="pointer-events-none absolute right-2 top-2">                  <IOSSpinner className="size-4" />                </div>              )}            </div>          </CardStackEntryField>        </CardStackContent>      </CardStack>       {/* ── Title card (shown below spec input after analysis) ── */}      {preview ? (        <CardStack>          <CardStackContent className="border-t-0">            <CardStackEntry>              {faviconUrl && !faviconFailed && (                <img                  src={faviconUrl}                  alt=""                  className="size-4 shrink-0 object-contain"                  onError={() => setFaviconFailed(true)}                />              )}              <CardStackEntryContent>                <CardStackEntryTitle>                  {Option.getOrElse(preview.title, () => "API")}                </CardStackEntryTitle>                <CardStackEntryDescription>                  {Option.getOrElse(preview.version, () => "")}                  {Option.isSome(preview.version) && " · "}                  {preview.operationCount} operation                  {preview.operationCount !== 1 ? "s" : ""}                  {preview.tags.length > 0 &&                    ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`}                </CardStackEntryDescription>              </CardStackEntryContent>            </CardStackEntry>          </CardStackContent>        </CardStack>      ) : analyzing ? (        <CardStack>          <CardStackContent className="border-t-0">            <CardStackEntry>              <Skeleton className="size-4 shrink-0 rounded" />              <CardStackEntryContent>                <Skeleton className="h-4 w-40" />                <Skeleton className="mt-1 h-3 w-56" />              </CardStackEntryContent>            </CardStackEntry>          </CardStackContent>        </CardStack>      ) : null}       {analyzeError && (        <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">          <p className="text-[12px] text-destructive">{analyzeError}</p>        </div>      )}       {/* ── Everything below appears after analysis ── */}      {preview && (        <>          <SourceIdentityFields identity={identity} />           {/* Base URL */}          <CardStack>            <CardStackContent className="border-t-0">              <CardStackEntryField label="Base URL">                {servers.length > 1 ? (                  <div className="space-y-2">                    <RadioGroup value={baseUrl} onValueChange={setBaseUrl} className="gap-1.5">                      {servers.map((s, i) => {                        const url = s.url ?? "";                        return (                          <Label                            key={i}                            className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${                              baseUrl === url                                ? "border-primary/50 bg-primary/[0.03]"                                : "border-border hover:bg-accent/50"                            }`}                          >                            <RadioGroupItem value={url} />                            <span className="font-mono text-xs text-foreground truncate">                              {url}                            </span>                          </Label>                        );                      })}                    </RadioGroup>                    <Input                      value={baseUrl}                      onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)}                      placeholder="Or enter a custom URL…"                      className="font-mono text-sm"                    />                  </div>                ) : (                  <Input                    value={baseUrl}                    onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)}                    placeholder="https://api.example.com"                    className="font-mono text-sm"                  />                )}                 {!baseUrl.trim() && (                  <p className="text-[11px] text-amber-600 dark:text-amber-400">                    A base URL is required to make requests.                  </p>                )}              </CardStackEntryField>            </CardStackContent>          </CardStack>           <section className="space-y-2.5">            <FieldLabel>Authentication</FieldLabel>            {preview.headerPresets.length > 0 && (              <RadioGroup                value={String(selectedStrategy)}                onValueChange={(value) => selectStrategy(Number(value))}                className="gap-1.5"              >                {preview.headerPresets.map((preset, i) => (                  <Label                    key={i}                    className={`flex items-start gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${                      selectedStrategy === i                        ? "border-primary/50 bg-primary/[0.03]"                        : "border-border hover:bg-accent/50"                    }`}                  >                    <RadioGroupItem value={String(i)} className="mt-0.5" />                    <div className="min-w-0 flex-1">                      <div className="text-xs font-medium text-foreground">{preset.label}</div>                      {preset.secretHeaders.length > 0 && (                        <div className="mt-0.5 font-mono text-[10px] text-muted-foreground">                          {preset.secretHeaders.join(" · ")}                        </div>                      )}                    </div>                  </Label>                ))}                <Label                  className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${                    selectedStrategy === -2                      ? "border-primary/50 bg-primary/[0.03]"                      : "border-border hover:bg-accent/50"                  }`}                >                  <RadioGroupItem value="-2" />                  <span className="text-xs font-medium text-foreground">Custom</span>                </Label>                <Label                  className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${                    selectedStrategy === -1                      ? "border-primary/50 bg-primary/[0.03]"                      : "border-border hover:bg-accent/50"                  }`}                >                  <RadioGroupItem value="-1" />                  <span className="text-xs font-medium text-foreground">None</span>                </Label>              </RadioGroup>            )}            {(preview.headerPresets.length === 0 || selectedStrategy !== -1) && (              <HeadersList                headers={customHeaders}                onHeadersChange={handleHeadersChange}                existingSecrets={secretList}              />            )}          </section>           {/* Add error */}          {addError && (            <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">              <p className="text-[12px] text-destructive">{addError}</p>            </div>          )}        </>      )}       <FloatActions>        <Button variant="ghost" onClick={props.onCancel} disabled={adding}>          Cancel        </Button>        {preview && (          <Button onClick={handleAdd} disabled={!canAdd || adding}>            {adding && <Spinner className="size-3.5" />}            {adding ? "Adding…" : "Add source"}          </Button>        )}      </FloatActions>    </div>  );}