mirror of https://github.com/buster-so/buster.git
data grid 2
This commit is contained in:
parent
49ec28142f
commit
4d85e27391
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
}
|
||||
};
|
|
@ -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-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 (
|
||||
<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';
|
Loading…
Reference in New Issue