/* 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/. */ /** * ListPanel - Main container for the Lists feature * * Shows either: * - List builder (when creating/editing a list) * - List results table (when a list has been executed) * - List library (saved lists + presets) */ import React, { useCallback, useState, useMemo } from 'react'; import { X, Plus, Play, FileSpreadsheet, Trash2, Download, Upload, Loader2, Table2, Pencil, Copy, Settings2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useViewerStore } from '@/store'; import { useIfc } from '@/hooks/useIfc'; import { executeList, summariseListRows, LIST_PRESETS, importListDefinition, exportListDefinition, createListDataProvider, } from '@/lib/lists'; import type { ListDefinition, ListResult, ListDataProvider, ListGrouping } from '@/lib/lists'; import type { IfcDataStore } from '@ifc-lite/parser'; import { ListBuilder } from './ListBuilder'; import { ListResultsTable } from './ListResultsTable'; interface ListPanelProps { onClose?: () => void; } type PanelView = 'library' | 'builder' | 'results'; export function ListPanel({ onClose }: ListPanelProps) { const { ifcDataStore, models } = useIfc(); const [view, setView] = useState('library'); const [editingList, setEditingList] = useState(null); const listDefinitions = useViewerStore((s) => s.listDefinitions); const activeListId = useViewerStore((s) => s.activeListId); const listResult = useViewerStore((s) => s.listResult); const listExecuting = useViewerStore((s) => s.listExecuting); const addListDefinition = useViewerStore((s) => s.addListDefinition); const updateListDefinition = useViewerStore((s) => s.updateListDefinition); const deleteListDefinition = useViewerStore((s) => s.deleteListDefinition); const setActiveListId = useViewerStore((s) => s.setActiveListId); const setListResult = useViewerStore((s) => s.setListResult); const setListExecuting = useViewerStore((s) => s.setListExecuting); const pendingListDraft = useViewerStore((s) => s.pendingListDraft); const setPendingListDraft = useViewerStore((s) => s.setPendingListDraft); // A draft handed off from "Create list" (search filter) opens straight into // the builder for column configuration, then is cleared so it fires once. React.useEffect(() => { if (!pendingListDraft) return; setEditingList(pendingListDraft); setView('builder'); setPendingListDraft(null); }, [pendingListDraft, setPendingListDraft]); const importInputRef = React.useRef(null); // Build the {modelId, provider} pairs in a single pass so the two // arrays can never drift out of alignment (skipping a model without // an ifcDataStore must not shift every later model's provider index). const modelProviderPairs = useMemo(() => { const pairs: Array<{ modelId: string; provider: ListDataProvider; store: IfcDataStore }> = []; if (models.size > 0) { for (const [modelId, model] of models) { // Skip native-metadata models — they don't have a parsed // IfcDataStore, so the list provider can't query them. if (!model.ifcDataStore) continue; pairs.push({ modelId, provider: createListDataProvider(model.ifcDataStore), store: model.ifcDataStore }); } } else if (ifcDataStore) { pairs.push({ modelId: 'default', provider: createListDataProvider(ifcDataStore), store: ifcDataStore }); } return pairs; }, [models, ifcDataStore]); const allProviders = useMemo(() => modelProviderPairs.map((p) => p.provider), [modelProviderPairs]); const allStores = useMemo(() => modelProviderPairs.map((p) => p.store), [modelProviderPairs]); const hasData = allProviders.length > 0; const handleExecuteList = useCallback((definition: ListDefinition) => { if (!hasData) return; setListExecuting(true); setActiveListId(definition.id); setEditingList(definition); // Use requestAnimationFrame to avoid blocking UI during execution requestAnimationFrame(() => { try { const resultParts: ListResult[] = []; for (const { modelId, provider } of modelProviderPairs) { resultParts.push(executeList(definition, provider, modelId)); } const allRows = resultParts.flatMap(r => r.rows); const totalTime = resultParts.reduce((sum, r) => sum + r.executionTime, 0); // Re-derive groups/summary over the merged rows so grouping works // across federated models (and isn't dropped on the merge). const { groups, summary } = summariseListRows(definition, allRows); setListResult({ columns: definition.columns, rows: allRows, totalCount: allRows.length, executionTime: totalTime, groups, summary, }); setView('results'); } catch (err) { console.error('[Lists] Execution failed:', err); } finally { setListExecuting(false); } }); }, [hasData, modelProviderPairs, setActiveListId, setListResult, setListExecuting]); const handleCreateNew = useCallback(() => { setEditingList(null); setView('builder'); }, []); const handleEdit = useCallback((definition: ListDefinition) => { setEditingList(definition); setView('builder'); }, []); const handleDuplicate = useCallback((definition: ListDefinition) => { const clone: ListDefinition = { ...definition, id: crypto.randomUUID(), name: `${definition.name} (Copy)`, createdAt: Date.now(), updatedAt: Date.now(), }; addListDefinition(clone); }, [addListDefinition]); const handleSaveList = useCallback((definition: ListDefinition) => { // Check if updating existing or adding new const exists = listDefinitions.some(d => d.id === definition.id); if (exists) { updateListDefinition(definition.id, definition); } else { addListDefinition(definition); } setView('library'); }, [listDefinitions, addListDefinition, updateListDefinition]); const handleDelete = useCallback((id: string) => { deleteListDefinition(id); }, [deleteListDefinition]); const handleEditFromResults = useCallback(() => { if (editingList) { setView('builder'); } }, [editingList]); // Grouping/summing changed directly from the results table: update the // executed definition (so Settings reflects it), persist if it's saved, and // re-derive groups/summary over the current rows for a consistent result. const handleGroupingFromTable = useCallback((grouping: ListGrouping | undefined) => { const def = editingList; if (!def) return; const next: ListDefinition = { ...def, grouping }; setEditingList(next); if (listDefinitions.some((d) => d.id === def.id)) { updateListDefinition(def.id, { grouping }); } const current = useViewerStore.getState().listResult; if (current) { const summ = summariseListRows(next, current.rows); setListResult({ ...current, groups: summ.groups, summary: summ.summary }); } }, [editingList, listDefinitions, updateListDefinition, setListResult]); const handleImport = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; try { const definition = await importListDefinition(file); addListDefinition(definition); } catch (err) { console.error('[Lists] Import failed:', err); } e.target.value = ''; }, [addListDefinition]); const handleExportDefinition = useCallback((definition: ListDefinition) => { exportListDefinition(definition); }, []); return (
{/* Header */}
{view === 'library' && 'Lists'} {view === 'builder' && (editingList ? 'Edit List' : 'New List')} {view === 'results' && 'Results'} {view === 'results' && listResult && ( ({listResult.totalCount} rows, {listResult.executionTime.toFixed(0)}ms) )}
{view === 'results' && ( <> Edit Configuration Back to Lists )} {view === 'builder' && ( )} {onClose && ( )}
{/* Content */} {view === 'library' && ( importInputRef.current?.click()} /> )} {view === 'builder' && hasData && ( setView('library')} onExecute={handleExecuteList} /> )} {view === 'results' && listResult && ( )} {/* Hidden import input */}
); } // ============================================================================ // List Library Sub-Component // ============================================================================ interface ListLibraryProps { definitions: ListDefinition[]; activeListId: string | null; executing: boolean; hasData: boolean; onExecute: (def: ListDefinition) => void; onCreateNew: () => void; onEdit: (def: ListDefinition) => void; onDuplicate: (def: ListDefinition) => void; onDelete: (id: string) => void; onExport: (def: ListDefinition) => void; onImport: () => void; } function ListLibrary({ definitions, activeListId, executing, hasData, onExecute, onCreateNew, onEdit, onDuplicate, onDelete, onExport, onImport, }: ListLibraryProps) { return (
{/* Actions */}
{/* User's saved lists */} {definitions.length > 0 && (
Saved Lists
{definitions.map(def => ( ))}
)} {definitions.length > 0 && } {/* Presets */}
Templates
{LIST_PRESETS.map(preset => ( ))}
); } // ============================================================================ // List Item // ============================================================================ interface ListItemProps { definition: ListDefinition; isActive: boolean; executing: boolean; hasData: boolean; onExecute: (def: ListDefinition) => void; onEdit?: (def: ListDefinition) => void; onDuplicate?: (def: ListDefinition) => void; onDelete?: (id: string) => void; onExport?: (def: ListDefinition) => void; isPreset?: boolean; } function ListItem({ definition, isActive, executing, hasData, onExecute, onEdit, onDuplicate, onDelete, onExport, isPreset }: ListItemProps) { return (
hasData && onExecute(definition)} >
{definition.name}
{definition.description && (
{definition.description}
)}
{executing ? ( ) : ( <> Run {!isPreset && onEdit && ( Edit )} {onDuplicate && ( {isPreset ? 'Use as Template' : 'Duplicate'} )} {!isPreset && onExport && ( Export )} {!isPreset && onDelete && ( Delete )} )}
); }