diff --git a/web/package-lock.json b/web/package-lock.json index 7ea590efa..34e7ece48 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 82150cb66..26572a69c 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/features/layouts/AppVerticalCodeSplitter/DataContainer.tsx b/web/src/components/features/layouts/AppVerticalCodeSplitter/DataContainer.tsx index deb146046..eadcc423f 100644 --- a/web/src/components/features/layouts/AppVerticalCodeSplitter/DataContainer.tsx +++ b/web/src/components/features/layouts/AppVerticalCodeSplitter/DataContainer.tsx @@ -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'; diff --git a/web/src/components/ui/table/AppDataGrid/AppDataGrid2.stories.tsx b/web/src/components/ui/table/AppDataGrid/AppDataGrid2.stories.tsx new file mode 100644 index 000000000..7dd67d22d --- /dev/null +++ b/web/src/components/ui/table/AppDataGrid/AppDataGrid2.stories.tsx @@ -0,0 +1,171 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { AppDataGrid2 } from './AppDataGrid2'; + +const meta: Meta = { + title: 'UI/Table/AppDataGrid2', + component: AppDataGrid2, + parameters: { + layout: 'fullscreen' + }, + tags: ['autodocs'] +}; + +export default meta; +type Story = StoryObj; + +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 + } +}; diff --git a/web/src/components/ui/table/AppDataGrid/AppDataGrid2.tsx b/web/src/components/ui/table/AppDataGrid/AppDataGrid2.tsx new file mode 100644 index 000000000..89f95c086 --- /dev/null +++ b/web/src/components/ui/table/AppDataGrid/AppDataGrid2.tsx @@ -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[]; + columnOrder?: string[]; + columnWidths?: Record; + 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 + sortable: boolean; + resizable: boolean; +} +const SortableHeader: React.FC = ({ header, sortable, resizable }) => { + // Set up dnd-kit’s 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 ( +
+
+ {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getIsSorted() === 'asc' && 🔼} + {header.column.getIsSorted() === 'desc' && 🔽} +
+ {resizable && ( +
{ + e.stopPropagation(); + e.preventDefault(); + }}> +
+
+ )} +
+ ); +}; + +export const AppDataGrid2: React.FC = 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([]); + const [columnSizing, setColumnSizing] = useState(() => { + const initial: Record = {}; + fields.forEach((field) => { + initial[field] = columnWidthsProp?.[field] || 100; + }); + return initial; + }); + const [colOrder, setColOrder] = useState(serverColumnOrder || fields); + + // Build columns from fields. + const columns = useMemo[]>( + () => + 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(null); + const rowVirtualizer = useVirtualizer({ + count: table.getRowModel().rows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, // estimated row height + overscan: 5 + }); + + return ( +
+ {/* Header */} +
+ + header.id) || []} + strategy={horizontalListSortingStrategy}> +
+ {table + .getHeaderGroups()[0] + ?.headers.map((header) => ( + + ))} +
+
+
+
+ {/* Body */} +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = table.getRowModel().rows[virtualRow.index]; + return ( +
+ {row.getVisibleCells().map((cell) => ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ))} +
+ ); + })} +
+
+ ); + } +); + +AppDataGrid2.displayName = 'AppDataGrid2';