import "./FilterBuilder.css"; import { Button, Checkbox, Dialog, Heading } from "@digdir/designsystemet-react"; import { XMarkIcon } from "@navikt/aksel-icons"; import { canEditSavedFilter, canSetCrossDomain, canShareSavedFilter, createEmptyFilter, findSavedFilter, type FilterTree, getLocalizedString, isEmptyFilter, pruneFilter, type SavedFilter, } from "@olenbetong/appframe-core"; import { type NamedFilterType, setNamedFilter, useFilterConversion, useSavedFilters } from "@olenbetong/appframe-react"; import type { SaveFilterInput } from "@olenbetong/appframe-react"; import type { DataObject } from "@olenbetong/appframe-data"; import clsx from "clsx"; import { type ReactNode, useCallback, useEffect, useState } from "react"; import { FilterEditor } from "./FilterEditor.js"; import { FilterNameDialog } from "./FilterNameDialog.js"; import { FilterShareDialog } from "./FilterShareDialog.js"; import { SavedFilterTree } from "./SavedFilterTree.js"; import type { FilterEditorSize, GetFieldOptions } from "./types.js"; import type { FieldMetadata } from "@olenbetong/appframe-core"; export type FilterBuilderProps = { /** The fields the user can filter on. Fields with `excludeFromFilter` are hidden. */ fields: FieldMetadata[]; /** * The view the filter applies to. Used both for conversion and, unless * `dbObjectId` is set, as the key saved filters are stored under. */ viewName?: string; /** Overrides `viewName` as the key saved filters are stored under. */ dbObjectId?: string; /** Loads the filters saved for a report instead of for a view. */ reportId?: string; getFieldOptions?: GetFieldOptions; /** * `"dialog"` wraps the builder in a modal, `"inline"` renders it in place. * @default "dialog" */ variant?: "dialog" | "inline"; /** Whether the dialog is open. Ignored when `variant` is `"inline"`. */ open?: boolean; /** Called when the dialog is dismissed, and after the filter is applied. */ onClose?: () => void; /** The filter tree. Use together with `onChange` for a controlled component. */ value?: FilterTree | null; /** The initial filter tree when uncontrolled. */ defaultValue?: FilterTree | null; /** Called on every edit. */ onChange?: (filter: FilterTree) => void; /** * Called when the user applies the filter. The tree is pruned, so incomplete * conditions are dropped and an empty filter is reported as `null`. */ onApply?: (filter: FilterTree | null, filterString: string) => void; /** * Applies the filter to a data object when the user confirms. The filter is * written to a named slot, so it composes with other filter providers on the * same data object instead of overwriting them. */ dataObject?: DataObject; /** * Parameter the filter is applied to. * @default "filterObject" */ filterType?: NamedFilterType; /** * Name of the slot the filter is applied to. * @default "filterBuilder" */ slotName?: string; /** * Shows the filter string below the tree. * @default true */ showFilterString?: boolean; /** Called whenever the filter string is reconverted. */ onFilterStringChange?: (filterString: string) => void; /** Called when a saved filter is selected, and with `null` for "New Filter". */ onSelect?: (filter: SavedFilter | null) => void; /** * Extra columns written along with the filter when it is saved. The report * launcher stores its header text, its hide-criteria flag and its parameter * values here. */ saveValues?: SavedFilterValues; /** * Hides the OK/Cancel row, for callers that supply their own actions. The * saved-filter toolbar stays. */ hideActions?: boolean; /** * Replaces the OK/Cancel buttons, so a caller's own actions share the row * with the buttons acting on the selected saved filter. */ actions?: ReactNode; /** * Wraps the criteria editor, for callers that put it beside content of their * own — the report launcher puts it in a tab next to the report parameters. * Whatever is returned fills the column next to the saved-filter list. */ renderMain?: (editor: ReactNode) => ReactNode; /** Hides the saved-filter list, leaving just the criteria editor. */ hideSavedFilters?: boolean; className?: string; "data-size"?: FilterEditorSize; }; /** The parts of a saved filter the builder writes on behalf of a caller. */ export type SavedFilterValues = Pick; /** * The web port of the Windows filter builder: the saved-filter list, the * criteria editor and the save/share toolbar. * * Saved filters are stored as criteria strings, so selecting one round-trips * through the conversion endpoint to rebuild the tree, and saving converts the * tree back. * * @example * ```jsx * setOpen(false)} * /> * ``` */ export function FilterBuilder({ fields, viewName, dbObjectId, reportId, getFieldOptions, variant = "dialog", open = true, onClose, value, defaultValue, onChange, onApply, dataObject, filterType = "filterObject", slotName = "filterBuilder", showFilterString = true, onFilterStringChange, onSelect, saveValues, hideActions, actions, renderMain, hideSavedFilters, className, "data-size": size = "sm", }: FilterBuilderProps) { let resolvedId = dbObjectId ?? viewName; let { mine, shared, filters, loading, save, rename, remove, setCrossDomain } = useSavedFilters({ dbObjectId: reportId ? undefined : resolvedId, reportId, }); let [uncontrolled, setUncontrolled] = useState(() => defaultValue ?? createEmptyFilter()); let isControlled = value !== undefined; let filter = isControlled ? (value ?? createEmptyFilter()) : uncontrolled; let [selectedId, setSelectedId] = useState(null); let [dirty, setDirty] = useState(false); let [saving, setSaving] = useState(false); let [sharing, setSharing] = useState(false); let [error, setError] = useState(null); let { filterString, isConverting, parseFilterString } = useFilterConversion(filter, { viewName }); let selected = findSavedFilter(filters, selectedId); useEffect(() => { onFilterStringChange?.(filterString); }, [filterString, onFilterStringChange]); let update = useCallback( (next: FilterTree) => { if (!isControlled) setUncontrolled(next); onChange?.(next); setDirty(true); }, [isControlled, onChange], ); function applyFilter(next: FilterTree, markClean: boolean) { if (!isControlled) setUncontrolled(next); onChange?.(next); if (markClean) setDirty(false); } async function handleSelect(filterId: number | null) { setSelectedId(filterId); setError(null); if (filterId === null) { applyFilter(createEmptyFilter(), true); onSelect?.(null); return; } let savedFilter = findSavedFilter(filters, filterId); if (!savedFilter) return; onSelect?.(savedFilter); // Filters are stored as criteria strings, so the tree has to be rebuilt. let parsed = await parseFilterString(savedFilter.criteria); applyFilter(parsed ?? createEmptyFilter(), true); } async function handleSave(name: string) { setSaving(false); setError(null); try { // Saving under an existing name of the user's own replaces that // filter rather than creating a duplicate. let existing = mine.find((candidate) => candidate.name === name); let saved = await save({ ...saveValues, filterId: existing?.filterId ?? null, name, criteria: filterString, crossDomain: existing?.crossDomain ?? undefined, }); if (saved) setSelectedId(saved.filterId); setDirty(false); } catch (cause) { setError(String((cause as Error)?.message ?? cause)); } } async function handleOverwrite() { if (!selected) return; setError(null); try { await save({ ...saveValues, filterId: selected.filterId, name: selected.name, criteria: filterString }); setDirty(false); } catch (cause) { setError(String((cause as Error)?.message ?? cause)); } } function handleRename(savedFilter: SavedFilter) { let name = globalThis.prompt?.(getLocalizedString("Filter name"), savedFilter.name); if (name?.trim()) void rename(savedFilter.filterId, name.trim()); } function handleDelete(savedFilter: SavedFilter) { let message = getLocalizedString("Delete the filter {0}?").replace("{0}", savedFilter.name); if (!globalThis.confirm?.(message)) return; void remove(savedFilter.filterId).then(() => { if (savedFilter.filterId === selectedId) void handleSelect(null); }); } function handleApply() { let pruned = pruneFilter(filter); if (dataObject) { setNamedFilter(dataObject, slotName, pruned, { type: filterType }); } onApply?.(pruned, filterString); onClose?.(); } let isEmpty = isEmptyFilter(filter); let canOverwrite = Boolean(selected) && canEditSavedFilter(selected as SavedFilter) && dirty && !isEmpty; let editor = ( ); let content = (
{!hideSavedFilters && (
void handleSelect(filterId)} onRename={handleRename} onDelete={handleDelete} />
)}
{renderMain ? renderMain(editor) : editor}
{error && (

{error}

)}
{!hideSavedFilters && (
{ if (selected) void setCrossDomain(selected.filterId, event.currentTarget.checked); }} />
)} {!hideActions && (
{actions ?? ( <> )}
)}
candidate.name)} data-size={size} onCancel={() => setSaving(false)} onSubmit={(name) => void handleSave(name)} /> setSharing(false)} />
); if (variant === "inline") return content; return ( {/* * `Dialog`'s own close button is a floated first child of the dialog, * which a flex layout takes out of its float and stacks above the * heading. Rendering it in the header instead keeps it aligned. */} {getLocalizedString("Edit filter")} {content} ); }