"use client"; /** * FilterBuilder — nested AND/OR/NOT filter tree editor (PRODUCT-SPEC §9.3). * * Produces exactly the documented wire shape (docs/api/databases.md §5.3): * { op: "and"|"or"|"not", conditions: (group|condition)[] } * { propertyId, op, value } * Evaluation happens server-side / in @factory/database-engine — this file * only builds and edits the tree. Live "N records match" preview is provided * by the parent (DatabaseToolbar), which re-fetches useDatabaseRecords. */ import { useMemo, useState } from "react"; import { ChevronDown, Plus, Trash2, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { DatabaseProperty, FilterCondition, FilterGroup, FilterTree, isCondition, SelectOption } from "@/hooks/use-database"; // --------------------------------------------------------------------------- // Operator catalog per property kind (docs/api/databases.md §5.3) // --------------------------------------------------------------------------- export interface OpDef { op: string; label: string; /** Which value input the condition needs. */ needs: "none" | "text" | "number" | "option" | "options" | "date" | "dateRange" | "days" | "boolean" | "person"; } const TEXT_OPS: OpDef[] = [ { op: "contains", label: "Contains", needs: "text" }, { op: "does_not_contain", label: "Does not contain", needs: "text" }, { op: "starts_with", label: "Starts with", needs: "text" }, { op: "ends_with", label: "Ends with", needs: "text" }, { op: "is", label: "Is", needs: "text" }, { op: "is_not", label: "Is not", needs: "text" } ]; const NUMBER_OPS: OpDef[] = [ { op: "equals", label: "Equals", needs: "number" }, { op: "not_equals", label: "Does not equal", needs: "number" }, { op: "greater_than", label: "Greater than", needs: "number" }, { op: "less_than", label: "Less than", needs: "number" }, { op: "greater_or_equal", label: "Greater than or equal to", needs: "number" }, { op: "less_or_equal", label: "Less than or equal to", needs: "number" } ]; const OPTION_OPS: OpDef[] = [ { op: "equals", label: "Is", needs: "option" }, { op: "not_equals", label: "Is not", needs: "option" }, { op: "contains_any", label: "Contains any", needs: "options" }, { op: "contains_all", label: "Contains all", needs: "options" } ]; const DATE_OPS: OpDef[] = [ { op: "equals", label: "Is on date", needs: "date" }, { op: "before", label: "Is before", needs: "date" }, { op: "after", label: "Is after", needs: "date" }, { op: "between", label: "Is between", needs: "dateRange" }, { op: "on_or_before", label: "Is on or before", needs: "date" }, { op: "on_or_after", label: "Is on or after", needs: "date" }, { op: "is_this_week", label: "Is this week", needs: "none" }, { op: "is_this_month", label: "Is this month", needs: "none" }, { op: "within_next_days", label: "Is within next", needs: "days" }, { op: "is_past", label: "Is in the past", needs: "none" }, { op: "is_future", label: "Is in the future", needs: "none" } ]; const PERSON_OPS: OpDef[] = [ { op: "contains_any", label: "Contains any", needs: "person" }, { op: "contains_all", label: "Contains all", needs: "person" }, { op: "is_current_user", label: "Is me", needs: "none" } ]; const CHECKBOX_OPS: OpDef[] = [ { op: "is", label: "Is", needs: "boolean" }, { op: "is_not", label: "Is not", needs: "boolean" } ]; const EMPTY_OPS: OpDef[] = [ { op: "is_empty", label: "Is empty", needs: "none" }, { op: "is_not_empty", label: "Is not empty", needs: "none" } ]; export function opsForProperty(p: DatabaseProperty): OpDef[] { const empties: OpDef[] = [{ op: "is_empty", label: "Is empty", needs: "none" }, { op: "is_not_empty", label: "Is not empty", needs: "none" }]; switch (p.type) { case "title": case "text": case "rich_text": case "email": case "url": return [...TEXT_OPS, ...empties]; case "number": case "rollup": case "formula": return [...NUMBER_OPS, ...empties]; case "select": case "status": return [...OPTION_OPS, ...empties]; case "multi_select": case "relation": return [...OPTION_OPS, ...empties].map((o) => (o.needs === "option" ? { ...o, needs: "options" as const } : o)); case "date": case "date_range": return [...DATE_OPS, ...empties]; case "person": return [...PERSON_OPS, ...empties]; case "checkbox": return CHECKBOX_OPS; case "files": return EMPTY_OPS; default: return empties; } } /** Filterable properties (every type has at least an emptiness test). */ export function filterableProperties(properties: DatabaseProperty[]): DatabaseProperty[] { return properties; } // --------------------------------------------------------------------------- // Default value per needs // --------------------------------------------------------------------------- function defaultValue(needs: OpDef["needs"]): Record | undefined { switch (needs) { case "text": return { text: "" }; case "number": return { value: 0 }; case "option": return { optionId: "" }; case "options": return { optionIds: [] }; case "person": return { userIds: [] }; case "date": return { value: new Date().toISOString() }; case "dateRange": return { start: "", end: "" }; case "days": return { days: 7 }; case "boolean": return { value: true }; default: return undefined; } } // --------------------------------------------------------------------------- // Value input (per needs) // --------------------------------------------------------------------------- const inputCls = "rounded border border-input bg-background px-1.5 py-0.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"; function ValueInput({ needs, property, value, onChange }: { needs: OpDef["needs"]; property: DatabaseProperty; value?: Record; onChange: (v: Record | undefined) => void; }) { if (needs === "none") return —; if (needs === "text") { return ( onChange({ text: e.target.value })} /> ); } if (needs === "number" || needs === "days") { const key = needs === "days" ? "days" : "value"; return ( onChange({ [key]: Number(e.target.value) })} /> ); } if (needs === "option") { const options = property.config.options ?? []; return ( ); } if (needs === "options") { const options = property.config.options ?? []; const selected = (value?.optionIds as string[] | undefined) ?? []; return ( ); } if (needs === "date" || needs === "dateRange") { const type = property.config.includeTime ? "datetime-local" : "date"; if (needs === "dateRange") { const toLocal = (iso?: string) => (iso ? String(iso).slice(0, 10) : ""); return ( onChange({ ...value, start: e.target.value })} /> – onChange({ ...value, end: e.target.value })} /> ); } return ( onChange({ value: e.target.value })} /> ); } if (needs === "boolean") { return ( ); } if (needs === "person") { // Compact: comma-separated user ids is not friendly — use a text input // that stores known member ids; the toolbar passes the workspace members // via a data-attribute when available. const ids = (value?.userIds as string[] | undefined) ?? []; return ( onChange({ userIds: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) }) } /> ); } return null; } // --------------------------------------------------------------------------- // Tree editing helpers (immutable) // --------------------------------------------------------------------------- type Node = FilterGroup | FilterCondition; function isGroupNode(n: Node): n is FilterGroup { return !isCondition(n); } function updateGroup( group: FilterGroup, predicate: (node: Node) => boolean, update: (node: Node) => Node | null ): FilterGroup | null { const next: Node[] = []; for (const child of group.conditions) { if (predicate(child)) { const u = update(child); if (u != null) next.push(u); } else if (isGroupNode(child)) { const u = updateGroup(child, predicate, update); if (u != null) next.push(u); } else { next.push(child); } } if (next.length === 0 && group.op !== "not") return null; return { ...group, conditions: next }; } function addNode(tree: FilterTree | null, newNode: Node): FilterTree { if (!tree) { return { op: "and", conditions: [newNode] }; } if (isCondition(tree)) { return { op: "and", conditions: [tree, newNode] }; } return { ...tree, conditions: [...tree.conditions, newNode] }; } function nodeKey(node: Node, index: number): string { if (isCondition(node)) return `c${index}:${node.propertyId}:${node.op}`; return `g${index}:${node.op}`; } // --------------------------------------------------------------------------- // Condition row // --------------------------------------------------------------------------- function ConditionRow({ condition, properties, onChange, onRemove }: { condition: FilterCondition; properties: DatabaseProperty[]; onChange: (c: FilterCondition | null) => void; onRemove: () => void; }) { const property = properties.find((p) => p.id === condition.propertyId) ?? properties[0]; const ops = useMemo(() => (property ? opsForProperty(property) : []), [property]); const opDef = ops.find((o) => o.op === condition.op) ?? ops[0]; if (!property) return null; return (
onChange({ ...condition, value: v })} />
); } // --------------------------------------------------------------------------- // Group block (recursive) // --------------------------------------------------------------------------- function GroupBlock({ group, depth, properties, onChange, onRemove }: { group: FilterGroup; depth: number; properties: DatabaseProperty[]; onChange: (g: FilterGroup | null) => void; onRemove?: () => void; }) { const [collapsed, setCollapsed] = useState(depth > 0); const addCondition = () => { const p = properties[0]; if (!p) return; const op = opsForProperty(p)[0]; onChange({ ...group, conditions: [ ...group.conditions, { propertyId: p.id, op: op.op, value: defaultValue(op.needs) } ] }); }; const addGroup = () => { onChange({ ...group, conditions: [...group.conditions, { op: "or", conditions: [] }] }); }; return (
0 && "bg-muted/30 p-1.5")}>
{group.conditions.filter(isCondition).length} condition(s) {onRemove && ( )}
{!collapsed && (
{group.conditions.map((node, i) => isGroupNode(node) ? ( onChange( updateGroup(group, (n) => n === node, () => g) ?? group ) } onRemove={() => onChange( updateGroup(group, (n) => n === node, () => null) ?? group ) } /> ) : ( onChange( updateGroup(group, (n) => n === node, () => c) ?? group ) } onRemove={() => onChange( updateGroup(group, (n) => n === node, () => null) ?? group ) } /> ) )}
)}
); } // --------------------------------------------------------------------------- // Public component // --------------------------------------------------------------------------- export interface FilterBuilderProps { value: FilterTree | null; onChange: (tree: FilterTree | null) => void; properties: DatabaseProperty[]; /** Compact mode for narrow popovers. */ className?: string; } export function FilterBuilder({ value, onChange, properties, className }: FilterBuilderProps) { if (properties.length === 0) { return

Add properties before filtering.

; } return (
{value ? ( isGroupNode(value) ? ( onChange(g ?? null)} /> ) : (
onChange(c)} onRemove={() => onChange(null)} />
) ) : ( )} {value && (
)}
); }