451 lines
15 KiB
JavaScript
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;
|