@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
JavaScript
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