data grid 2

This commit is contained in:
Nate Kelley 2025-04-04 13:38:53 -06:00
parent 49ec28142f
commit 4d85e27391
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
5 changed files with 469 additions and 1 deletions

28
web/package-lock.json generated
View File

@ -36,6 +36,7 @@
"@tanstack/react-query-devtools": "^5.71.10",
"@tanstack/react-query-persist-client": "^5.71.10",
"@tanstack/react-table": "^8.21.2",
"@tanstack/react-virtual": "^3.13.6",
"@types/jest": "^29.5.14",
"@types/prettier": "^2.7.3",
"@types/react-color": "^3.0.13",
@ -6983,6 +6984,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.6.tgz",
"integrity": "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/store": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.0.tgz",
@ -7006,6 +7024,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.6",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.6.tgz",
"integrity": "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",

View File

@ -44,6 +44,7 @@
"@tanstack/react-query-devtools": "^5.71.10",
"@tanstack/react-query-persist-client": "^5.71.10",
"@tanstack/react-table": "^8.21.2",
"@tanstack/react-virtual": "^3.13.6",
"@types/jest": "^29.5.14",
"@types/prettier": "^2.7.3",
"@types/react-color": "^3.0.13",

View File

@ -1,4 +1,4 @@
import type { IDataResult } from '@/api/asset_interfaces';
import type { IDataResult } from '@/api/asset_interfaces/metric';
import React from 'react';
import isEmpty from 'lodash/isEmpty';
import { AppDataGrid } from '@/components/ui/table/AppDataGrid';

View File

@ -0,0 +1,171 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AppDataGrid2 } from './AppDataGrid2';
const meta: Meta<typeof AppDataGrid2> = {
title: 'UI/Table/AppDataGrid2',
component: AppDataGrid2,
parameters: {
layout: 'fullscreen'
},
tags: ['autodocs']
};
export default meta;
type Story = StoryObj<typeof AppDataGrid2>;
const sampleData = [
{
id: 1,
name: 'John Doe',
age: 30,
email: 'john@example.com',
joinDate: new Date('2023-01-15').toISOString()
},
{
id: 2,
name: 'Jane Smith',
age: 25,
email: 'jane@example.com',
joinDate: new Date('2023-02-20').toISOString()
},
{
id: 3,
name: 'Bob Johnson',
age: 35,
email: 'bob@example.com',
joinDate: new Date('2023-03-10').toISOString()
},
{
id: 4,
name: 'Alice Brown',
age: 28,
email: 'alice@example.com',
joinDate: new Date('2023-04-05').toISOString()
},
{
id: 5,
name: 'Michael Wilson',
age: 42,
email: 'michael@example.com',
joinDate: new Date('2023-05-12').toISOString()
},
{
id: 6,
name: 'Sarah Davis',
age: 31,
email: 'sarah@example.com',
joinDate: new Date('2023-06-08').toISOString()
}
];
export const Default: Story = {
args: {
rows: sampleData,
resizable: true,
draggable: true,
sortable: true
}
};
export const NonResizable: Story = {
args: {
rows: sampleData,
resizable: false,
draggable: true,
sortable: true
}
};
export const NonDraggable: Story = {
args: {
rows: sampleData,
resizable: true,
draggable: false,
sortable: true
}
};
export const NonSortable: Story = {
args: {
rows: sampleData,
resizable: true,
draggable: true,
sortable: false
}
};
export const CustomColumnOrder: Story = {
args: {
rows: sampleData,
columnOrder: ['name', 'email', 'age', 'id', 'joinDate'],
resizable: true,
draggable: true,
sortable: true
}
};
export const CustomColumnWidths: Story = {
args: {
rows: sampleData,
columnWidths: {
id: 70,
name: 180,
age: 80,
email: 220,
joinDate: 120
},
resizable: true,
draggable: true,
sortable: true
}
};
export const CustomFormatting: Story = {
args: {
rows: sampleData,
headerFormat: (value, columnName) => columnName.toUpperCase(),
cellFormat: (value, columnName) => {
if (columnName === 'joinDate' && value instanceof Date) {
return value.toLocaleDateString();
}
if (columnName === 'age') {
return `${value} years`;
}
return String(value);
},
resizable: true,
draggable: true,
sortable: true
}
};
export const WithCallbacks: Story = {
args: {
rows: sampleData,
onReorderColumns: (columnIds) => console.log('Columns reordered:', columnIds),
onResizeColumns: (columnSizes) => console.log('Columns resized:', columnSizes),
onReady: () => console.log('Grid is ready'),
resizable: true,
draggable: true,
sortable: true
}
};
export const ManyRows: Story = {
args: {
rows: Array.from({ length: 1000 }).map((_, index) => ({
id: index + 1,
name: `User ${index + 1}`,
age: Math.floor(Math.random() * 50) + 18,
email: `user${index + 1}@example.com`,
joinDate: new Date(
2023,
Math.floor(Math.random() * 12),
Math.floor(Math.random() * 28) + 1
).toISOString()
})),
resizable: true,
draggable: true,
sortable: true
}
};

View File

