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