/* 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/. */ /** * SearchModalFilterBuilder — chip palette over the unified * `FilterRule[]`. Storey / IFC type / Predefined type / Name / Property / * Quantity rules with AND/OR + IsSet/IsNotSet, schema-aware dropdowns * (storeys + types load eagerly, pset/qto names lazily), and saved * preset persistence. * * UI-only: this component owns rule editing, not run lifecycle. The * parent `SearchModalFilter` reads the same slice state and triggers * the path-B evaluator from a single Run button. */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Plus, Trash2, X, Bookmark, Save } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { useViewerStore } from '@/store'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, } from '@/components/ui/dropdown-menu'; import { COMMON_IFC_TYPES } from '@/lib/search/common-ifc-types'; import { Rule, type FilterRule, type Combinator, } from '@/lib/search/filter-rules'; import { discoverFilterSchema, discoverPropertyAndQuantitySchema, discoverFilterValues, } from '@/lib/search/filter-schema'; import { loadSavedFilters, saveFilter, deleteSavedFilter, type SavedFilterPreset, } from '@/lib/search/saved-filters'; import { RuleRow, RULE_KIND_LABEL } from './SearchModal.filter.editors'; export function SearchModalFilterBuilder() { const { filter, schemaMap, models, activeModelId, searchQuery, setFilterCombinator, setFilterLimit, addFilterRule, updateFilterRule, removeFilterRule, clearFilterRules, setFilterSchema, setFilterPsetQtoSchema, setFilterValueSchema, setSearchFilter, } = useViewerStore( useShallow((s) => ({ filter: s.searchFilter, schemaMap: s.searchFilterSchema, models: s.models, activeModelId: s.activeModelId, searchQuery: s.searchQuery, setFilterCombinator: s.setFilterCombinator, setFilterLimit: s.setFilterLimit, addFilterRule: s.addFilterRule, updateFilterRule: s.updateFilterRule, removeFilterRule: s.removeFilterRule, clearFilterRules: s.clearFilterRules, setFilterSchema: s.setFilterSchema, setFilterPsetQtoSchema: s.setFilterPsetQtoSchema, setFilterValueSchema: s.setFilterValueSchema, setSearchFilter: s.setSearchFilter, })), ); const [savedPresets, setSavedPresets] = useState(() => loadSavedFilters()); const activeModel = activeModelId ? models.get(activeModelId) : undefined; const activeStore = activeModel?.ifcDataStore ?? null; const schemaEntry = activeModelId ? schemaMap.get(activeModelId) : undefined; // Cheap schema discovery — runs once per active model. useEffect(() => { if (!activeModelId || !activeStore) return; if (schemaMap.has(activeModelId)) return; setFilterSchema(activeModelId, discoverFilterSchema(activeStore)); }, [activeModelId, activeStore, schemaMap, setFilterSchema]); // Lazy pset/qto schema — fired the first time a property/quantity rule appears. useEffect(() => { if (!activeModelId || !activeStore) return; const entry = schemaMap.get(activeModelId); if (entry?.psetQto) return; const needs = filter.rules.some((r) => r.kind === 'property' || r.kind === 'quantity'); if (!needs) return; setFilterPsetQtoSchema(activeModelId, discoverPropertyAndQuantitySchema(activeStore)); }, [activeModelId, activeStore, filter.rules, schemaMap, setFilterPsetQtoSchema]); // Lazy value discovery — distinct material / classification / property // values for the chip value suggestions. Fired the first time a rule that // benefits from them (property, material, classification) appears. useEffect(() => { if (!activeModelId || !activeStore) return; const entry = schemaMap.get(activeModelId); if (entry?.values) return; const needs = filter.rules.some( (r) => r.kind === 'property' || r.kind === 'material' || r.kind === 'classification', ); if (!needs) return; setFilterValueSchema(activeModelId, discoverFilterValues(activeStore)); }, [activeModelId, activeStore, filter.rules, schemaMap, setFilterValueSchema]); const ifcTypeOptions = useMemo(() => { if (schemaEntry?.basic.ifcTypes && schemaEntry.basic.ifcTypes.length > 0) { return schemaEntry.basic.ifcTypes; } return COMMON_IFC_TYPES.slice(); }, [schemaEntry]); const storeyOptions = schemaEntry?.basic.storeys ?? []; // ── Rule construction ───────────────────────────────────────────── const addRuleOfKind = useCallback((kind: FilterRule['kind']) => { let rule: FilterRule; switch (kind) { case 'storey': rule = Rule.storey([], 'in'); break; case 'ifcType': rule = Rule.ifcType([], 'in'); break; case 'predefinedType': rule = Rule.predefinedType([], 'in'); break; case 'name': rule = Rule.name('contains', ''); break; case 'property': rule = Rule.property('', '', 'eq', ''); break; case 'quantity': rule = Rule.quantity('', '', 'gt', 0); break; case 'material': rule = Rule.material('contains', ''); break; case 'classification': rule = Rule.classification('', 'contains', ''); break; case 'elevation': rule = Rule.elevation('gt', 0); break; } addFilterRule(rule); }, [addFilterRule]); const promoteSearchQuery = useCallback(() => { const q = searchQuery.trim(); if (!q) return; addFilterRule(Rule.name('contains', q)); }, [addFilterRule, searchQuery]); // ── Preset handlers ───────────────────────────────────────────────── const handleSavePreset = useCallback(() => { if (filter.rules.length === 0) return; // eslint-disable-next-line no-alert const name = window.prompt('Save filter as…', ''); if (!name) return; setSavedPresets(saveFilter(name, filter.combinator, filter.rules)); }, [filter.combinator, filter.rules]); const handleLoadPreset = useCallback((preset: SavedFilterPreset) => { setSearchFilter({ rules: preset.rules.map((r) => ({ ...r }) as FilterRule), combinator: preset.combinator, limit: filter.limit, }); }, [filter.limit, setSearchFilter]); const handleDeletePreset = useCallback((name: string) => { setSavedPresets(deleteSavedFilter(name)); }, []); return (
{/* ── Toolbar: AND/OR · Limit · promote-query · Presets · Save · Reset ── */}
setFilterLimit(Number.parseInt(e.target.value, 10) || 0)} className="h-7 w-20 text-xs" /> 0 = none
{searchQuery.trim().length > 0 && ( )}
{filter.rules.length > 0 && ( )}
{/* ── Rules list ──────────────────────────────────────────────────── */}
{filter.rules.length === 0 && (

Add a rule to start filtering — pick by storey, IFC type, name, property, quantity, material, classification, or elevation.

)} {filter.rules.map((rule, i) => ( updateFilterRule(i, next)} onRemove={() => removeFilterRule(i)} /> ))}
); } // ── Sub-components ──────────────────────────────────────────────────── function CombinatorToggle({ value, onChange, }: { value: Combinator; onChange: (next: Combinator) => void; }) { return (
{(['AND', 'OR'] as const).map((c) => ( ))}
); } function PresetMenu({ presets, onLoad, onDelete, }: { presets: SavedFilterPreset[]; onLoad: (preset: SavedFilterPreset) => void; onDelete: (name: string) => void; }) { if (presets.length === 0) { return ( ); } return ( Saved presets {presets.map((p) => ( onLoad(p)} className="flex items-start justify-between gap-2" >
{p.name} {p.rules.length} rule{p.rules.length === 1 ? '' : 's'} · {p.combinator}
))}
); } function AddRuleMenu({ onAdd, }: { onAdd: (kind: FilterRule['kind']) => void; }) { return ( Filter dimension {(Object.keys(RULE_KIND_LABEL) as FilterRule['kind'][]).map((k) => ( onAdd(k)}> {RULE_KIND_LABEL[k]} ))} ); } function truncate(s: string, max: number): string { return s.length <= max ? s : s.slice(0, max - 1) + '…'; }