@ -0,0 +1,268 @@
import React, { useMemo, useRef, useState, useEffect, CSSProperties } from 'react';
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
SortingState
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
DndContext,
MouseSensor,
TouchSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCenter,
DragEndEvent
} from '@dnd-kit/core';
import { arrayMove, SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import sampleSize from 'lodash/sampleSize';
import { defaultCellFormat, defaultHeaderFormat } from './helpers';
import { cn } from '@/lib/classMerge';
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
export interface AppDataGridProps {
className?: string;
resizable?: boolean;
draggable?: boolean;
sortable?: boolean;
rows: Record<string, string | number | null | Date>[];
columnOrder?: string[];
columnWidths?: Record<string, number>;
headerFormat?: (value: any, columnName: string) => string;
cellFormat?: (value: any, columnName: string) => string;
onReorderColumns?: (columnIds: string[]) => void;
onReady?: () => void;
onResizeColumns?: (
columnSizes: {
key: string;
size: number;
}[]
) => void;
}
interface SortableHeaderProps {
header: any; // Header<any, unknown>
sortable: boolean;
resizable: boolean;
}
const SortableHeader: React.FC<SortableHeaderProps> = ({ header, sortable, resizable }) => {
// Set up dnd-kits useSortable for this header cell.
const { attributes, listeners, isDragging, setNodeRef, transform, transition } = useSortable({
id: header.id
});
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: 'relative',
transform: CSS.Translate.toString(transform), // translate instead of transform to avoid squishing
transition: 'width transform 0.2s ease-in-out',
whiteSpace: 'nowrap',
width: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
boxShadow: isDragging ? '0 0 10px rgba(0, 0, 0, 0.5)' : 'none'
};
return (
<div
style={style}
className="relative flex items-center border bg-gray-100 p-2 select-none"
// onClick toggles sorting if enabled
onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}>
<div className="flex-1" ref={setNodeRef} {...attributes} {...listeners}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' && <span> 🔼</span>}
{header.column.getIsSorted() === 'desc' && <span> 🔽</span>}
</div>
{resizable && (
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}>
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className="absolute top-0 right-0 h-full w-2 cursor-col-resize select-none"
/>
</div>
)}
</div>
);
};
export const AppDataGrid2: React.FC<AppDataGridProps> = React.memo(
({
className = '',
resizable = true,
draggable = true,
sortable = true,
columnWidths: columnWidthsProp,
columnOrder: serverColumnOrder,
onReorderColumns,
onResizeColumns,
onReady,
rows,
headerFormat = defaultHeaderFormat,
cellFormat = defaultCellFormat
}) => {
// Get a list of fields (each field becomes a column)
const fields = useMemo(() => {
return Object.keys(rows[0] || {});
}, [rows]);
// (Optional) Use a sample of rows for preview purposes.
const sampleOfRows = useMemo(() => sampleSize(rows, 15), [rows]);
// Set up initial states for sorting, column sizing, and column order.
const [sorting, setSorting] = useState<SortingState>([]);
const [columnSizing, setColumnSizing] = useState(() => {
const initial: Record<string, number> = {};
fields.forEach((field) => {
initial[field] = columnWidthsProp?.[field] || 100;
});
return initial;
});
const [colOrder, setColOrder] = useState<string[]>(serverColumnOrder || fields);
// Build columns from fields.
const columns = useMemo<ColumnDef<any, any>[]>(
() =>
fields.map((field) => ({
id: field,
accessorKey: field,
header: () => headerFormat(field, field),
cell: (info) => cellFormat(info.getValue(), field),
enableSorting: sortable,
enableResizing: resizable
})),
[fields, headerFormat, cellFormat, sortable, resizable]
);
// Create the table instance.
const table = useReactTable({
data: rows,
columns,
state: {
sorting,
columnSizing,
columnOrder: colOrder
},
onSortingChange: setSorting,
onColumnSizingChange: setColumnSizing,
onColumnOrderChange: setColOrder,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
columnResizeMode: 'onChange'
});
// Notify when column sizing changes.
useEffect(() => {
if (onResizeColumns) {
const sizes = Object.entries(columnSizing).map(([key, size]) => ({ key, size }));
onResizeColumns(sizes);
}
}, [columnSizing, onResizeColumns]);
// Call onReady when the table is first set up.
useEffect(() => {
if (onReady) onReady();
}, [onReady]);
// Set up dnd-kit sensors.
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 2
}
}),
useSensor(TouchSensor, {
activationConstraint: {
distance: 2
}
}),
useSensor(KeyboardSensor)
);
// Handle drag end to reorder columns.
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active && over && active.id !== over.id) {
const oldIndex = colOrder.indexOf(active.id as string);
const newIndex = colOrder.indexOf(over.id as string);
const newOrder = arrayMove(colOrder, oldIndex, newIndex);
setColOrder(newOrder);
if (onReorderColumns) onReorderColumns(newOrder);
}
};
// Set up the virtualizer for infinite scrolling.
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: table.getRowModel().rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35, // estimated row height
overscan: 5
});
return (
<div ref={parentRef} className={cn('h-full w-full overflow-auto', className)}>
{/* Header */}
<div className="sticky top-0 z-10 w-full bg-gray-100">
<DndContext
sensors={sensors}
modifiers={[restrictToHorizontalAxis]}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}>
<SortableContext
items={table.getHeaderGroups()[0]?.headers.map((header) => header.id) || []}
strategy={horizontalListSortingStrategy}>
<div className="flex">
{table
.getHeaderGroups()[0]
?.headers.map((header) => (
<SortableHeader
key={header.id}
header={header}
sortable={sortable}
resizable={resizable}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
{/* Body */}
<div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index];
return (
<div
key={row.id}
className="absolute inset-x-0 flex"
style={{
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`
}}>
{row.getVisibleCells().map((cell) => (
<div
key={cell.id}
className="border p-2"
style={{ width: cell.column.getSize() }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
);
})}
</div>
</div>
);
}
);
AppDataGrid2.displayName = 'AppDataGrid2';