'use client'; /* Origin: ticket-management (96S2) `tickets/FilterPanel.tsx`. */ import { useEffect, useRef, useState } from 'react'; import { Check, ChevronDown, Filter, Plus, X } from 'lucide-react'; import { DATE_MODE_OPTIONS, KIND, MAX_CONDITIONS, OPERATOR_LABELS, OP, dateModeOf, isValueless, operatorsFor, parseMultiValue, relativeDateValue, toMultiValue, type Condition, type DateMode, type FilterField, type FilterFieldOption, type MatchMode, type Operator, } from '@/lib/conditions'; import { cn } from '@/lib/cn'; import { PANEL_EMPTY_CLASS, PANEL_REMOVE_CLASS, TOOLBAR_BADGE_CLASS, TOOLBAR_PANEL_CLASS, panelAddButtonClass, toolbarButtonClass, } from '@/lib/toolbar'; import { useDismiss } from '@/lib/use-dismiss'; import { InfoTooltip } from '@/components/overlay/Tooltip'; import OptionPill, { optionTone } from '@/components/data/OptionPill'; /** * The filter panel — `[field] [operator] [value]` rows, matching all or any. * The Lark Base funnel. * * ── Why it holds a DRAFT ──────────────────────────────────────────────────── * * A condition is built left to right: picking the field alone would refilter * the list, then the operator again, then each keystroke of the value again. * So the panel edits a local draft and commits on Apply — the same bargain * `CombinedSelect` makes. A half-built row is kept on screen and * simply not applied: `conditionsMatch` skips any valued operator with an * empty value, so choosing a field and pausing to think does not narrow the * list under you. * * CLOSE WITHOUT APPLYING — REVERTS. Closing by any path that is not the Apply * button (outside click, Escape, the trigger again) discards the draft and * puts it back to the last APPLIED value, so reopening a moment later shows * what is actually live rather than whatever was half-typed last time. * * `ConditionRow` and `ValueInput` are EXPORTED: `ColorRulesPanel` builds the * same predicate with a colour on the end, and `ConditionGroupsBuilder` lays * the same rows out in OR'd groups. Shared rather than copied, because the * options-vs-free-text fallback and the valueless case must be identical in * all three and two copies would drift the first time one gained a kind. */ export default function FilterPanel({ fields, conditions, match, onApply, title = 'Filter', width = 580, className, }: { fields: FilterField[]; conditions: Condition[]; match: MatchMode; /** Fired on Apply and on Clear all — never per keystroke. */ onApply: (conditions: Condition[], match: MatchMode) => void; /** The panel's heading. */ title?: string; /** * Measured, not chosen: the fixed skeleton of a row (joiner, field select, * operator select, remove button, gaps, padding) is 430px, and the value * column needs 150 to show a date input and its placeholder in full. */ width?: number; className?: string; }) { const [open, setOpen] = useState(false); const [draft, setDraft] = useState(conditions); const [draftMatch, setDraftMatch] = useState(match); const root = useRef(null); // Re-seed whenever the committed value changes underneath — a back button, // or a "clear everything" from elsewhere, must not leave a stale draft. useEffect(() => { setDraft(conditions); setDraftMatch(match); }, [conditions, match]); const revertAndClose = () => { setDraft(conditions); setDraftMatch(match); setOpen(false); }; useDismiss(root, open, revertAndClose); const setAt = (i: number, patch: Partial) => setDraft((prev) => prev.map((c, j) => (j === i ? { ...c, ...patch } : c))); const removeAt = (i: number) => setDraft((prev) => prev.filter((_, j) => j !== i)); const add = () => { const blank = blankCondition(fields); if (blank) setDraft((prev) => [...prev, blank]); }; const changeField = (i: number, fieldId: string) => { const next = nextConditionForField(fields, draft[i], fieldId); if (next) setDraft((prev) => prev.map((c, j) => (j === i ? next : c))); }; const apply = () => { onApply(draft, draftMatch); setOpen(false); }; const clear = () => { setDraft([]); onApply([], 'all'); setOpen(false); }; const label = conditions.length ? `${title} — ${conditions.length} condition${conditions.length === 1 ? '' : 's'}` : title; return (
{open && (
{title} Matching of the conditions
{draft.length === 0 &&

No conditions. Every row is shown.

}
{draft.map((c, i) => ( changeField(i, fieldId)} onChangeOp={(op) => setAt(i, { op, value: '' })} onChangeValue={(v) => setAt(i, { value: v })} onRemove={() => removeAt(i)} /> ))}
{draft.length >= MAX_CONDITIONS && ( )}
)}
); } /** "Where" on the first row, then the match mode's own word — so a list reads as one sentence. */ export function joinerFor(index: number, match: MatchMode): string { return index === 0 ? 'Where' : match === 'any' ? 'or' : 'and'; } /** * Changing the FIELD can invalidate the operator — "contains" means nothing on * a number, ">" nothing on a select. The operator falls back to the new kind's * first, and the value is cleared, because a category id is not a status * name. `null` when the field id is unknown, so the caller leaves the row alone. */ export function nextConditionForField( fields: readonly FilterField[], current: Condition, fieldId: string, ): Condition | null { const field = fields.find((f) => f.id === fieldId); if (!field) return null; const ops = operatorsFor(field.kind); return { field: fieldId, op: ops.includes(current.op) ? current.op : ops[0], value: '' }; } /** A fresh, half-built row on the first field — what "+ Add condition" appends. */ export function blankCondition(fields: readonly FilterField[]): Condition | null { const first = fields[0]; return first ? { field: first.id, op: operatorsFor(first.kind)[0], value: '' } : null; } /** * ONE `[joiner] [field] [operator] [value] [×]` row. Stateless: the owner keeps * the list and decides what a change means (this panel: a draft until Apply). */ export function ConditionRow({ fields, condition, joiner, disabled = false, onChangeField, onChangeOp, onChangeValue, onRemove, }: { fields: readonly FilterField[]; condition: Condition; joiner: string; disabled?: boolean; onChangeField: (fieldId: string) => void; onChangeOp: (op: Operator) => void; onChangeValue: (value: string) => void; onRemove: () => void; }) { const field = fields.find((f) => f.id === condition.field); const ops = operatorsFor(field?.kind ?? 'text'); return (
{joiner}
); } /** * The value control, chosen by the field's kind. * * A picker wherever the values are a known set, because typing an id into a * text box is not a filter anyone can write. `isValueless` operators render * NOTHING — "is empty" with a disabled text box beside it looks broken rather * than complete. */ export function ValueInput({ field, op, value, onChange, disabled = false, }: { field: FilterField | undefined; op: Operator; value: string; onChange: (v: string) => void; disabled?: boolean; }) { if (isValueless(op)) { return
—
; } if (field?.options?.length) { // "Contains" / "doesn't contain" name a SET of picked choices ("any of"), // not one — Lark Base's own reading. Every other operator is a plain // dropdown. if (field.kind === KIND.SELECT && (op === OP.CONTAINS || op === OP.NOT_CONTAINS)) { return ; } return ( ); } if (field?.kind === 'bool') { return ( ); } // A DATE field's `is` offers Lark Base's second dropdown — exact date, or a // relative window resolved at evaluation time. `before`/`after` keep the // plain date input. if (field?.kind === 'date' && op === OP.IS) { return ; } return ( onChange(e.target.value)} placeholder="Enter a value" className="field-input min-w-0 flex-1 py-1.5 text-xs" /> ); } /** * The DATE `is` control — a mode dropdown and, only for "Exact date", the day * input beside it. Module-scope, never declared inside another component's * render body: a fresh component TYPE on every render remounts it and drops * focus after one keystroke. */ function RelativeDateInput({ value, onChange, disabled }: { value: string; onChange: (v: string) => void; disabled?: boolean }) { const mode = dateModeOf(value); return (
{mode === 'exact' && ( onChange(e.target.value)} className="field-input min-w-0 flex-1 py-1.5 text-xs" /> )}
); } /** * The "contains" / "doesn't contain" control for a select field — several * choices at once, in a Lark-Base-style dropdown: a search box pinned above a * list of coloured pills, a checkmark on the selected side, picking one * keeping the list open so several can be picked in one visit. * * In-flow and absolute inside its trigger, like every other popover here; the * panel it sits in is `panel-solid`, which creates no stacking context, so * this stacks above the rows below it at the same `z-50`. */ function MultiSelectInput({ options, value, onChange, disabled, }: { options: readonly FilterFieldOption[]; value: string; onChange: (v: string) => void; disabled?: boolean; }) { const selected = parseMultiValue(value); const root = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const close = () => { setOpen(false); setQuery(''); }; useDismiss(root, open, close); // TOGGLE, NEVER CLOSE — several picks are one edit. const toggle = (v: string) => onChange(toMultiValue(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v])); const label = selected.length === 0 ? 'Select…' : selected.length === 1 ? options.find((o) => o.value === selected[0])?.label ?? selected[0] : `${selected.length} selected`; const needle = query.trim().toLowerCase(); const rows = needle === '' ? options : options.filter((o) => o.label.toLowerCase().includes(needle)); return (
{open && (
setQuery(e.target.value)} placeholder="Search" className="w-full bg-transparent px-1.5 py-1 text-xs outline-none placeholder-slate-400 dark:placeholder-slate-500" />
    {rows.length === 0 &&
  • No match.
  • } {rows.map((o) => { const checked = selected.includes(o.value); return (
  • { e.preventDefault(); toggle(o.value); }} className={cn( 'flex cursor-pointer items-center justify-between gap-2 rounded px-2 py-1.5', checked && 'bg-slate-100 dark:bg-slate-700/60', )} > {checked && }
  • ); })}
)}
); }