/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Per-rule chip editors for the filter builder. Split out of * `SearchModal.filter.builder.tsx` (which keeps the toolbar / preset / * run-state orchestration) to stay under the module size cap. `RuleRow` * dispatches to the right per-kind editor; the builder only imports * `RuleRow` and `RULE_KIND_LABEL`. */ import { useMemo } from 'react'; import { Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from '@/components/ui/dropdown-menu'; import { Rule, type FilterRule, type SetOp, type StringOp, type ValueOp, type NumericOp, type ClassificationOp, } from '@/lib/search/filter-rules'; import { ComboInput } from '@/components/ui/combo-input'; import { propValueKey, type FilterValueSchema } from '@/lib/search/filter-schema'; const NO_OPTIONS: readonly string[] = []; // ── Op constants ────────────────────────────────────────────────────── const SET_OPS: SetOp[] = ['in', 'notIn']; const STRING_OPS: StringOp[] = ['eq', 'ne', 'contains', 'notContains', 'startsWith']; const VALUE_OPS: ValueOp[] = [ 'eq', 'ne', 'contains', 'notContains', 'gt', 'gte', 'lt', 'lte', 'isSet', 'isNotSet', ]; const NUMERIC_OPS: NumericOp[] = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte']; const CLASSIFICATION_OPS: ClassificationOp[] = [ 'contains', 'eq', 'ne', 'notContains', 'isSet', 'isNotSet', ]; const OP_LABEL: Record = { in: 'is one of', notIn: 'is not one of', eq: '=', ne: '≠', contains: 'contains', notContains: 'does not contain', startsWith: 'starts with', gt: '>', gte: '≥', lt: '<', lte: '≤', isSet: 'is set', isNotSet: 'is not set', }; export const RULE_KIND_LABEL: Record = { storey: 'Storey', ifcType: 'IFC Type', predefinedType: 'Predefined Type', name: 'Name', property: 'Property', quantity: 'Quantity', material: 'Material', classification: 'Classification', elevation: 'Elevation', }; // ── Rule row dispatcher ─────────────────────────────────────────────── export interface RuleRowProps { rule: FilterRule; ifcTypeOptions: string[]; storeyOptions: ReadonlyArray; psetQto: { psets: ReadonlyArray]>; qtos: ReadonlyArray]> } | null; /** Distinct model values for value suggestions (materials, classifications, property values). */ valueSchema: FilterValueSchema | null; onChange: (next: FilterRule) => void; onRemove: () => void; } export function RuleRow({ rule, ifcTypeOptions, storeyOptions, psetQto, valueSchema, onChange, onRemove }: RuleRowProps) { return (
{RULE_KIND_LABEL[rule.kind]} {rule.kind === 'storey' && ( ({ label: elev != null ? `${name} (${elev.toFixed(2)} m)` : name, value: name, }))} onChange={(values, op) => onChange(Rule.storey(values, op))} /> )} {rule.kind === 'ifcType' && ( ({ label: t, value: t }))} onChange={(values, op) => onChange(Rule.ifcType(values, op))} /> )} {rule.kind === 'predefinedType' && ( onChange(Rule.predefinedType(values, op))} /> )} {rule.kind === 'name' && ( onChange(Rule.name(op, value))} /> )} {rule.kind === 'property' && ( )} {rule.kind === 'quantity' && ( )} {rule.kind === 'material' && ( onChange(Rule.material(op, value))} /> )} {rule.kind === 'classification' && ( )} {rule.kind === 'elevation' && ( onChange(Rule.elevation(op, value))} /> )}
); } // ── Per-kind editors ────────────────────────────────────────────────── interface SetRuleEditorProps { values: string[]; op: SetOp; options: Array<{ label: string; value: string }>; onChange: (values: string[], op: SetOp) => void; } function SetRuleEditor({ values, op, options, onChange }: SetRuleEditorProps) { const toggle = (v: string) => { const next = values.includes(v) ? values.filter((x) => x !== v) : [...values, v]; onChange(next, op); }; return ( <> onChange(values, next)} /> {options.length === 0 && ( No options available — load a model first. )} {options.map((o) => ( { // Keep the menu open for multi-select. e.preventDefault(); toggle(o.value); }} className="font-mono" > {values.includes(o.value) ? '✓' : ''} {o.label} ))} {values.length > 0 && (
{values.map((v) => ( {v} ))}
)} ); } function PredefinedTypeEditor({ values, op, onChange, }: { values: string[]; op: SetOp; onChange: (values: string[], op: SetOp) => void; }) { // Predefined types aren't materialised in the parser today — pick // them via free-text. The user enters comma-separated values. const text = values.join(', '); return ( <> onChange(values, next)} /> onChange( e.target.value.split(',').map((s) => s.trim()).filter((s) => s.length > 0), op, ) } className="h-7 w-72 text-xs font-mono" /> ); } function NameEditor({ op, value, onChange, }: { op: StringOp; value: string; onChange: (op: StringOp, value: string) => void; }) { return ( <> onChange(next, value)} /> onChange(op, e.target.value)} className="h-7 w-56 text-xs font-mono" /> ); } interface PropertyEditorProps { rule: Extract; psetQto: RuleRowProps['psetQto']; valueSchema: FilterValueSchema | null; onChange: (next: FilterRule) => void; } function PropertyEditor({ rule, psetQto, valueSchema, onChange }: PropertyEditorProps) { const psetNames = useMemo(() => (psetQto ? psetQto.psets.map(([n]) => n) : []), [psetQto]); const propNames = useMemo(() => { if (!psetQto) return []; const entry = psetQto.psets.find(([n]) => n === rule.setName); return entry ? Array.from(entry[1]) : []; }, [psetQto, rule.setName]); const valueOptions = useMemo( () => valueSchema?.propertyValues.get(propValueKey(rule.setName, rule.propertyName)) ?? NO_OPTIONS, [valueSchema, rule.setName, rule.propertyName], ); const valueless = rule.op === 'isSet' || rule.op === 'isNotSet'; return ( <> onChange({ ...rule, setName: next, propertyName: '' })} /> . onChange({ ...rule, propertyName: next })} /> onChange({ ...rule, op: next })} /> {!valueless && ( onChange({ ...rule, value })} /> )} ); } interface QuantityEditorProps { rule: Extract; psetQto: RuleRowProps['psetQto']; onChange: (next: FilterRule) => void; } function QuantityEditor({ rule, psetQto, onChange }: QuantityEditorProps) { const qsetNames = useMemo(() => (psetQto ? psetQto.qtos.map(([n]) => n) : []), [psetQto]); const qtyNames = useMemo(() => { if (!psetQto) return []; const entry = psetQto.qtos.find(([n]) => n === rule.setName); return entry ? entry[1].map(([n]) => n) : []; }, [psetQto, rule.setName]); return ( <> onChange({ ...rule, setName: next, quantityName: '' })} /> . onChange({ ...rule, quantityName: next })} /> onChange({ ...rule, op: next })} /> onChange({ ...rule, value: Number.parseFloat(e.target.value) || 0 })} className="h-7 w-32 text-xs font-mono" /> ); } function MaterialEditor({ op, value, options, onChange, }: { op: StringOp; value: string; options: ReadonlyArray; onChange: (op: StringOp, value: string) => void; }) { return ( <> onChange(next, value)} /> onChange(op, v)} /> ); } function ClassificationEditor({ rule, valueSchema, onChange, }: { rule: Extract; valueSchema: FilterValueSchema | null; onChange: (next: FilterRule) => void; }) { const valueless = rule.op === 'isSet' || rule.op === 'isNotSet'; return ( <> onChange(Rule.classification(v, rule.op, rule.value))} /> onChange(Rule.classification(rule.system ?? '', next, rule.value))} /> {!valueless && ( onChange(Rule.classification(rule.system ?? '', rule.op, v))} /> )} ); } function ElevationEditor({ op, value, onChange, }: { op: NumericOp; value: number; onChange: (op: NumericOp, value: number) => void; }) { return ( <> onChange(next, value)} /> onChange(op, Number.parseFloat(e.target.value) || 0)} className="h-7 w-28 text-xs font-mono" /> m (storey elevation) ); } // ── Building-block widgets ─────────────────────────────────────────── function OpDropdown({ ops, value, onChange, }: { ops: ReadonlyArray; value: T; onChange: (next: T) => void; }) { return ( {ops.map((op) => ( onChange(op)} className="font-mono"> {OP_LABEL[op] ?? op} {op} ))} ); }