UNPKG

@flanksource/clicky-ui

Version:

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

485 lines (484 loc) 19.8 kB
import { jsx, jsxs } from "react/jsx-runtime"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { FilterBar } from "../components/FilterBar.js"; import { MethodBadge } from "../data/MethodBadge.js"; import { AcceptPicker } from "./AcceptPicker.js"; import { CommandForm, pathParamNames, submitValue } from "./CommandForm.js"; import { CommandOutput } from "./CommandOutput.js"; import { buildInitialParameterValues, packParameterValues, useDebouncedRecord, parametersToFormConfig, pruneParameterValues, titleCase } from "./formMetadata.js"; import { InlineError } from "./InlineError.js"; import { OperationActionDialog } from "./OperationActionDialog.js"; import { isPositionalParam } from "./types.js"; import { useOperationById } from "./useOperations.js"; function OperationCommandPage({ client, operationId, operation: providedOperation, operations = providedOperation ? [providedOperation] : [], initialValues = {}, autoRun = false, backHref, backLabel = "Back", renderLink, onNavigate, hideLockedPathFilters = Boolean(providedOperation), className }) { const lookup = useOperationById(client, providedOperation ? void 0 : operationId); const operation = providedOperation ?? lookup.operation; const isLoading = providedOperation ? false : lookup.isLoading; const [accept, setAccept] = useState("application/clicky+json"); const [previewMode, setPreviewMode] = useState("hidden"); const [isExecuting, setIsExecuting] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [hasAutoRun, setHasAutoRun] = useState(false); const parameters = (operation == null ? void 0 : operation.operation.parameters) ?? []; const parameterSignature = JSON.stringify( parameters.map((param) => { var _a; return { name: param.name, in: param.in, required: param.required ?? false, default: ((_a = param.schema) == null ? void 0 : _a.default) ?? null }; }) ); const operationKey = `${(operation == null ? void 0 : operation.method) ?? ""}:${(operation == null ? void 0 : operation.path) ?? ""}:${(operation == null ? void 0 : operation.operation.operationId) ?? ""}`; const effectiveInitialValues = useMemo( () => operation ? buildInitialParameterValues( parameters, operation.method, {}, stripRunnerParams(initialValues) ) : stripRunnerParams(initialValues), [initialValues, operation == null ? void 0 : operation.method, parameterSignature] ); const pathParameters = parameters.filter((param) => param.in === "path"); const lockedPathValues = useMemo(() => { const values = {}; for (const param of pathParameters) { const value = effectiveInitialValues[param.name]; if (typeof value === "string" && value.trim() !== "") { values[param.name] = value; } } return values; }, [effectiveInitialValues, pathParameters]); const detailOperation = useMemo( () => operation ? findDetailOperation(operation, operations) : void 0, [operation, operations] ); const relatedOperations = useMemo( () => operation ? findRelatedOperations(operation, operations, lockedPathValues) : [], [lockedPathValues, operation, operations] ); async function executeOperation(values) { if (!operation) return; setIsExecuting(true); setError(null); try { const response = await client.executeCommand( operation.path, operation.method, packParameterValues(values, operation.operation.parameters ?? []), { Accept: accept } ); setResult(response); } catch (err) { setResult(null); setError(err); } finally { setIsExecuting(false); } } useEffect(() => { setHasAutoRun(false); setResult(null); setError(null); }, [autoRun, operationKey]); useEffect(() => { if (!autoRun || !operation || hasAutoRun) return; if (operation.method.toUpperCase() === "GET" && parameters.length > 0) return; const missingRequired = parameters.filter((param) => { if (!param.required) return false; return (effectiveInitialValues[param.name] ?? "").trim() === ""; }); if (missingRequired.length > 0) return; setHasAutoRun(true); void executeOperation(effectiveInitialValues); }, [accept, autoRun, effectiveInitialValues, hasAutoRun, operationKey, parameterSignature]); const getRowDetailHref = useCallback( (row) => { if (!detailOperation) return void 0; const id = getClickyRowId(row); if (!id) return void 0; return hrefForOperation(detailOperation, [], { id }); }, [detailOperation] ); const handleTableRowClick = useCallback( (row) => { const href = getRowDetailHref(row); if (href) onNavigate == null ? void 0 : onNavigate(href); }, [getRowDetailHref, onNavigate] ); const backLink = backHref == null ? null : renderLink ? renderLink({ to: backHref, className: "text-sm text-primary underline-offset-4 hover:underline", children: backLabel }) : /* @__PURE__ */ jsx("a", { href: backHref, className: "text-sm text-primary underline-offset-4 hover:underline", children: backLabel }); if (isLoading) { return /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: "Loading operation..." }); } if (!operation) { return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [ /* @__PURE__ */ jsxs("div", { className: "text-sm text-muted-foreground", children: [ "Unknown operation: ", /* @__PURE__ */ jsx("code", { children: operationId }) ] }), backLink ] }); } const { path, method, operation: op } = operation; const preview = buildOperationPreview(operation, effectiveInitialValues, accept, previewMode); return /* @__PURE__ */ jsxs("div", { className: className ?? "min-w-0 flex-1 space-y-6 p-6", children: [ /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-4", children: [ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [ backLink, /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [ /* @__PURE__ */ jsx(MethodBadge, { method }), /* @__PURE__ */ jsx("h1", { className: "truncate text-xl font-bold", children: op.summary || op.operationId || path }) ] }), /* @__PURE__ */ jsx("p", { className: "mt-1 font-mono text-xs text-muted-foreground", children: path }), op.operationId && op.summary && /* @__PURE__ */ jsx("p", { className: "mt-2 font-mono text-xs text-muted-foreground", children: op.operationId }), op.description && op.description !== op.summary && /* @__PURE__ */ jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: op.description }) ] }), /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 flex-col items-end gap-3", children: [ /* @__PURE__ */ jsxs("div", { children: [ /* @__PURE__ */ jsx("div", { className: "mb-1 text-right text-[10px] font-semibold uppercase tracking-wider text-muted-foreground", children: "Accept" }), /* @__PURE__ */ jsx( AcceptPicker, { value: accept, onChange: setAccept, previewMode, onPreviewModeChange: setPreviewMode } ) ] }), relatedOperations.length > 0 && /* @__PURE__ */ jsx("div", { className: "flex flex-wrap justify-end gap-2", children: relatedOperations.map( (related) => related.href ? renderLink ? renderLink({ key: `${related.operation.method}:${related.operation.path}`, to: related.href, className: "inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground", children: related.label }) : /* @__PURE__ */ jsx( "a", { href: related.href, className: "inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground", children: related.label }, `${related.operation.method}:${related.operation.path}` ) : /* @__PURE__ */ jsx( OperationActionDialog, { operation: related.operation, client, initialValues: lockedPathValues, label: related.label, defaultAccept: accept, ...onNavigate ? { onNavigateAction: onNavigate } : {} }, `${related.operation.method}:${related.operation.path}` ) ) }) ] }) ] }), /* @__PURE__ */ jsxs("section", { className: "space-y-3", children: [ method.toUpperCase() === "GET" ? parameters.length > 0 && /* @__PURE__ */ jsx( OperationQueryFilterBar, { client, path, method, parameters, initialValues: effectiveInitialValues, lockedValues: lockedPathValues, hideLocked: hideLockedPathFilters, autoSubmit: autoRun, isSubmitting: isExecuting, onSubmit: executeOperation } ) : /* @__PURE__ */ jsx("div", { className: "rounded-lg border p-4", children: /* @__PURE__ */ jsx( CommandForm, { parameters, onExecute: (params) => executeOperation(params), isPending: isExecuting, method, path, accept, initialValues: effectiveInitialValues } ) }), preview && /* @__PURE__ */ jsx("pre", { className: "overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs", children: preview }) ] }), error ? /* @__PURE__ */ jsx(InlineError, { title: `Failed to load ${path} as ${accept}`, error }) : result ? /* @__PURE__ */ jsx("div", { role: "region", "aria-label": "Response body", children: /* @__PURE__ */ jsx( CommandOutput, { response: result, bare: true, ...detailOperation ? { getTableRowHref: getRowDetailHref, onTableRowClick: handleTableRowClick, isTableRowClickable: (row) => Boolean(getRowDetailHref(row)) } : {} } ) }) : null ] }); } function OperationQueryFilterBar({ client, path, method, parameters, initialValues, lockedValues = {}, hideLocked = false, autoSubmit, isSubmitting, onSubmit }) { const [values, setValues] = useState(initialValues); const [error, setError] = useState(""); const debouncedValues = useDebouncedRecord(values, 250); const resetKey = useMemo( () => `${method}:${path}:${JSON.stringify(initialValues)}:${JSON.stringify(lockedValues)}`, [initialValues, lockedValues, method, path] ); const lastSubmitted = useRef(""); useEffect(() => { setValues(initialValues); setError(""); lastSubmitted.current = ""; }, [resetKey]); const lookupQuery = useQuery({ queryKey: ["operation-query-lookup", method, path, debouncedValues], queryFn: async () => { var _a; return await ((_a = client.lookupFilters) == null ? void 0 : _a.call( client, path, method, packParameterValues(debouncedValues, parameters), { Accept: "application/json+clicky" } )) ?? { filters: {} }; }, enabled: !!client.lookupFilters && parameters.some((param) => param.in === "query"), staleTime: 3e4, retry: 0 }); const formConfig = useMemo( () => parametersToFormConfig(parameters, values, setValues, { lookup: lookupQuery.data, lockedValues, hideLocked }), [hideLocked, lockedValues, lookupQuery.data, parameters, values] ); async function handleSubmit(nextValues = values) { const missingRequired = parameters.filter((param) => { if (!param.required) return false; const value = lockedValues[param.name] ?? nextValues[param.name] ?? ""; return value.trim() === ""; }); if (missingRequired.length > 0) { setError( `Missing required fields: ${missingRequired.map((param) => titleCase(param.name)).join(", ")}` ); return; } setError(""); await onSubmit(pruneParameterValues({ ...nextValues, ...lockedValues })); } useEffect(() => { if (!autoSubmit) return; const next = pruneParameterValues({ ...debouncedValues, ...lockedValues }); const signature = JSON.stringify(next); if (lastSubmitted.current === signature) return; lastSubmitted.current = signature; void handleSubmit(debouncedValues); }, [autoSubmit, debouncedValues, lockedValues]); if (formConfig.filters.length === 0 && formConfig.timeRange == null) { return /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-4", children: [ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "This operation does not require input." }), /* @__PURE__ */ jsx( "button", { type: "button", className: "rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50", disabled: isSubmitting, onClick: () => handleSubmit(), children: isSubmitting ? "Executing..." : "Execute request" } ) ] }); } return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [ /* @__PURE__ */ jsx( FilterBar, { autoSubmit, filters: formConfig.filters, ...!autoSubmit ? { onApply: () => handleSubmit() } : {}, applyLabel: "Execute request", isPending: isSubmitting, ...formConfig.timeRange ? { timeRange: formConfig.timeRange } : {} } ), error && /* @__PURE__ */ jsx("div", { className: "rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive", children: error }) ] }); } function findRelatedOperations(current, operations, pathValues) { if (current.method.toUpperCase() !== "GET") return []; if (!pathTemplateSatisfied(current.path, pathValues)) return []; const basePath = current.path.replace(/\/+$/, ""); return operations.filter((candidate) => { if (candidate === current) return false; if (!candidate.path.startsWith(`${basePath}/`)) return false; if (!pathTemplateSatisfied(candidate.path, pathValues)) return false; const method = candidate.method.toUpperCase(); return method === "GET" || method === "POST" || method === "PUT" || method === "DELETE"; }).map((related) => { const method = related.method.toUpperCase(); const href = method === "GET" ? hrefForOperation(related, [], pathValues) : void 0; return { operation: related, label: operationLabel(related), ...href ? { href } : {} }; }).filter((related) => related.operation.method.toUpperCase() !== "GET" || related.href); } function findDetailOperation(current, operations) { const method = current.method.toUpperCase(); const detailPath = `${current.path.replace(/\/+$/, "")}/{id}`; return operations.find( (candidate) => { var _a; return candidate.method.toUpperCase() === method && candidate.path === detailPath && ((_a = candidate.operation.parameters) == null ? void 0 : _a.some( (param) => param.in === "path" && param.name === "id" && param.required )); } ); } function hrefForOperation(operation, args = [], flags = {}) { let route = apiPathToRoutePath(operation.path); const consumedFlags = /* @__PURE__ */ new Set(); pathParamNames(operation.path).forEach((name, index) => { const value = flags[name] ?? args[index]; if (!value) return; consumedFlags.add(name); route = route.replace(`:${name}`, encodeURIComponent(value)); }); if (route.includes(":")) return void 0; const search = new URLSearchParams(); for (const [key, value] of Object.entries(flags)) { if (value && !consumedFlags.has(key)) search.set(key, value); } const query = search.toString(); return query ? `${route}?${query}` : route; } function apiPathToRoutePath(path) { const cliPath = path.trim().replace(/^\/api\/v1\/?/, "").replace(/^\/+/, "").replace(/\/+$/, ""); if (!cliPath) return "/"; return `/${cliPath.replace(/\{([^}]+)\}/g, ":$1")}`; } function pathTemplateSatisfied(path, values) { return pathParamNames(path).every((name) => Boolean(values[name])); } function operationLabel(operation) { var _a; const actionName = ((_a = operation.operation["x-clicky"]) == null ? void 0 : _a.actionName) || operation.path.split("/").filter(Boolean).at(-1) || operation.operation.operationId || operation.method; return titleCase(actionName.replace(/[_-]+/g, " ")); } function buildOperationPreview(operation, values, accept, mode) { if (mode === "hidden") return ""; if (mode === "cli") return buildCliPreview(operation, values); return buildCurlPreview(operation, values, accept); } function buildCurlPreview(operation, values, accept) { const params = operation.operation.parameters ?? []; const parts = [`curl -X ${operation.method.toUpperCase()}`]; if (accept !== "application/json") parts.push(`-H "Accept: ${accept}"`); let url = operation.path; const queryParts = []; for (const param of params) { const value = submitValue(param, values[param.name]); if (!value) continue; if (param.in === "path") { url = url.replace(`{${param.name}}`, encodeURIComponent(value)); } else if (param.in === "query") { queryParts.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(value)}`); } } if (queryParts.length > 0) url += `?${queryParts.join("&")}`; parts.push(`"${url}"`); return parts.join(" "); } function buildCliPreview(operation, values) { var _a; const command = (_a = operation.operation["x-clicky"]) == null ? void 0 : _a.command; if (!command) return buildCurlPreview(operation, values, "application/json"); const params = operation.operation.parameters ?? []; const positionalNames = new Set(params.filter(isPositionalParam).map((param) => param.name)); const parts = [command.replaceAll("/", " ")]; for (const param of params) { const value = submitValue(param, values[param.name]); if (!value) continue; if (param.in === "path" || positionalNames.has(param.name)) { parts.push(shellQuote(value)); continue; } parts.push(`--${param.name}`, shellQuote(value)); } return parts.join(" "); } function shellQuote(value) { if (/^[a-zA-Z0-9_./:@-]+$/.test(value)) return value; return `'${value.replaceAll("'", "'\\''")}'`; } function stripRunnerParams(values) { const next = {}; for (const [key, value] of Object.entries(values)) { if (key === "autoRun") continue; next[key] = value; } return next; } function getClickyRowId(row) { const candidates = ["_id", "id", "ID", "guid", "GUID"]; for (const key of candidates) { const value = clickyNodeText(row.cells[key]); if (value) return value; } return void 0; } function clickyNodeText(node) { if (!node) return ""; if (node.plain) return node.plain; if (node.text) return node.text; if (node.source) return node.source; if (node.children) return node.children.map(clickyNodeText).join(""); if (node.items) return node.items.map(clickyNodeText).join(" "); if (node.fields) return node.fields.map((field) => clickyNodeText(field.value)).join(" "); return ""; } export { OperationCommandPage }; //# sourceMappingURL=OperationCommandPage.js.map