Files
bface/src/ui/components/DirView.jsx
2026-05-05 12:24:13 -05:00

451 lines
15 KiB
JavaScript

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 (
<XStack gap="$3" flexWrap="wrap">
{summary.items.map((item) => (
<YStack
key={item.id || item.label}
minWidth={140}
padding="$3"
borderWidth={1}
borderColor="$lineSubtle"
borderRadius="$radiusMd"
backgroundColor="$bgPanel"
gap="$1"
>
<Text fontSize="$3" color="$textMuted">
{item.label}
</Text>
<Text fontSize="$7" fontWeight="700" color="$accent">
{normalizeSummaryValue(item.value)}
</Text>
</YStack>
))}
</XStack>
);
}
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 (
<XStack
flex={column.flex || 1}
flexBasis={0}
minWidth={column.minWidth || 120}
alignItems="center"
justifyContent={justifyContent}
>
<Button
chromeless
disabled={!sortable}
onPress={() => sortable && onSort(column.id)}
padding={0}
justifyContent={justifyContent}
width="100%"
hoverStyle={sortable ? { backgroundColor: '$bgPage' } : undefined}
pressStyle={sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
>
<XStack width="100%" alignItems="center" justifyContent={justifyContent} gap="$2">
<Text {...getTypographyRoleProps('tableHeader')} textAlign={column.align || 'left'} numberOfLines={1}>
{column.label}
</Text>
{sortable ? (
isActive ? (
order === 'asc'
? (CaretUp ? <CaretUp size="xs" color="$textSecondary" /> : null)
: (CaretDown ? <CaretDown size="xs" color="$textSecondary" /> : null)
) : (
CaretDown ? <CaretDown size="xs" color="$textMuted" style={{ opacity: 0.6 }} /> : null
)
) : null}
</XStack>
</Button>
</XStack>
);
}
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 (
<XStack
flex={column.flex || 1}
flexBasis={0}
minWidth={column.minWidth || 120}
justifyContent={justifyContent}
alignItems="center"
>
<XStack width="100%" justifyContent={justifyContent} alignItems="center">
{React.isValidElement(content) ? content : (
<Text color="$color" numberOfLines={1} textAlign={column.align || 'left'} width="100%">
{String(content)}
</Text>
)}
</XStack>
</XStack>
);
}
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 (
<Text key={action?.key || action?.text || index} color="$color" opacity={0.7}>
{action?.text}
</Text>
);
}
if (action?.kind === 'node') {
return <React.Fragment key={action?.key || index}>{action?.node}</React.Fragment>;
}
const IconComponent = action?.icon ? getIcon(action.icon) : null;
return (
<Button
key={action?.id || action?.label || index}
size="$3"
theme={action?.theme}
chromeless={action?.chromeless}
disabled={loading || action?.disabled}
icon={IconComponent ? <IconComponent size={16} /> : undefined}
onPress={action?.onPress}
>
{action?.label}
</Button>
);
};
return (
<YStack gap="$4" width="100%">
{showHeader ? (
<XStack justifyContent="space-between" alignItems="center" gap="$4" flexWrap="wrap">
<XStack alignItems="center" gap="$3" flex={1} minWidth={240} flexWrap="wrap">
<Text {...getTypographyRoleProps('sectionTitle')}>
{title}
</Text>
{topLeftContent}
</XStack>
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexWrap="wrap">
{searchConfig?.enabled ? (
<Input
width={260}
placeholder={searchConfig.placeholder || 'Search records...'}
value={effectiveSearchTerm}
onChangeText={updateSearchTerm}
backgroundColor="$bgPanel"
borderColor="$lineSubtle"
focusStyle={{ borderColor: '$accent' }}
/>
) : null}
{effectiveToolbarItems.map(renderToolbarButton)}
<Button
size="$3"
chromeless
circular
aria-label="Refresh directory"
icon={RefreshIcon ? <RefreshIcon size="sm" color="$textSecondary" /> : undefined}
onPress={handleRefresh}
disabled={loading}
/>
{topRightContent}
</XStack>
</XStack>
) : null}
{error ? (
<YStack padding="$3" borderRadius="$radiusMd" backgroundColor="$dangerBg" borderWidth={1} borderColor="$danger">
<Text color="$danger" fontWeight="600">{error}</Text>
</YStack>
) : null}
{showSummary ? <SummaryCards summary={summary} /> : null}
{bodyHeaderContent}
<YStack borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusLg" overflow="hidden" backgroundColor="$bgPanel">
<XStack padding={paddingRow} gap="$3" backgroundColor="transparent" borderBottomWidth={1} borderBottomColor="$lineSubtle">
{resolvedColumns.map((column) => (
<HeaderCell
key={column.id}
column={column}
orderBy={orderBy}
order={order}
onSort={(columnId) => {
const nextOrder = orderBy === columnId && order === 'asc' ? 'desc' : 'asc';
setOrderBy(columnId);
setOrder(nextOrder);
setCurrentPage(1);
}}
/>
))}
</XStack>
<ScrollView {...(bodyMaxHeight != null ? { maxHeight: bodyMaxHeight } : {})}>
<YStack>
{loading ? (
<XStack justifyContent="center" padding="$6">
<Spinner size="large" color="$accent" />
</XStack>
) : records.length === 0 ? (
<YStack padding="$6" alignItems="center" gap="$2">
<Paragraph color="$textSecondary">
No records found
</Paragraph>
<Text color="$textMuted" fontSize="$3">Try adjusting your search or filters.</Text>
</YStack>
) : (
records.map((record, index) => (
<YStack key={record?.[dataModel?.getIdField?.() || 'id'] || index}>
<XStack
padding={paddingRow}
gap="$3"
alignItems="center"
backgroundColor={striped && index % 2 === 1 ? '$bgPage' : 'transparent'}
hoverStyle={{ backgroundColor: '$bgPage' }}
pressStyle={{ backgroundColor: '$bgPanelElev' }}
cursor={effectiveRowPress ? 'pointer' : undefined}
onPress={() => effectiveRowPress?.(record)}
>
{resolvedColumns.map((column) => (
<RowCell key={column.id} column={column} record={record} />
))}
</XStack>
{index < records.length - 1 ? <Separator borderColor="$lineSubtle" /> : null}
</YStack>
))
)}
</YStack>
</ScrollView>
</YStack>
{bodyFooterContent}
<XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap">
<Text color="$textMuted">
Rows: {totalRecords}
</Text>
<XStack alignItems="center" gap="$2" flexWrap="wrap" padding="$1" borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusMd" backgroundColor="$bgPanel">
<Button
size="$3"
chromeless
aria-label="First page"
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage(1)}
disabled={currentPage === 1 || loading}
/>
<Button
size="$3"
chromeless
aria-label="Previous page"
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage((value) => Math.max(1, value - 1))}
disabled={currentPage === 1 || loading}
/>
<Text color="$textSecondary">
Page {currentPage} of {totalPages}
</Text>
<Button
size="$3"
chromeless
aria-label="Next page"
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage((value) => Math.min(totalPages, value + 1))}
disabled={currentPage >= totalPages || loading}
/>
<Button
size="$3"
chromeless
aria-label="Last page"
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
onPress={() => setCurrentPage(totalPages)}
disabled={currentPage >= totalPages || loading}
/>
</XStack>
</XStack>
</YStack>
);
}
export default DirView;