import React, { useEffect, useMemo, useState } from 'react'; import { Button, Input, Paragraph, ScrollView, Separator, Spinner, Text, XStack, YStack } from 'tamagui'; import { getIcon } from './IconMapper.jsx'; import { normalizeColumnsArray } from './grid/utils.js'; import { getTypographyRoleProps } from '../styles/index.js'; const EMPTY_COLUMNS = []; const EMPTY_ACTIONS = []; const DEFAULT_SEARCH_CONFIG = { enabled: true, placeholder: 'Search records...' }; const EMPTY_SUMMARY_DEFINITIONS = []; function getColumnJustify(align) { if (align === 'right') { return 'flex-end'; } if (align === 'center') { return 'center'; } return 'flex-start'; } function normalizeSummaryValue(value) { if (typeof value === 'number') { return Number.isInteger(value) ? String(value) : value.toFixed(2); } if (value && typeof value === 'object') { return Object.entries(value) .map(([key, count]) => `${key}: ${count}`) .join(' ยท '); } return String(value ?? ''); } function SummaryCards({ summary }) { if (!summary?.items?.length) { return null; } return ( {summary.items.map((item) => ( {item.label} {normalizeSummaryValue(item.value)} ))} ); } function HeaderCell({ column, orderBy, order, onSort }) { const sortable = column.sortable !== false; const isActive = orderBy === column.id; const CaretUp = getIcon('caret-up'); const CaretDown = getIcon('caret-down'); const justifyContent = getColumnJustify(column.align); return ( ); } function RowCell({ column, record }) { const value = record?.[column.id]; const content = column.render ? column.render(value, record, column) : (value ?? ''); const justifyContent = getColumnJustify(column.align); return ( {React.isValidElement(content) ? content : ( {String(content)} )} ); } export function DirView({ dataModel, columns = EMPTY_COLUMNS, title = 'Directory', toolbarActions = EMPTY_ACTIONS, toolbarItems = undefined, actions = undefined, topLeftContent = null, topRightContent = null, bodyHeaderContent = null, bodyFooterContent = null, searchConfig = DEFAULT_SEARCH_CONFIG, searchValue = undefined, onSearchChange = null, initialSearchValue = '', summaryDefinitions = EMPTY_SUMMARY_DEFINITIONS, pageSize = 10, bodyMaxHeight = 480, onRowClick = null, onRowPress = null, onRefresh = null, showHeader = true, showSummary = false, density = 'comfortable', striped = false }) { const [dataVersion, setDataVersion] = useState(0); const [records, setRecords] = useState([]); const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [internalSearchTerm, setInternalSearchTerm] = useState(initialSearchValue); const resolvedColumns = useMemo(() => normalizeColumnsArray(columns), [columns]); const effectiveToolbarItems = actions ?? toolbarItems ?? toolbarActions; const effectiveSearchTerm = searchValue ?? internalSearchTerm; const effectiveRowPress = onRowPress ?? onRowClick; const [orderBy, setOrderBy] = useState(resolvedColumns.find((column) => column.sortable !== false)?.id || ''); const [order, setOrder] = useState('asc'); const [currentPage, setCurrentPage] = useState(1); const [totalRecords, setTotalRecords] = useState(0); const resolvedSummaryDefinitions = summaryDefinitions || EMPTY_SUMMARY_DEFINITIONS; const RefreshIcon = getIcon('refresh'); const FirstPageIcon = getIcon('first-page'); const PreviousPageIcon = getIcon('chevron-left'); const NextPageIcon = getIcon('chevron-right'); const LastPageIcon = getIcon('last-page'); const paddingRow = density === 'compact' ? '$2' : density === 'spacious' ? '$4' : '$3'; useEffect(() => { if (!dataModel?.subscribe) { return undefined; } return dataModel.subscribe(() => { setDataVersion((value) => value + 1); }); }, [dataModel]); const totalPages = useMemo(() => Math.max(1, Math.ceil(totalRecords / pageSize)), [totalRecords, pageSize]); useEffect(() => { let cancelled = false; async function loadData() { if (!dataModel) { return; } setLoading(true); setError(''); try { const offset = (currentPage - 1) * pageSize; const sortBy = orderBy ? [{ field: orderBy, direction: order }] : []; const filterBy = effectiveSearchTerm ? { search: effectiveSearchTerm } : {}; const query = { offset, page_size: pageSize, sort_by: sortBy, filter_by: filterBy }; const [recordResult, summaryResult] = await Promise.all([ dataModel.queryRecords(query), dataModel.querySummary(query, resolvedSummaryDefinitions) ]); if (!cancelled) { setRecords(recordResult.rows || []); setTotalRecords(recordResult.total || 0); setSummary(summaryResult || null); } } catch (loadError) { if (!cancelled) { setError(loadError.message || 'Failed to load records'); } } finally { if (!cancelled) { setLoading(false); } } } loadData(); return () => { cancelled = true; }; }, [currentPage, dataModel, dataVersion, effectiveSearchTerm, order, orderBy, pageSize, resolvedSummaryDefinitions]); useEffect(() => { const nextOrderBy = resolvedColumns.find((column) => column.sortable !== false)?.id || ''; setOrderBy((current) => { if (current && resolvedColumns.some((column) => column.id === current)) { return current; } return nextOrderBy; }); }, [resolvedColumns]); const handleRefresh = async () => { if (onRefresh) { await onRefresh(); } setDataVersion((value) => value + 1); }; const updateSearchTerm = (value) => { if (searchValue === undefined) { setInternalSearchTerm(value); } onSearchChange?.(value); searchConfig?.onChange?.(value); setCurrentPage(1); }; const renderToolbarButton = (action, index) => { if (React.isValidElement(action)) { return React.cloneElement(action, { key: action.key || `toolbar-${index}` }); } if (action?.kind === 'text') { return ( {action?.text} ); } if (action?.kind === 'node') { return {action?.node}; } const IconComponent = action?.icon ? getIcon(action.icon) : null; return ( ); }; return ( {showHeader ? ( {title} {topLeftContent} {searchConfig?.enabled ? ( ) : null} {effectiveToolbarItems.map(renderToolbarButton)}