UNPKG

@flanksource/clicky-ui

Version:

Flanksource Clicky UI — React component library built on shadcn/ui with light/dark and density theming.

340 lines (339 loc) 14.7 kB
import { jsxs, jsx, Fragment } from "react/jsx-runtime"; import { useState, useMemo, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button } from "../components/button.js"; import { FilterBar } from "../components/FilterBar.js"; import { Icon } from "../data/Icon.js"; import { MethodBadge } from "../data/MethodBadge.js"; import { Modal } from "../overlay/Modal.js"; import { filterOperationsByDomain, findListEndpoint } from "./classify.js"; import { filterOperationsBySurface, findSurfaceListOperation, findSurfaceCollectionActions, getOperationClickyMeta } from "./clickyMetadata.js"; import { EndpointList } from "./EndpointList.js"; import { ExecutionResult } from "./ExecutionResult.js"; import { FilterForm } from "./FilterForm.js"; import { isPositionalParam } from "./types.js"; import { useOperations } from "./useOperations.js"; import { packParameterValues, parametersToFormConfig } from "./formMetadata.js"; const defaultCommandHref = (operationId) => `/commands/${operationId}`; function defaultRenderError(err, title) { const message = err instanceof Error ? err.message : String(err ?? ""); return /* @__PURE__ */ jsxs("div", { className: "rounded-xl border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive", children: [ /* @__PURE__ */ jsx("div", { className: "font-medium", children: title }), message && /* @__PURE__ */ jsx("div", { className: "mt-1 whitespace-pre-wrap text-xs opacity-80", children: message }) ] }); } function OperationCatalog({ definition, entities, client, renderLink, allOperations = false, operationIdPrefix, listOperationId, surfaceKey, getCommandHref = defaultCommandHref, renderError = defaultRenderError, kind, commandRuntime }) { const { operations, isLoading } = useOperations(client); const [view, setView] = useState("table"); const [activeAction, setActiveAction] = useState(null); const [actionResult, setActionResult] = useState(null); const [actionError, setActionError] = useState(""); const [isExecutingAction, setIsExecutingAction] = useState(false); const surfaceOps = useMemo( () => filterOperationsBySurface(operations, surfaceKey), [operations, surfaceKey] ); const useSurfaceMetadata = surfaceOps.length > 0; const domainOps = useMemo(() => { if (useSurfaceMetadata) { return surfaceOps; } return (allOperations ? operations : filterOperationsByDomain(operations, entities)).filter( (op) => operationIdPrefix ? (op.operation.operationId ?? "").startsWith(operationIdPrefix) : true ); }, [allOperations, entities, operationIdPrefix, operations, surfaceOps, useSurfaceMetadata]); const listEndpoint = useMemo(() => { if (useSurfaceMetadata) { return findSurfaceListOperation(domainOps, surfaceKey); } return listOperationId ? domainOps.find((op) => op.method === "get" && op.operation.operationId === listOperationId) : findListEndpoint(domainOps, entities); }, [domainOps, entities, listOperationId, surfaceKey, useSurfaceMetadata]); const [filters, setFilters] = useState(() => readFiltersFromUrl()); const listParameters = (listEndpoint == null ? void 0 : listEndpoint.operation.parameters) ?? []; useEffect(() => { writeFiltersToUrl(filters); }, [filters]); const listQuery = useQuery({ queryKey: ["operation-list", listEndpoint == null ? void 0 : listEndpoint.method, listEndpoint == null ? void 0 : listEndpoint.path, filters], queryFn: () => client.executeCommand( listEndpoint.path, listEndpoint.method, packParameterValues(filters, listEndpoint.operation.parameters ?? []), { Accept: "application/json+clicky" } ), enabled: !!listEndpoint && view === "table", staleTime: 3e4, retry: 0 }); const lookupQuery = useQuery({ queryKey: ["operation-lookup", listEndpoint == null ? void 0 : listEndpoint.method, listEndpoint == null ? void 0 : listEndpoint.path, filters], queryFn: async () => { var _a; return await ((_a = client.lookupFilters) == null ? void 0 : _a.call( client, listEndpoint.path, listEndpoint.method, packParameterValues(filters, listParameters), { Accept: "application/json+clicky" } )) ?? { filters: {} }; }, enabled: !!listEndpoint && view === "table" && !!client.lookupFilters, staleTime: 3e4, retry: 0 }); const actionOps = useMemo(() => { if (useSurfaceMetadata) { return findSurfaceCollectionActions(domainOps, surfaceKey); } return domainOps.filter( (op) => { var _a; return op.method !== "get" && !op.path.includes("{") && !((_a = op.operation.parameters) == null ? void 0 : _a.some((p) => p.in === "path" || isPositionalParam(p))); } ); }, [domainOps, surfaceKey, useSurfaceMetadata]); const filterBarConfig = useMemo(() => { const options = { includeLocations: ["query"] }; if (lookupQuery.data != null) { options.lookup = lookupQuery.data; } return parametersToFormConfig(listParameters, filters, setFilters, options); }, [filters, listParameters, lookupQuery.data]); const hasTable = !!listEndpoint; const showTable = hasTable && view === "table"; const sectionLabel = kind === "configuration" || definition.key.startsWith("config-") ? "Configuration" : "Operations"; const activeActionMeta = activeAction ? getOperationClickyMeta(activeAction) : void 0; const actionLockedValues = useMemo(() => { const meta = activeActionMeta; if (!activeAction || meta == null || !meta.supportsFilterMode) { return {}; } const locked = {}; for (const param of activeAction.operation.parameters ?? []) { const value = filters[param.name]; if (param.in === "query" && value) { locked[param.name] = value; } } if (meta.idParam) { locked[meta.idParam] = "all"; } if ((activeAction.operation.parameters ?? []).some((param) => param.name === "filter")) { locked.filter = Object.entries(filters).filter(([, value]) => value).map(([key, value]) => `${key}=${value}`).join(", ") || "current list filters"; } return locked; }, [activeAction, activeActionMeta, filters]); async function executeAction(values) { if (!activeAction) return; setIsExecutingAction(true); setActionError(""); try { const response = await client.executeCommand( activeAction.path, activeAction.method, packParameterValues(values, activeAction.operation.parameters ?? []), { Accept: "application/json+clicky" } ); setActionResult(response); await listQuery.refetch(); } catch (err) { setActionResult(null); setActionError(err instanceof Error ? err.message : String(err ?? "Unknown error")); } finally { setIsExecutingAction(false); } } if (isLoading) { return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [ /* @__PURE__ */ jsx(SkeletonBlock, { className: "h-10 w-72" }), /* @__PURE__ */ jsx(SkeletonBlock, { className: "h-4 w-[32rem]" }), Array.from({ length: 5 }).map((_, i) => /* @__PURE__ */ jsx(SkeletonBlock, { className: "h-12" }, i)) ] }); } return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [ /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-4", children: [ /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [ /* @__PURE__ */ jsx("p", { className: "text-xs uppercase tracking-[0.18em] text-muted-foreground", children: sectionLabel }), /* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold tracking-tight", children: definition.title }), /* @__PURE__ */ jsx("p", { className: "mt-1 max-w-2xl text-sm text-muted-foreground", children: definition.description }) ] }), hasTable && /* @__PURE__ */ jsxs("div", { className: "flex gap-1 rounded-lg border p-1", children: [ /* @__PURE__ */ jsx( Button, { type: "button", variant: view === "table" ? "secondary" : "ghost", size: "sm", className: "h-7 w-7 p-0", "aria-label": "Table view", "aria-pressed": view === "table", onClick: () => setView("table"), children: /* @__PURE__ */ jsx(Icon, { name: "codicon:table" }) } ), /* @__PURE__ */ jsx( Button, { type: "button", variant: view === "endpoints" ? "secondary" : "ghost", size: "sm", className: "h-7 w-7 p-0", "aria-label": "Endpoint list view", "aria-pressed": view === "endpoints", onClick: () => setView("endpoints"), children: /* @__PURE__ */ jsx(Icon, { name: "codicon:list-flat" }) } ) ] }) ] }), actionOps.length > 0 && /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: actionOps.map((op) => { var _a; const commandName = op.operation.operationId || op.path; const tooltip = op.operation.description ? `${commandName} — ${op.operation.description}` : commandName; if (useSurfaceMetadata) { return /* @__PURE__ */ jsxs( Button, { type: "button", variant: "outline", size: "sm", title: tooltip, onClick: () => { setActiveAction(op); setActionResult(null); setActionError(""); }, children: [ /* @__PURE__ */ jsx(Icon, { name: "codicon:add", className: "text-xs" }), op.operation.summary || ((_a = getOperationClickyMeta(op)) == null ? void 0 : _a.actionName) || commandName ] }, `${op.method}:${op.path}` ); } return renderLink({ key: `${op.method}:${op.path}`, to: getCommandHref(op.operation.operationId ?? commandName, op), title: tooltip, className: "inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground", children: /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(Icon, { name: "codicon:add", className: "text-xs" }), op.operation.summary || commandName ] }) }); }) }), showTable ? /* @__PURE__ */ jsxs(Fragment, { children: [ (filterBarConfig.filters.length > 0 || filterBarConfig.timeRange != null) && /* @__PURE__ */ jsx( FilterBar, { autoSubmit: true, isPending: listQuery.isFetching, filters: filterBarConfig.filters, ...filterBarConfig.timeRange ? { timeRange: filterBarConfig.timeRange } : {} } ), listQuery.isLoading ? /* @__PURE__ */ jsx("div", { className: "space-y-2", children: Array.from({ length: 8 }).map((_, i) => /* @__PURE__ */ jsx(SkeletonBlock, { className: "h-10" }, i)) }) : listQuery.isError ? renderError(listQuery.error, `Failed to load ${(listEndpoint == null ? void 0 : listEndpoint.path) ?? ""}`) : listQuery.data ? /* @__PURE__ */ jsx( ExecutionResult, { response: listQuery.data, emptyMessage: "No records returned", ariaLabel: `${definition.title} results`, className: "mt-0", ...commandRuntime ? { commandRuntime } : {} } ) : null ] }) : /* @__PURE__ */ jsx( EndpointList, { operations: domainOps, definition, renderLink, getCommandHref } ), /* @__PURE__ */ jsx( Modal, { open: activeAction != null, onClose: () => setActiveAction(null), title: (activeAction == null ? void 0 : activeAction.operation.summary) || (activeActionMeta == null ? void 0 : activeActionMeta.actionName) || (activeAction == null ? void 0 : activeAction.operation.operationId) || "Action", size: "lg", children: activeAction && /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [ /* @__PURE__ */ jsx(MethodBadge, { method: activeAction.method }), /* @__PURE__ */ jsx("code", { className: "rounded-md bg-muted px-2 py-1 text-sm", children: activeAction.path }) ] }), /* @__PURE__ */ jsx( FilterForm, { client, path: activeAction.path, method: activeAction.method, parameters: activeAction.operation.parameters ?? [], lockedValues: actionLockedValues, enableLookup: Boolean(activeActionMeta == null ? void 0 : activeActionMeta.supportsLookup), submitLabel: "Execute request", submittingLabel: "Executing…", isSubmitting: isExecutingAction, onSubmit: executeAction } ), actionError ? /* @__PURE__ */ jsx("div", { className: "rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive", children: actionError }) : actionResult ? /* @__PURE__ */ jsx( ExecutionResult, { response: actionResult, className: "mt-0", ...commandRuntime ? { commandRuntime } : {} } ) : null ] }) } ) ] }); } function SkeletonBlock({ className }) { return /* @__PURE__ */ jsx("div", { className: `animate-pulse rounded-md bg-muted ${className ?? ""}`.trim() }); } function readFiltersFromUrl() { if (typeof window === "undefined") return {}; const search = new URLSearchParams(window.location.search); const values = {}; for (const [key, value] of search.entries()) { if (key.startsWith("__")) continue; if (value !== "") values[key] = value; } return values; } function writeFiltersToUrl(filters) { if (typeof window === "undefined") return; const search = new URLSearchParams(); for (const [key, value] of Object.entries(filters)) { if (value !== "") search.set(key, value); } const query = search.toString(); const next = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`; if (next !== `${window.location.pathname}${window.location.search}${window.location.hash}`) { window.history.replaceState(window.history.state, "", next); } } export { OperationCatalog }; //# sourceMappingURL=OperationCatalog.js.map