UNPKG

@flanksource/clicky-ui

Version:

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

393 lines (392 loc) 14.4 kB
import { jsxs, jsx, Fragment } from "react/jsx-runtime"; import { useMemo, useState, useRef, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button } from "../components/button.js"; import { FilterPill } from "../data/FilterPill.js"; import { Icon } from "../data/Icon.js"; import { cn } from "../lib/utils.js"; import { buildInitialParameterValues, useDebouncedRecord, packParameterValues, parametersToFormConfig, pruneParameterValues, titleCase } from "./formMetadata.js"; function FilterForm({ client, path, method, parameters = [], initialValues = {}, lockedValues = {}, hideLocked = false, enableLookup = method.toUpperCase() === "GET", autoSubmit = false, submitLabel = "Execute request", submittingLabel = "Executing…", emptyMessage = "This operation does not require input.", isSubmitting = false, className, onSubmit }) { const resetKey = useMemo( () => `${method}:${path}:${JSON.stringify(initialValues)}:${JSON.stringify(lockedValues)}`, [initialValues, lockedValues, method, path] ); const [values, setValues] = useState( () => buildInitialParameterValues(parameters, method, lockedValues, initialValues) ); const [error, setError] = useState(""); const debouncedValues = useDebouncedRecord(values, 250); const lastAutoSubmitted = useRef(null); const lookupQuery = useQuery({ queryKey: ["filter-form-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: enableLookup && !!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] ); const hasFields = formConfig.filters.length > 0 || formConfig.timeRange != null; async function handleSubmit(event) { event == null ? void 0 : event.preventDefault(); const missingRequired = parameters.filter((param) => { if (!param.required) return false; const value = lockedValues[param.name] ?? values[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(values)); } useEffect(() => { setValues(buildInitialParameterValues(parameters, method, lockedValues, initialValues)); setError(""); lastAutoSubmitted.current = null; }, [resetKey]); useEffect(() => { if (!autoSubmit) { return; } const missingRequired = parameters.filter((param) => { if (!param.required) return false; const value = lockedValues[param.name] ?? debouncedValues[param.name] ?? ""; return value.trim() === ""; }); if (missingRequired.length > 0) { return; } const submittedValues = pruneParameterValues(debouncedValues); const signature = JSON.stringify(submittedValues); if (lastAutoSubmitted.current === signature) { return; } lastAutoSubmitted.current = signature; setError(""); void onSubmit(submittedValues); }, [autoSubmit, debouncedValues, lockedValues, onSubmit, parameters]); return /* @__PURE__ */ jsxs("form", { className: "space-y-3", onSubmit: handleSubmit, children: [ hasFields ? /* @__PURE__ */ jsx( ParameterGrid, { autoSubmit, filters: formConfig.filters, isSubmitting, submitLabel, submittingLabel, ...formConfig.timeRange ? { timeRange: formConfig.timeRange } : {}, ...className ? { className } : {} } ) : /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-4", children: [ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: emptyMessage }), /* @__PURE__ */ jsx(Button, { type: "button", onClick: handleSubmit, disabled: isSubmitting, children: isSubmitting ? submittingLabel : submitLabel }) ] }), error && /* @__PURE__ */ jsx("div", { className: "rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive", children: error }) ] }); } function ParameterGrid({ filters, timeRange, autoSubmit, isSubmitting, submitLabel, submittingLabel, className }) { return /* @__PURE__ */ jsxs("div", { className: cn("space-y-3", className), children: [ /* @__PURE__ */ jsxs("div", { className: "divide-y divide-border border-y border-border", children: [ filters.map((filter) => /* @__PURE__ */ jsx(ParameterRow, { filter }, filter.key)), timeRange && /* @__PURE__ */ jsx(TimeRangeRow, { timeRange }) ] }), !autoSubmit && /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx(Button, { type: "submit", disabled: isSubmitting, children: isSubmitting ? submittingLabel : submitLabel }) }) ] }); } function ParameterRow({ filter }) { const id = `clicky-param-${filter.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`; return /* @__PURE__ */ jsxs( "div", { title: filter.description, className: cn( "grid grid-cols-1 gap-2 py-2 sm:grid-cols-[minmax(8rem,14rem)_minmax(0,1fr)] sm:items-center", filter.disabled && "opacity-60" ), children: [ /* @__PURE__ */ jsx("label", { htmlFor: id, className: "text-sm font-medium text-muted-foreground", children: filter.label }), /* @__PURE__ */ jsx("div", { className: "min-w-0", children: renderParameterInput(filter, id) }) ] } ); } function renderParameterInput(filter, id) { if (filter.kind === "enum") { return /* @__PURE__ */ jsxs( "select", { id, "aria-label": filter.label, className: inputClassName, value: filter.value, disabled: filter.disabled, onChange: (event) => filter.onChange(event.target.value), children: [ /* @__PURE__ */ jsx("option", { value: "", children: filter.placeholder ?? `Any ${filter.label.toLowerCase()}` }), filter.options.map((option) => /* @__PURE__ */ jsx("option", { value: option.value, children: option.label ?? option.value }, option.value)) ] } ); } if (filter.kind === "boolean") { return /* @__PURE__ */ jsx("div", { className: "flex h-8 items-center", children: /* @__PURE__ */ jsx( "input", { id, type: "checkbox", "aria-label": filter.label, className: "h-4 w-4 accent-primary disabled:cursor-not-allowed", checked: filter.value, disabled: filter.disabled, onChange: (event) => filter.onChange(event.target.checked) } ) }); } if (filter.kind === "lookup") { const listId = `${id}-options`; return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx( "input", { id, type: filter.inputType === "number" ? "number" : "text", "aria-label": filter.label, className: inputClassName, placeholder: filter.placeholder ?? "Value", value: filter.value, list: listId, disabled: filter.disabled, onChange: (event) => filter.onChange(event.target.value) } ), /* @__PURE__ */ jsx("datalist", { id: listId, children: filter.options.map((option) => /* @__PURE__ */ jsx( "option", { value: option.value, label: option.label ?? option.value, disabled: option.disabled }, option.value )) }) ] }); } if (filter.kind === "lookup-multi") { const listId = `${id}-options`; return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx( "input", { id, type: "text", "aria-label": filter.label, className: inputClassName, placeholder: filter.placeholder ?? "value-1, value-2", value: filter.value.join(", "), list: listId, disabled: filter.disabled, onChange: (event) => filter.onChange(splitCommaValues(event.target.value)) } ), /* @__PURE__ */ jsx("datalist", { id: listId, children: filter.options.map((option) => /* @__PURE__ */ jsx( "option", { value: option.value, label: option.label ?? option.value, disabled: option.disabled }, option.value )) }) ] }); } if (filter.kind === "multi") { return /* @__PURE__ */ jsx(MultiParameterInput, { filter, id }); } if (filter.kind === "text") { return /* @__PURE__ */ jsx( "input", { id, type: "text", "aria-label": filter.label, className: inputClassName, placeholder: filter.placeholder ?? "Value", value: filter.value, disabled: filter.disabled, onChange: (event) => filter.onChange(event.target.value) } ); } return /* @__PURE__ */ jsx( "input", { id, type: "text", "aria-label": filter.label, className: inputClassName, value: JSON.stringify(filter.value), disabled: true, readOnly: true } ); } function MultiParameterInput({ filter, id }) { const [query, setQuery] = useState(""); const showOptionFilter = filter.options.length > 7; const visibleOptions = useMemo(() => { const normalized = query.trim().toLowerCase(); if (!normalized) return filter.options; return filter.options.filter( (option) => multiOptionText(option).toLowerCase().includes(normalized) ); }, [filter.options, query]); const setMode = (value, mode) => { if (filter.disabled) return; const next = { ...filter.value }; if (mode === "include" || mode === "exclude") { next[value] = mode; } else { delete next[value]; } filter.onChange(next); }; return /* @__PURE__ */ jsxs( "div", { id, role: "group", "aria-label": filter.label, className: "rounded-md border border-input bg-background p-2", children: [ showOptionFilter && /* @__PURE__ */ jsxs("label", { className: "mb-2 flex items-center gap-2 rounded-md border border-input bg-background px-2", children: [ /* @__PURE__ */ jsx(Icon, { name: "codicon:search", className: "shrink-0 text-muted-foreground" }), /* @__PURE__ */ jsx( "input", { type: "search", "aria-label": `Filter ${filter.label} options`, className: "h-8 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground", placeholder: `Filter ${filter.label.toLowerCase()}`, value: query, disabled: filter.disabled, onChange: (event) => setQuery(event.target.value) } ) ] }), /* @__PURE__ */ jsxs("div", { className: "max-h-56 space-y-1 overflow-auto", children: [ visibleOptions.map((option) => { const mode = filter.value[option.value] ?? "neutral"; return /* @__PURE__ */ jsx( FilterPill, { className: "w-full justify-between", label: option.label, mode, title: option.title ?? multiOptionText(option), togglePosition: "right", onModeChange: (next) => setMode(option.value, next) }, option.value ); }), visibleOptions.length === 0 && /* @__PURE__ */ jsx("div", { className: "px-2 py-3 text-sm text-muted-foreground", children: "No options found" }) ] }) ] } ); } function multiOptionText(option) { const label = typeof option.label === "string" ? option.label : ""; return [option.value, label, option.title ?? ""].filter(Boolean).join(" "); } function TimeRangeRow({ timeRange }) { const fromId = "clicky-param-from"; const toId = "clicky-param-to"; const from = timeRange.from ?? ""; const to = timeRange.to ?? ""; return /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-1 gap-2 py-2 sm:grid-cols-[minmax(8rem,14rem)_minmax(0,1fr)] sm:items-center", children: [ /* @__PURE__ */ jsx("div", { className: "text-sm font-medium text-muted-foreground", children: "Time range" }), /* @__PURE__ */ jsxs("div", { className: "grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2", children: [ /* @__PURE__ */ jsxs("label", { className: "min-w-0", children: [ /* @__PURE__ */ jsx("span", { className: "sr-only", children: "From" }), /* @__PURE__ */ jsx( "input", { id: fromId, type: "text", "aria-label": "From", className: inputClassName, placeholder: timeRange.fromPlaceholder ?? "From", value: from, onChange: (event) => timeRange.onApply(event.target.value, to) } ) ] }), /* @__PURE__ */ jsxs("label", { className: "min-w-0", children: [ /* @__PURE__ */ jsx("span", { className: "sr-only", children: "To" }), /* @__PURE__ */ jsx( "input", { id: toId, type: "text", "aria-label": "To", className: inputClassName, placeholder: timeRange.toPlaceholder ?? "To", value: to, onChange: (event) => timeRange.onApply(from, event.target.value) } ) ] }) ] }) ] }); } function splitCommaValues(value) { return value.split(",").map((item) => item.trim()).filter(Boolean); } const inputClassName = "h-8 w-full min-w-0 rounded-md border border-input bg-background px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring"; export { FilterForm }; //# sourceMappingURL=FilterForm.js.map