refactor(DataTable): improve column sizing and visibility handling; remove deprecated features

This commit is contained in:
Marco Beretta 2025-09-29 21:19:22 +02:00
parent 1cd82247ce
commit 9c5bbdaa28
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
10 changed files with 179 additions and 147 deletions

View file

@ -132,7 +132,7 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
return (
<div
className={cn(
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
'group relative flex h-12 w-full items-center rounded-lg md:h-9',
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
)}
role="button"

View file

@ -129,17 +129,6 @@ export default function ArchivedChatsTable() {
[],
);
const handleError = useCallback(
(error: Error) => {
console.error('DataTable error:', error);
showToast({
message: localize('com_ui_unarchive_error'),
severity: NotificationSeverity.ERROR,
});
},
[showToast, localize],
);
const flattenedConversations = useMemo(
() => data?.pages?.flatMap((page) => page?.conversations?.filter(Boolean) ?? []) ?? [],
[data?.pages],
@ -259,7 +248,8 @@ export default function ArchivedChatsTable() {
);
},
meta: {
className: 'min-w-[150px] flex-1',
width: 65,
className: 'min-w-[150px]',
},
enableSorting: true,
},
@ -279,8 +269,9 @@ export default function ArchivedChatsTable() {
return formatDate(convo.createdAt?.toString() ?? '', isSmallScreen);
},
meta: {
className: 'w-32 sm:w-40',
// desktopOnly: true, // Potential future use
width: 20,
className: 'min-w-[6rem]',
desktopOnly: true,
},
enableSorting: true,
},
@ -298,13 +289,13 @@ export default function ArchivedChatsTable() {
const isRowUnarchiving = unarchivingId === convo.conversationId;
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 md:gap-2">
<TooltipAnchor
description={localize('com_ui_unarchive')}
render={
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
className="h-9 w-9 p-0 hover:bg-surface-hover md:h-8 md:w-8"
onClick={() => {
const conversationId = convo.conversationId;
if (!conversationId) return;
@ -322,7 +313,7 @@ export default function ArchivedChatsTable() {
render={
<Button
variant="destructive"
className="h-8 w-8 p-0"
className="h-9 w-9 p-0 md:h-8 md:w-8"
onClick={() => {
setDeleteRow(convo);
setIsDeleteOpen(true);
@ -337,7 +328,8 @@ export default function ArchivedChatsTable() {
);
},
meta: {
className: 'w-24',
width: 30,
className: 'min-w-[5rem]',
},
enableSorting: false,
},
@ -358,12 +350,6 @@ export default function ArchivedChatsTable() {
<OGDialogHeader>
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
</OGDialogHeader>
{/*
Server-only sorting strategy:
- Query key changes (sort/search) trigger immediate empty state (keepPreviousData:false)
- DataTable shows skeletons instead of client re-sorting stale rows
- No local caching/aggregation layer (flatten derived straight from query pages)
*/}
<DataTable
columns={columns}
data={flattenedConversations}
@ -388,7 +374,6 @@ export default function ArchivedChatsTable() {
isFetchingNextPage={isFetchingNextPage}
sorting={sorting}
onSortingChange={handleSortingChange}
onError={handleError}
/>
</OGDialogContent>
</OGDialog>

View file

@ -15,11 +15,9 @@ import type { DataTableProps, ProcessedDataRow } from './DataTable.types';
import { SelectionCheckbox, MemoizedTableRow, SkeletonRows } from './DataTableComponents';
import { Table, TableBody, TableHead, TableHeader, TableCell, TableRow } from '../Table';
import { useDebounced, useOptimizedRowSelection } from './DataTable.hooks';
import { DataTableErrorBoundary } from './DataTableErrorBoundary';
import { useMediaQuery, useLocalize } from '~/hooks';
import { DataTableSearch } from './DataTableSearch';
import { cn, logger } from '~/utils';
import { Button } from '../Button';
import { Label } from '../Label';
import { Spinner } from '~/svgs';
@ -36,7 +34,6 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
isFetchingNextPage = false,
hasNextPage = false,
fetchNextPage,
onReset,
sorting,
onSortingChange,
customActionsRenderer,
@ -84,7 +81,6 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection();
const [error, setError] = useState<Error | null>(null);
const [searchTerm, setSearchTerm] = useState(filterValue);
const [internalSorting, setInternalSorting] = useState<SortingState>(defaultSort);
@ -123,6 +119,17 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
const debouncedTerm = useDebounced(searchTerm, debounceDelay);
const finalSorting = sorting ?? internalSorting;
/**
* Mobile responsive column visibility system
*
* Calculates which columns should be hidden on mobile devices based on the `desktopOnly` meta property.
* When a column has `meta.desktopOnly: true`, it will be hidden on viewports < 768px (mobile).
*
* This works in conjunction with:
* - Header rendering (lines 479-485): Conditionally renders headers based on visibility
* - Cell rendering (DataTableComponents.tsx): Applies CSS classes to hide cells
* - Skeleton rendering (DataTableComponents.tsx): Applies same CSS to skeleton cells
*/
const calculatedVisibility = useMemo(() => {
const newVisibility: VisibilityState = {};
columns.forEach((col) => {
@ -171,6 +178,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
const selectColumn: ColumnDef<TData, TValue> = {
id: 'select',
enableResizing: false,
header: () => {
const extraCheckboxProps = (isIndeterminate ? { indeterminate: true } : {}) as Record<
string,
@ -220,16 +228,29 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
);
},
meta: {
className: 'w-12',
className: 'max-w-[20px] flex-1',
},
};
return [selectColumn, ...columns.map((col) => col as unknown as ColumnDef<TData, TValue>)];
}, [columns, enableRowSelection, showCheckboxes, localize]);
}, [
columns,
enableRowSelection,
showCheckboxes,
localize,
data,
getRowId,
isAllSelected,
isIndeterminate,
setOptimizedRowSelection,
]);
// No transformation for sizing; width handled via meta.width percentages.
const sizedColumns = tableColumns;
const table = useReactTable<TData>({
data,
columns: tableColumns,
columns: sizedColumns,
getRowId: getRowId,
getCoreRowModel: getCoreRowModel(),
enableRowSelection,
@ -246,6 +267,8 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
onRowSelectionChange: setOptimizedRowSelection,
});
// Removed column size initialization (deprecated)
const rowVirtualizer = useVirtualizer({
enabled: virtualizationActive,
count: data.length,
@ -267,15 +290,60 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
const showSkeletons = isLoading || (isFetching && !isFetchingNextPage);
const shouldShowSearch = enableSearch && onFilterChange;
// useEffect(() => {
// if (data.length > 1000) {
// const cleanup = setTimeout(() => {
// rowVirtualizer.scrollToIndex(0, { align: 'start' });
// rowVirtualizer.measure();
// }, 1000);
// return () => clearTimeout(cleanup);
// }
// }, [data.length, rowVirtualizer]);
let tableBodyContent: React.ReactNode;
if (showSkeletons) {
tableBodyContent = (
<SkeletonRows
count={skeletonCount}
columns={tableColumns as ColumnDef<Record<string, unknown>>[]}
/>
);
} else if (virtualizationActive) {
tableBodyContent = (
<>
{paddingTop > 0 && (
<TableRow aria-hidden="true">
<TableCell
colSpan={tableColumns.length}
style={{ height: paddingTop, padding: 0, border: 0 }}
/>
</TableRow>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<MemoizedTableRow
key={virtualRow.key}
row={row as unknown as Row<Record<string, unknown>>}
virtualIndex={virtualRow.index}
selected={row.getIsSelected()}
style={{ height: rowHeight }}
/>
);
})}
{paddingBottom > 0 && (
<TableRow aria-hidden="true">
<TableCell
colSpan={tableColumns.length}
style={{ height: paddingBottom, padding: 0, border: 0 }}
/>
</TableRow>
)}
</>
);
} else {
tableBodyContent = rows.map((row) => (
<MemoizedTableRow
key={getRowId(row.original as TData, row.index)}
row={row as unknown as Row<Record<string, unknown>>}
virtualIndex={row.index}
selected={row.getIsSelected()}
style={{ height: rowHeight }}
/>
));
}
useEffect(() => {
setSearchTerm(filterValue);
@ -295,6 +363,8 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
rowVirtualizer.calculateRange();
}, [data.length, finalSorting, columnVisibility, virtualizationActive, rowVirtualizer]);
// Removed manual column sizing dependency effect
// ResizeObserver to re-measure when container size changes
useEffect(() => {
if (!virtualizationActive) return;
@ -374,24 +444,6 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
};
}, [handleScroll, cleanupTimers]);
const handleReset = useCallback(() => {
setError(null);
setOptimizedRowSelection({});
setSearchTerm('');
onReset?.();
}, [onReset, setOptimizedRowSelection]);
if (error) {
return (
<DataTableErrorBoundary onReset={handleReset}>
<div className="flex flex-col items-center justify-center p-8">
<p className="mb-4 text-red-500">{error.message}</p>
<Button onClick={handleReset}>{localize('com_ui_retry')}</Button>
</div>
</DataTableErrorBoundary>
);
}
return (
<div
className={cn(
@ -402,7 +454,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
role="region"
aria-label={localize('com_ui_data_table')}
>
<div className="flex w-full shrink-0 items-center gap-3 border-b border-border-light">
<div className="flex w-full shrink-0 items-center gap-2 border-b border-border-light md:gap-3">
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
{customActionsRenderer &&
customActionsRenderer({
@ -424,15 +476,22 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
aria-label={localize('com_ui_data_table_scroll_area')}
aria-describedby={showSkeletons ? 'loading-status' : undefined}
>
<Table role="table" aria-label={localize('com_ui_data_table')} aria-rowcount={data.length}>
<Table
role="table"
aria-label={localize('com_ui_data_table')}
aria-rowcount={data.length}
className="table-fixed"
>
<TableHeader className="sticky top-0 z-10 bg-surface-secondary">
{headerGroups.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
// Check if this column should be hidden on mobile (desktopOnly feature)
const isDesktopOnly =
(header.column.columnDef.meta as { desktopOnly?: boolean } | undefined)
?.desktopOnly ?? false;
// Hide header if column is not visible or if it's desktop-only on a mobile viewport
if (!header.column.getIsVisible() || (isSmallScreen && isDesktopOnly)) {
return null;
}
@ -465,15 +524,24 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
}
};
const metaWidth = (header.column.columnDef.meta as { width?: number } | undefined)
?.width;
const widthStyle = isSelectHeader
? { width: '32px', maxWidth: '32px' }
: metaWidth && metaWidth >= 1 && metaWidth <= 100
? { width: `${metaWidth}%`, maxWidth: `${metaWidth}%` }
: {};
return (
<TableHead
key={header.id}
className={cn(
'border-b border-border-light py-2',
isSelectHeader ? 'px-0 text-center' : 'px-3',
'border-b border-border-light px-2 py-2 md:px-3 md:py-2',
isSelectHeader && 'px-0 text-center',
canSort && 'cursor-pointer hover:bg-surface-tertiary',
meta?.className,
header.column.getIsResizing() && 'bg-surface-tertiary/60',
)}
style={widthStyle}
onClick={header.column.getToggleSortingHandler()}
onKeyDown={handleSortingKeyDown}
role={canSort ? 'button' : undefined}
@ -490,7 +558,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
{isSelectHeader ? (
flexRender(header.column.columnDef.header, header.getContext())
) : (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 md:gap-2">
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort && (
<span className="text-text-primary" aria-hidden="true">
@ -504,6 +572,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
)}
</div>
)}
{/* Resizer removed (manual resizing deprecated) */}
</TableHead>
);
})}
@ -512,54 +581,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
</TableHeader>
<TableBody>
{showSkeletons ? (
<SkeletonRows
count={skeletonCount}
columns={tableColumns as ColumnDef<Record<string, unknown>>[]}
/>
) : virtualizationActive ? (
<>
{paddingTop > 0 && (
<TableRow aria-hidden="true">
<TableCell
colSpan={tableColumns.length}
style={{ height: paddingTop, padding: 0, border: 0 }}
/>
</TableRow>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<MemoizedTableRow
key={virtualRow.key}
row={row as unknown as Row<TData>}
virtualIndex={virtualRow.index}
selected={row.getIsSelected()}
style={{ height: rowHeight }}
/>
);
})}
{paddingBottom > 0 && (
<TableRow aria-hidden="true">
<TableCell
colSpan={tableColumns.length}
style={{ height: paddingBottom, padding: 0, border: 0 }}
/>
</TableRow>
)}
</>
) : (
rows.map((row) => (
<MemoizedTableRow
key={getRowId(row.original as TData, row.index)}
row={row as unknown as Row<TData>}
virtualIndex={row.index}
selected={row.getIsSelected()}
style={{ height: rowHeight }}
/>
))
)}
{tableBodyContent}
{isFetchingNextPage && (
<TableRow>
<TableCell

View file

@ -8,11 +8,33 @@ export type TableColumnDef<TData, TValue> = ColumnDef<ProcessedDataRow<TData>, T
export type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
accessorKey?: string | number;
meta?: {
size?: string | number;
mobileSize?: string | number;
minWidth?: string | number;
priority?: number;
/** Column width as a percentage (1-100). Used for proportional column sizing. */
width?: number;
/** Additional CSS classes to apply to the column cells and header. */
className?: string;
/**
* When true, this column will be hidden on mobile devices (viewport < 768px).
* This is useful for hiding less critical information on smaller screens.
*
* **Usage Example:**
* ```typescript
* {
* accessorKey: 'createdAt',
* header: 'Date Created',
* cell: ({ row }) => formatDate(row.original.createdAt),
* meta: {
* desktopOnly: true, // Hide this column on mobile
* width: 20,
* className: 'min-w-[6rem]'
* }
* }
* ```
*
* The column will be completely hidden including:
* - Header cell
* - Data cells
* - Skeleton loading cells
*/
desktopOnly?: boolean;
};
};
@ -33,8 +55,8 @@ export interface DataTableConfig {
virtualization?: {
overscan?: number;
minRows?: number;
rowHeight?: number; // fixed row height to disable costly dynamic measurements
fastOverscanMultiplier?: number; // multiplier applied during fast scroll bursts
rowHeight?: number;
fastOverscanMultiplier?: number;
};
pinning?: {
enableColumnPinning?: boolean;
@ -55,8 +77,6 @@ export interface DataTableProps<TData extends Record<string, unknown>, TValue> {
isFetchingNextPage?: boolean;
hasNextPage?: boolean;
fetchNextPage?: () => Promise<unknown>;
onError?: (error: Error) => void;
onReset?: () => void;
sorting?: SortingState;
onSortingChange?: (updater: SortingState | ((old: SortingState) => SortingState)) => void;
conversationIndex?: number;

View file

@ -27,7 +27,7 @@ export const SelectionCheckbox = memo(
}
e.stopPropagation();
}}
className="flex h-full w-[30px] items-center justify-center"
className="flex h-full w-8 items-center justify-center"
onClick={(e) => {
e.stopPropagation();
onChange(!checked);
@ -40,18 +40,15 @@ export const SelectionCheckbox = memo(
SelectionCheckbox.displayName = 'SelectionCheckbox';
interface TableRowComponentProps<TData extends Record<string, unknown>> {
row: Row<TData>;
virtualIndex?: number;
style?: React.CSSProperties;
selected: boolean;
}
const TableRowComponent = <TData extends Record<string, unknown>>(
{
row,
virtualIndex,
style,
selected,
}: {
row: Row<TData>;
virtualIndex?: number;
style?: React.CSSProperties;
selected: boolean;
},
{ row, virtualIndex, style, selected }: TableRowComponentProps<TData>,
ref: React.Ref<HTMLTableRowElement>,
) => (
<TableRow
@ -63,18 +60,27 @@ const TableRowComponent = <TData extends Record<string, unknown>>(
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as
| { className?: string; desktopOnly?: boolean }
| { className?: string; desktopOnly?: boolean; width?: number }
| undefined;
const isDesktopOnly = meta?.desktopOnly;
const percent = meta?.width;
const widthStyle =
cell.column.id === 'select'
? { width: '32px', maxWidth: '32px' }
: percent && percent >= 1 && percent <= 100
? { width: `${percent}%`, maxWidth: `${percent}%` }
: undefined;
return (
<TableCell
key={cell.id}
className={cn(
'truncate p-3',
cell.column.id === 'select' && 'p-1',
'truncate px-2 py-2 md:px-3 md:py-3',
cell.column.id === 'select' && 'w-8 p-1',
meta?.className,
isDesktopOnly && 'hidden md:table-cell',
)}
style={widthStyle}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
@ -84,11 +90,7 @@ const TableRowComponent = <TData extends Record<string, unknown>>(
);
type ForwardTableRowComponentType = <TData extends Record<string, unknown>>(
props: {
row: Row<TData>;
virtualIndex?: number;
style?: React.CSSProperties;
} & React.RefAttributes<HTMLTableRowElement>,
props: TableRowComponentProps<TData> & React.RefAttributes<HTMLTableRowElement>,
) => JSX.Element;
const ForwardTableRowComponent = forwardRef(TableRowComponent) as ForwardTableRowComponentType;

View file

@ -24,7 +24,7 @@ export const DataTableSearch = memo(
aria-label={localize('com_ui_search_table')}
aria-describedby="search-description"
placeholder={placeholder || localize('com_ui_search')}
className={cn('h-12 rounded-b-none border-0 bg-surface-secondary', className)}
className={cn('h-10 rounded-b-none border-0 bg-surface-secondary md:h-12', className)}
/>
<span id="search-description" className="sr-only">
{localize('com_ui_search_table_description')}

View file

@ -0,0 +1,3 @@
export { default as DataTable } from './DataTable';
export * from './DataTable.types';
// Removed legacy DataTableSettings exports (store/context) as column resizing & dynamic sizing were deprecated.

View file

@ -4,7 +4,6 @@ export * from './AlertDialog';
export * from './Breadcrumb';
export * from './Button';
export * from './Checkbox';
export * from './Dialog';
export * from './DropdownMenu';
export * from './HoverCard';
@ -31,6 +30,7 @@ export * from './InputOTP';
export * from './MultiSearch';
export * from './Resizable';
export * from './Select';
export * from './DataTable/index';
export { default as Radio } from './Radio';
export { default as Badge } from './Badge';
export { default as Avatar } from './Avatar';

View file

@ -1,7 +1,5 @@
// Components
export * from './components';
export { default as DataTable } from './components/DataTable/DataTable';
export * from './components/DataTable/DataTable.types';
// Hooks
export * from './hooks';

View file

@ -16,10 +16,12 @@
"com_ui_search_table_description": "Type to filter results",
"com_ui_search": "Search",
"com_ui_data_table_scroll_area": "Scrollable data table area",
"com_ui_select_row": "Select Row {{0}}",
"com_ui_select_row": "Select row {{0}}",
"com_ui_loading_more_data": "Loading more data...",
"com_ui_no_search_results": "No search results found",
"com_ui_table_error": "Table Error",
"com_ui_table_error_description": "Table failed to load. Please refresh or try again.",
"com_ui_error_details": "Error Details (Dev)"
"com_ui_error_details": "Error Details (Dev)",
"com_ui_enabled": "Enabled",
"com_ui_disabled": "Disabled"
}