Initial commit: bface library, build fixes, and refreshed docs
- Externalize all @tamagui/* and tamagui subpaths so dist no longer vendors Tamagui. - Emit TypeScript declarations with vite-plugin-dts; fix package exports types for ui/*. - Align initEnv with profiles: displayName, brandLogo, api.baseURL, themeColor, uiShell. - Stabilize tests with Node localStorage file; env tests pass. - Update README and component docs for services, menus, API client, and development.
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
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';
|
||||
|
||||
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="$borderColor"
|
||||
borderRadius="$4"
|
||||
backgroundColor="$accentSurface"
|
||||
gap="$1"
|
||||
>
|
||||
<Text fontSize="$3" color="$color" opacity={0.7}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text fontSize="$7" fontWeight="700" color="$accentColor">
|
||||
{normalizeSummaryValue(item.value)}
|
||||
</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderCell({ column, orderBy, order, onSort }) {
|
||||
const sortable = column.sortable !== false;
|
||||
const isActive = orderBy === column.id;
|
||||
const arrow = isActive ? (order === 'asc' ? '↑' : '↓') : '';
|
||||
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%"
|
||||
>
|
||||
<Text fontSize="$4" fontWeight="700" color="$color" textAlign={column.align || 'left'} width="100%">
|
||||
{column.label}{arrow ? ` ${arrow}` : ''}
|
||||
</Text>
|
||||
</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
|
||||
}) {
|
||||
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');
|
||||
|
||||
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 query = {
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
search: effectiveSearchTerm,
|
||||
orderBy,
|
||||
order
|
||||
};
|
||||
|
||||
const [recordResult, summaryResult] = await Promise.all([
|
||||
dataModel.queryRecords(query),
|
||||
dataModel.querySummary(query, resolvedSummaryDefinitions)
|
||||
]);
|
||||
|
||||
if (!cancelled) {
|
||||
setRecords(recordResult.records || []);
|
||||
setTotalRecords(recordResult.totalRecords || 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%">
|
||||
<XStack justifyContent="space-between" alignItems="center" gap="$4" flexWrap="wrap">
|
||||
<XStack alignItems="center" gap="$3" flex={1} minWidth={240} flexWrap="wrap">
|
||||
<Text fontSize="$8" fontWeight="800" color="$accentColor">
|
||||
{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}
|
||||
/>
|
||||
) : null}
|
||||
{effectiveToolbarItems.map(renderToolbarButton)}
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
circular
|
||||
aria-label="Refresh directory"
|
||||
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
|
||||
onPress={handleRefresh}
|
||||
disabled={loading}
|
||||
/>
|
||||
{topRightContent}
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
{error ? (
|
||||
<YStack padding="$3" borderRadius="$4" backgroundColor="#fef2f2" borderWidth={1} borderColor="#fecaca">
|
||||
<Text color="#b91c1c">{error}</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<SummaryCards summary={summary} />
|
||||
|
||||
{bodyHeaderContent}
|
||||
|
||||
<YStack borderWidth={1} borderColor="$borderColor" borderRadius="$5" overflow="hidden" backgroundColor="$background">
|
||||
<XStack padding="$3" gap="$3" backgroundColor="$accentSurface" borderBottomWidth={1} borderBottomColor="$borderColor">
|
||||
{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 maxHeight={bodyMaxHeight}>
|
||||
<YStack>
|
||||
{loading ? (
|
||||
<XStack justifyContent="center" padding="$6">
|
||||
<Spinner size="large" color="$accentColor" />
|
||||
</XStack>
|
||||
) : records.length === 0 ? (
|
||||
<YStack padding="$6" alignItems="center">
|
||||
<Paragraph color="$color" opacity={0.7}>
|
||||
No records found.
|
||||
</Paragraph>
|
||||
</YStack>
|
||||
) : (
|
||||
records.map((record, index) => (
|
||||
<YStack key={record?.[dataModel?.getIdField?.() || 'id'] || index}>
|
||||
<XStack
|
||||
padding="$3"
|
||||
gap="$3"
|
||||
alignItems="center"
|
||||
hoverStyle={{ backgroundColor: '$accentSurface' }}
|
||||
pressStyle={{ backgroundColor: '$accentSurface' }}
|
||||
cursor={effectiveRowPress ? 'pointer' : undefined}
|
||||
onPress={() => effectiveRowPress?.(record)}
|
||||
>
|
||||
{resolvedColumns.map((column) => (
|
||||
<RowCell key={column.id} column={column} record={record} />
|
||||
))}
|
||||
</XStack>
|
||||
{index < records.length - 1 ? <Separator /> : null}
|
||||
</YStack>
|
||||
))
|
||||
)}
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
|
||||
{bodyFooterContent}
|
||||
|
||||
<XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap">
|
||||
<Text color="$color" opacity={0.7}>
|
||||
Rows: {totalRecords}
|
||||
</Text>
|
||||
<XStack alignItems="center" gap="$2" flexWrap="wrap">
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
aria-label="First page"
|
||||
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined}
|
||||
onPress={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
/>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
aria-label="Previous page"
|
||||
icon={PreviousPageIcon ? <PreviousPageIcon size={16} /> : undefined}
|
||||
onPress={() => setCurrentPage((value) => Math.max(1, value - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
/>
|
||||
<Text color="$color" opacity={0.75}>
|
||||
Page {currentPage} of {totalPages}
|
||||
</Text>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
aria-label="Next page"
|
||||
icon={NextPageIcon ? <NextPageIcon size={16} /> : 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={16} /> : undefined}
|
||||
onPress={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
/>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
export default DirView;
|
||||
Reference in New Issue
Block a user