/* 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/. */ /** * ListBuilder — configure a list: scope (entity types + filters), the * columns to show, and optional grouping / totals. * * UI is organised as labelled sections with a consistent header treatment. * The most-used columns (attributes + Material / Classification / Storey) * are surfaced as a flat chip grid; property/quantity sets — which can be * numerous — stay in collapsible groups below. */ import React, { useCallback, useMemo, useState } from 'react'; import { Play, Plus, Trash2, ChevronDown, ChevronRight, ChevronUp, Save, Check, GripVertical } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ComboInput } from '@/components/ui/combo-input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { IfcTypeEnum } from '@ifc-lite/data'; import type { IfcDataStore } from '@ifc-lite/parser'; import { discoverFilterValues, discoverFilterSchema, propValueKey, } from '@/lib/search/filter-schema'; import type { ListDataProvider, ListDefinition, ColumnDefinition, DiscoveredColumns, PropertyCondition, ConditionOperator, } from '@ifc-lite/lists'; import { discoverColumns, ENTITY_ATTRIBUTES } from '@ifc-lite/lists'; const NO_OPTIONS: readonly string[] = []; /** * Distinct model values used to suggest condition values in the chip editors. * Storeys are intentionally NOT here — they come cheaply from the spatial * index, whereas these require sampling element property/material data. */ interface ListConditionValues { materials: string[]; classifications: string[]; /** propValueKey(pset, prop) → distinct values. */ propertyValues: Map; } /** * Merge per-store value discovery into one suggestion set. This is the * EXPENSIVE pass (samples element property/material/classification data), so * it's only run when a property/material/classification condition exists — * never for storey-only filters (storeys come from `discoverFilterSchema`). */ function discoverConditionValues(stores: IfcDataStore[]): ListConditionValues { const materials = new Set(); const classifications = new Set(); const propertyValues = new Map>(); for (const store of stores) { const v = discoverFilterValues(store); v.materials.forEach((m) => materials.add(m)); v.classifications.forEach((c) => classifications.add(c)); for (const [k, arr] of v.propertyValues) { let bucket = propertyValues.get(k); if (!bucket) { bucket = new Set(); propertyValues.set(k, bucket); } for (const val of arr) bucket.add(val); } } const sort = (s: Set) => Array.from(s).sort(); const pv = new Map(); for (const [k, s] of propertyValues) pv.set(k, sort(s)); return { materials: sort(materials), classifications: sort(classifications), propertyValues: pv }; } // Building element types available for selection const SELECTABLE_TYPES: { type: IfcTypeEnum; label: string }[] = [ { type: IfcTypeEnum.IfcWall, label: 'Walls' }, { type: IfcTypeEnum.IfcWallStandardCase, label: 'Walls (Standard)' }, { type: IfcTypeEnum.IfcDoor, label: 'Doors' }, { type: IfcTypeEnum.IfcWindow, label: 'Windows' }, { type: IfcTypeEnum.IfcSlab, label: 'Slabs' }, { type: IfcTypeEnum.IfcColumn, label: 'Columns' }, { type: IfcTypeEnum.IfcBeam, label: 'Beams' }, { type: IfcTypeEnum.IfcStair, label: 'Stairs' }, { type: IfcTypeEnum.IfcRamp, label: 'Ramps' }, { type: IfcTypeEnum.IfcRoof, label: 'Roofs' }, { type: IfcTypeEnum.IfcCovering, label: 'Coverings' }, { type: IfcTypeEnum.IfcCurtainWall, label: 'Curtain Walls' }, { type: IfcTypeEnum.IfcRailing, label: 'Railings' }, { type: IfcTypeEnum.IfcSpace, label: 'Spaces' }, { type: IfcTypeEnum.IfcSpatialZone, label: 'Spatial Zones' }, { type: IfcTypeEnum.IfcZone, label: 'Zones' }, { type: IfcTypeEnum.IfcSystem, label: 'Systems' }, { type: IfcTypeEnum.IfcDistributionSystem, label: 'Distribution Systems' }, { type: IfcTypeEnum.IfcBuildingStorey, label: 'Storeys' }, { type: IfcTypeEnum.IfcDistributionElement, label: 'MEP Distribution' }, { type: IfcTypeEnum.IfcFlowTerminal, label: 'MEP Terminals' }, { type: IfcTypeEnum.IfcFlowSegment, label: 'MEP Segments' }, { type: IfcTypeEnum.IfcFlowFitting, label: 'MEP Fittings' }, ]; /** Column descriptor shared by the quick-add grid. */ interface CommonColumn { id: string; source: ColumnDefinition['source']; propertyName: string; label: string } /** * The first-class columns: built-in attributes plus the spatial / semantic * columns. Surfaced as a flat grid so Material / Classification / Storey * are as reachable as Name / Class — not buried in a collapsed group. */ const COMMON_COLUMNS: CommonColumn[] = [ ...ENTITY_ATTRIBUTES.map((a): CommonColumn => ({ id: `attr-${a.toLowerCase()}`, source: 'attribute', propertyName: a, label: a, })), { id: 'col-material', source: 'material', propertyName: 'Material', label: 'Material' }, { id: 'col-classification', source: 'classification', propertyName: 'Classification', label: 'Classification' }, { id: 'col-storey', source: 'spatial', propertyName: 'Storey', label: 'Storey' }, ]; /** Union the per-provider complete-discovery results into one column set. */ function mergeDiscovered(parts: DiscoveredColumns[]): DiscoveredColumns { const properties = new Map>(); const quantities = new Map>(); const merge = (target: Map>, src: Map) => { for (const [k, arr] of src) { let b = target.get(k); if (!b) { b = new Set(); target.set(k, b); } for (const v of arr) b.add(v); } }; for (const d of parts) { merge(properties, d.properties); merge(quantities, d.quantities); } const toSorted = (m: Map>) => { const out = new Map(); for (const [k, s] of m) out.set(k, Array.from(s).sort()); return out; }; return { attributes: [...ENTITY_ATTRIBUTES], properties: toSorted(properties), quantities: toSorted(quantities) }; } interface ListBuilderProps { providers: ListDataProvider[]; /** Backing stores for value discovery (condition value suggestions). */ stores: IfcDataStore[]; initial: ListDefinition | null; onSave: (definition: ListDefinition) => void; onCancel: () => void; onExecute: (definition: ListDefinition) => void; } export function ListBuilder({ providers, stores, initial, onSave, onCancel, onExecute }: ListBuilderProps) { const [name, setName] = useState(initial?.name ?? ''); const [description, setDescription] = useState(initial?.description ?? ''); const [selectedTypes, setSelectedTypes] = useState>( new Set(initial?.entityTypes ?? []) ); const [columns, setColumns] = useState(initial?.columns ?? []); const [conditions, setConditions] = useState(initial?.conditions ?? []); // Lazily-discovered distinct values for condition suggestions. This is the // EXPENSIVE sampling pass, so only run it when a property / material / // classification condition exists — storey-only filters never trigger it. const [conditionValues, setConditionValues] = useState(null); React.useEffect(() => { if (conditionValues || stores.length === 0) return; const needs = conditions.some( (c) => c.source === 'property' || c.source === 'material' || c.source === 'classification', ); if (!needs) return; setConditionValues(discoverConditionValues(stores)); }, [conditions, stores, conditionValues]); // Storey names come cheaply from the spatial index (no element sampling), // so they're always available without the expensive value pass above. const storeyNames = useMemo(() => { if (stores.length === 0) return []; const set = new Set(); for (const store of stores) { for (const [name] of discoverFilterSchema(store).storeys) set.add(name); } return Array.from(set).sort(); }, [stores]); const [groupByColumnId, setGroupByColumnId] = useState(initial?.grouping?.columnId ?? ''); const [sumColumnIds, setSumColumnIds] = useState>( new Set(initial?.grouping?.sumColumnIds ?? []) ); // Count entities per type across all providers const typeCounts = useMemo(() => { const counts = new Map(); for (const { type } of SELECTABLE_TYPES) { let total = 0; for (const p of providers) { total += p.getEntitiesByType(type).length; } if (total > 0) counts.set(type, total); } return counts; }, [providers]); // Available columns. Prefer COMPLETE, type-independent discovery (every // property set / quantity set in the model) so all properties/quantities // are addable even with no entity type selected. Fall back to the // type-sampled discovery for providers that can't enumerate completely. const discovered = useMemo(() => { const complete = providers.filter((p) => typeof p.discoverAllColumns === 'function'); if (providers.length > 0 && complete.length === providers.length) { return mergeDiscovered(complete.map((p) => p.discoverAllColumns!())); } return discoverColumns(providers, Array.from(selectedTypes)); }, [providers, selectedTypes]); const toggleType = useCallback((type: IfcTypeEnum) => { setSelectedTypes(prev => { const next = new Set(prev); if (next.has(type)) next.delete(type); else next.add(type); return next; }); }, []); const addColumn = useCallback((col: ColumnDefinition) => { setColumns(prev => (prev.some(c => c.id === col.id) ? prev : [...prev, col])); }, []); const removeColumn = useCallback((id: string) => { setColumns(prev => prev.filter(c => c.id !== id)); // Keep grouping consistent when its column is removed. setGroupByColumnId(prev => (prev === id ? '' : prev)); setSumColumnIds(prev => { if (!prev.has(id)) return prev; const next = new Set(prev); next.delete(id); return next; }); }, []); const toggleColumn = useCallback((col: ColumnDefinition) => { setColumns(prev => (prev.some(c => c.id === col.id) ? prev.filter(c => c.id !== col.id) : [...prev, col])); }, []); const moveColumn = useCallback((idx: number, direction: -1 | 1) => { setColumns(prev => { const target = idx + direction; if (target < 0 || target >= prev.length) return prev; const next = [...prev]; [next[idx], next[target]] = [next[target], next[idx]]; return next; }); }, []); const addCondition = useCallback((condition: PropertyCondition) => { setConditions(prev => [...prev, condition]); }, []); const updateCondition = useCallback((idx: number, condition: PropertyCondition) => { setConditions(prev => prev.map((c, i) => (i === idx ? condition : c))); }, []); const removeCondition = useCallback((idx: number) => { setConditions(prev => prev.filter((_, i) => i !== idx)); }, []); const toggleSumColumn = useCallback((id: string) => { setSumColumnIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const buildDefinition = useCallback((): ListDefinition => { const groupValid = groupByColumnId && columns.some(c => c.id === groupByColumnId); const sumCols = columns.filter(c => sumColumnIds.has(c.id)).map(c => c.id); // Keep grouping when there's a valid group column OR any sum column — sums // alone still produce grand totals, and may have been set from the table. const grouping = (groupValid || sumCols.length > 0) ? { columnId: groupValid ? groupByColumnId : '', sumColumnIds: sumCols } : undefined; return { id: initial?.id ?? crypto.randomUUID(), name: name || 'Untitled List', description: description || undefined, createdAt: initial?.createdAt ?? Date.now(), updatedAt: Date.now(), entityTypes: Array.from(selectedTypes), // Preserve a filter-snapshot scope (set at creation; not edited here). expressIdsByModel: initial?.expressIdsByModel, conditions, columns, grouping, }; }, [initial, name, description, selectedTypes, conditions, columns, groupByColumnId, sumColumnIds]); const handleSave = useCallback(() => onSave(buildDefinition()), [buildDefinition, onSave]); const handleRun = useCallback(() => onExecute(buildDefinition()), [buildDefinition, onExecute]); const selectedColumnIds = useMemo(() => new Set(columns.map(c => c.id)), [columns]); const totalSelectedEntities = useMemo(() => { let count = 0; for (const type of selectedTypes) count += typeCounts.get(type) ?? 0; return count; }, [selectedTypes, typeCounts]); // A snapshot list (from "Create list" in the search filter) is frozen to an // explicit element set; the entity-type scope doesn't apply. const snapshotCount = initial?.expressIdsByModel ? Object.values(initial.expressIdsByModel).reduce((n, ids) => n + ids.length, 0) : 0; const isSnapshot = snapshotCount > 0; const canRun = columns.length > 0; return (
{/* Identity */}
setName(e.target.value)} className="h-9 text-sm font-medium" /> setDescription(e.target.value)} className="h-7 text-xs" />
{/* Scope: entity types — or a frozen filter snapshot */}
0 ? `${totalSelectedEntities.toLocaleString()} elements` : 'All elements'} > {isSnapshot ? (

Filter snapshot — frozen to the{' '} {snapshotCount.toLocaleString()} elements that matched the search filter. Entity-type scope doesn't apply; configure columns and grouping below.

) : ( <>
{SELECTABLE_TYPES.map(({ type, label }) => { const count = typeCounts.get(type); if (!count) return null; return ( toggleType(type)} trailing={count.toLocaleString()} > {label} ); })}
{selectedTypes.size === 0 && (

No type selected — the list targets all model elements. Use filters to narrow by name, material, classification or storey.

)} )}
{/* Filters */}
0 ? `${conditions.length}` : undefined}>
{/* Columns */}
0 ? `${columns.length}` : undefined}> {columns.length > 0 && ( )}
{/* Grouping & totals */} {columns.length > 0 && (
)}
{/* Bottom actions */}
); } // ============================================================================ // Section shell — consistent header with an accent rule // ============================================================================ function Section({ label, hint, children, }: { label: string; hint?: string; children: React.ReactNode; }) { return (
{label} {hint !== undefined && ( {hint} )}
{children}
); } function Chip({ selected, onClick, trailing, children, }: { selected: boolean; onClick: () => void; trailing?: React.ReactNode; children: React.ReactNode; }) { return ( ); } // ============================================================================ // Selected columns (ordered, reorderable) // ============================================================================ function SelectedColumns({ columns, onMove, onRemove, }: { columns: ColumnDefinition[]; onMove: (idx: number, dir: -1 | 1) => void; onRemove: (id: string) => void; }) { return (
{columns.map((col, idx) => (
{idx + 1} {col.label ?? col.propertyName} {col.psetName && · {col.psetName}}
))}
); } const SOURCE_TAG: Record = { attribute: 'attr', property: 'pset', quantity: 'qty', material: 'mat', classification: 'cls', spatial: 'storey', }; function ColSourceTag({ source }: { source: ColumnDefinition['source'] }) { return ( {SOURCE_TAG[source]} ); } // ============================================================================ // Column Picker — flat "common" grid + collapsible pset/qto groups // ============================================================================ interface ColumnPickerProps { discovered: DiscoveredColumns; selectedIds: Set; onAdd: (col: ColumnDefinition) => void; onToggle: (col: ColumnDefinition) => void; } function ColumnPicker({ discovered, selectedIds, onAdd, onToggle }: ColumnPickerProps) { const [expanded, setExpanded] = useState>(new Set()); const toggleSection = (id: string) => setExpanded(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const psetEntries = useMemo( () => Array.from(discovered.properties.entries()).sort(([a], [b]) => a.localeCompare(b)), [discovered.properties], ); const qtoEntries = useMemo( () => Array.from(discovered.quantities.entries()).sort(([a], [b]) => a.localeCompare(b)), [discovered.quantities], ); return (
{/* Quick-add grid of the first-class columns */}
{COMMON_COLUMNS.map(({ id, source, propertyName, label }) => { const selected = selectedIds.has(id); return ( onToggle({ id, source, propertyName, label })} > {selected && } {label} ); })}
{(psetEntries.length > 0 || qtoEntries.length > 0) && (
{psetEntries.map(([psetName, propNames]) => ( toggleSection(`pset-${psetName}`)} > {propNames.map(propName => { const id = `prop-${psetName}-${propName}`.toLowerCase().replace(/\s+/g, '-'); return ( onAdd({ id, source: 'property', psetName, propertyName: propName, label: propName })} /> ); })} ))} {qtoEntries.map(([qsetName, quantNames]) => ( toggleSection(`qset-${qsetName}`)} > {quantNames.map(quantName => { const id = `quant-${qsetName}-${quantName}`.toLowerCase().replace(/\s+/g, '-'); return ( onAdd({ id, source: 'quantity', psetName: qsetName, propertyName: quantName, label: quantName })} /> ); })} ))}
)}
); } function PickerGroup({ title, badge, expanded, onToggle, children, }: { title: string; badge: string; expanded: boolean; onToggle: () => void; children: React.ReactNode; }) { return (
{expanded &&
{children}
}
); } function PickerItem({ label, selected, onAdd, }: { label: string; selected: boolean; onAdd: () => void; }) { return ( ); } // ============================================================================ // Grouping & totals // ============================================================================ function GroupingBody({ columns, groupByColumnId, sumColumnIds, onGroupByChange, onToggleSum, }: { columns: ColumnDefinition[]; groupByColumnId: string; sumColumnIds: Set; onGroupByChange: (id: string) => void; onToggleSum: (id: string) => void; }) { return (
Σ Totals — sum these columns per group and overall
{columns.map((c) => ( onToggleSum(c.id)}> Σ {c.label ?? c.propertyName} ))}
); } // ============================================================================ // Filters (conditions) // ============================================================================ type ConditionSource = PropertyCondition['source']; const CONDITION_SOURCES: { source: ConditionSource; label: string }[] = [ { source: 'attribute', label: 'Attribute' }, { source: 'property', label: 'Property' }, { source: 'quantity', label: 'Quantity' }, { source: 'material', label: 'Material' }, { source: 'classification', label: 'Classification' }, { source: 'spatial', label: 'Storey' }, ]; const OPERATOR_LABEL: Record = { equals: '=', notEquals: '≠', contains: 'contains', gt: '>', lt: '<', gte: '≥', lte: '≤', exists: 'is set', }; function operatorsFor(source: ConditionSource): ConditionOperator[] { switch (source) { case 'quantity': return ['equals', 'notEquals', 'gt', 'gte', 'lt', 'lte', 'exists']; case 'material': case 'classification': return ['contains', 'equals', 'notEquals', 'exists']; default: return ['equals', 'notEquals', 'contains', 'exists']; } } function defaultConditionFor(source: ConditionSource): PropertyCondition { switch (source) { case 'property': return { source, psetName: '', propertyName: '', operator: 'equals', value: '' }; case 'quantity': return { source, psetName: '', propertyName: '', operator: 'gt', value: '' }; case 'material': return { source, propertyName: 'Material', operator: 'contains', value: '' }; case 'classification': return { source, propertyName: 'Classification', operator: 'contains', value: '' }; case 'spatial': return { source, propertyName: 'Storey', operator: 'equals', value: '' }; case 'attribute': default: return { source: 'attribute', propertyName: 'Name', operator: 'contains', value: '' }; } } const SELECT_CLASS = 'h-7 rounded-md border border-border bg-background px-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring'; function ConditionsBody({ conditions, discovered, values, storeys, onAdd, onUpdate, onRemove, }: { conditions: PropertyCondition[]; discovered: DiscoveredColumns; values: ListConditionValues | null; storeys: string[]; onAdd: (condition: PropertyCondition) => void; onUpdate: (idx: number, condition: PropertyCondition) => void; onRemove: (idx: number) => void; }) { return (
{conditions.map((condition, idx) => ( onUpdate(idx, next)} onRemove={() => onRemove(idx)} /> ))}
); } function ConditionRow({ condition, discovered, values, storeys, onChange, onRemove, }: { condition: PropertyCondition; discovered: DiscoveredColumns; values: ListConditionValues | null; storeys: string[]; onChange: (next: PropertyCondition) => void; onRemove: () => void; }) { const ops = operatorsFor(condition.source); const showValue = condition.operator !== 'exists'; const isProperty = condition.source === 'property'; const isQuantity = condition.source === 'quantity'; const showSetFields = isProperty || isQuantity; const setNameOptions = useMemo(() => { if (isProperty) return Array.from(discovered.properties.keys()).sort(); if (isQuantity) return Array.from(discovered.quantities.keys()).sort(); return []; }, [discovered, isProperty, isQuantity]); const propNameOptions = useMemo(() => { const set = condition.psetName ?? ''; if (isProperty) return [...(discovered.properties.get(set) ?? [])]; if (isQuantity) return [...(discovered.quantities.get(set) ?? [])]; return []; }, [discovered, condition.psetName, isProperty, isQuantity]); const valueOptions = useMemo(() => { switch (condition.source) { case 'property': return values?.propertyValues.get(propValueKey(condition.psetName ?? '', condition.propertyName)) ?? NO_OPTIONS; case 'material': return values?.materials ?? NO_OPTIONS; case 'classification': return values?.classifications ?? NO_OPTIONS; case 'spatial': return storeys; default: return NO_OPTIONS; } }, [condition.source, condition.psetName, condition.propertyName, values, storeys]); const valuePlaceholder = condition.source === 'spatial' ? 'storey name' : condition.source === 'material' ? 'material' : condition.source === 'classification' ? 'code or name' : 'value'; return (
{condition.source === 'attribute' && ( )} {showSetFields && ( <> onChange({ ...condition, psetName: v })} /> onChange({ ...condition, propertyName: v })} /> )} {showValue && ( onChange({ ...condition, value: v })} /> )}
); }