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