From 8d11b86d6d4a15ce8322ab4dfa5e148cc12501f0 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Fri, 4 Apr 2025 15:36:52 -0600 Subject: [PATCH] resize colors --- .../TanStackDataGrid/DataGridCell.tsx | 28 ++ .../TanStackDataGrid/DataGridHeader.tsx | 297 +++++++++++++++ .../TanStackDataGrid/DataGridRow.tsx | 30 ++ .../TanStackDataGrid/TanStackDataGrid.tsx | 337 ++---------------- .../AppDataGrid/TanStackDataGrid/constants.ts | 7 + .../AppDataGrid/TanStackDataGrid/index.ts | 1 + .../initializeColumnWidths.ts | 38 ++ 7 files changed, 428 insertions(+), 310 deletions(-) create mode 100644 web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridCell.tsx create mode 100644 web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridHeader.tsx create mode 100644 web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridRow.tsx create mode 100644 web/src/components/ui/table/AppDataGrid/TanStackDataGrid/constants.ts create mode 100644 web/src/components/ui/table/AppDataGrid/TanStackDataGrid/index.ts create mode 100644 web/src/components/ui/table/AppDataGrid/TanStackDataGrid/initializeColumnWidths.ts diff --git a/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridCell.tsx b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridCell.tsx new file mode 100644 index 000000000..2a1b21656 --- /dev/null +++ b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridCell.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Cell, flexRender } from '@tanstack/react-table'; +import { cn } from '@/lib/utils'; +import { CELL_HEIGHT } from './constants'; + +interface DataGridCellProps { + cell: Cell, unknown>; +} + +//DO NOT MEMOIZE CELL +export const DataGridCell: React.FC = ({ cell }) => { + return ( + +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ + {cell.column.getIsResizing() && ( + + )} + + ); +}; + +DataGridCell.displayName = 'DataGridCell'; diff --git a/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridHeader.tsx b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridHeader.tsx new file mode 100644 index 000000000..f71688ad7 --- /dev/null +++ b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridHeader.tsx @@ -0,0 +1,297 @@ +import React, { CSSProperties, useEffect, useRef, useState } from 'react'; +import { + DndContext, + DragOverlay, + useDraggable, + useDroppable, + DragStartEvent, + DragOverEvent, + DragEndEvent, + MouseSensor, + TouchSensor, + KeyboardSensor, + useSensors, + useSensor +} from '@dnd-kit/core'; +import { Header, Table } from '@tanstack/react-table'; +import { flexRender } from '@tanstack/react-table'; +import { cn } from '@/lib/classMerge'; +import { restrictToHorizontalAxis } from '@dnd-kit/modifiers'; +import { pointerWithin } from '@dnd-kit/core'; +import { CaretDown, CaretUp } from '../../../icons/NucleoIconFilled'; +import { HEADER_HEIGHT } from './constants'; +import { useMemoizedFn } from '@/hooks'; +import { arrayMove } from '@dnd-kit/sortable'; + +interface DraggableHeaderProps { + header: Header, unknown>; + resizable: boolean; + sortable: boolean; + overTargetId: string | null; +} + +const DraggableHeader: React.FC = React.memo( + ({ header, sortable, resizable, overTargetId }) => { + // Set up dnd-kit's useDraggable for this header cell + const { + attributes, + listeners, + isDragging, + setNodeRef: setDragNodeRef + } = useDraggable({ + id: header.id, + // This ensures the drag overlay matches the element's position exactly + data: { + type: 'header' + } + }); + + // Set up droppable area to detect when a header is over this target + const { setNodeRef: setDropNodeRef } = useDroppable({ + id: `droppable-${header.id}` + }); + + const isOverTarget = overTargetId === header.id; + + const style: CSSProperties = { + position: 'relative', + whiteSpace: 'nowrap', + width: header.column.getSize(), + opacity: isDragging ? 0.4 : 1, + transition: 'none', // Prevent any transitions for snappy changes + height: `${HEADER_HEIGHT}px` // Set fixed header height + }; + + return ( + + + + {flexRender(header.column.columnDef.header, header.getContext())} + + {header.column.getIsSorted() === 'asc' && ( + + + + )} + {header.column.getIsSorted() === 'desc' && ( + + + + )} + + {resizable && ( + { + e.stopPropagation(); + e.preventDefault(); + }}> + + + )} + + ); + } +); + +DraggableHeader.displayName = 'DraggableHeader'; + +// Header content component to use in the DragOverlay +const HeaderDragOverlay = ({ + header +}: { + header: Header, unknown>; +}) => { + return ( +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getIsSorted() === 'asc' && 🔼} + {header.column.getIsSorted() === 'desc' && 🔽} +
+ ); +}; + +interface DataGridHeaderProps { + table: Table>; + sortable: boolean; + resizable: boolean; + colOrder: string[]; + setColOrder: (colOrder: string[]) => void; + onReorderColumns?: (colOrder: string[]) => void; +} + +export const DataGridHeader: React.FC = ({ + table, + colOrder, + sortable, + resizable, + setColOrder, + onReorderColumns +}) => { + // Track active drag item and over target + const [activeId, setActiveId] = useState(null); + const [overTargetId, setOverTargetId] = useState(null); + // Store active header for overlay rendering + const [activeHeader, setActiveHeader] = useState, + unknown + > | null>(null); + + // Reference to the style element for cursor handling + const styleRef = useRef(null); + + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { + distance: 2 + } + }), + useSensor(TouchSensor, { + activationConstraint: { + distance: 2 + } + }), + useSensor(KeyboardSensor) + ); + + // Handle drag start to capture the active header + const onDragStart = useMemoizedFn((event: DragStartEvent) => { + const { active } = event; + setActiveId(active.id as string); + + // Add global cursor style + const style = document.createElement('style'); + style.innerHTML = `* { cursor: grabbing !important; }`; + document.head.appendChild(style); + styleRef.current = style; + + // Find and store the active header for the overlay + const headerIndex = table + .getHeaderGroups()[0] + ?.headers.findIndex((header) => header.id === active.id); + + if (headerIndex !== undefined && headerIndex !== -1) { + setActiveHeader(table.getHeaderGroups()[0]?.headers[headerIndex]); + } + }); + + // Handle drag over to highlight the target + const onDragOver = useMemoizedFn((event: DragOverEvent) => { + const { over } = event; + if (over) { + // Extract the actual header ID from the droppable ID + const headerId = over.id.toString().replace('droppable-', ''); + setOverTargetId(headerId); + } else { + setOverTargetId(null); + } + }); + + // Handle drag end to reorder columns. + const onDragEnd = useMemoizedFn((event: DragEndEvent) => { + const { active, over } = event; + + // Remove global cursor style + if (styleRef.current) { + document.head.removeChild(styleRef.current); + styleRef.current = null; + } + + // Reset states immediately to prevent animation + setActiveId(null); + setActiveHeader(null); + setOverTargetId(null); + + if (active && over) { + // Extract the actual header ID from the droppable ID + const overId = over.id.toString().replace('droppable-', ''); + + if (active.id !== overId) { + const oldIndex = colOrder.indexOf(active.id as string); + const newIndex = colOrder.indexOf(overId); + const newOrder = arrayMove(colOrder, oldIndex, newIndex); + setColOrder(newOrder); + if (onReorderColumns) onReorderColumns(newOrder); + } + } + }); + + // Clean up any styles on unmount + useEffect(() => { + return () => { + // Clean up cursor style if component unmounts during a drag + if (styleRef.current) { + document.head.removeChild(styleRef.current); + styleRef.current = null; + } + }; + }, []); + + return ( + + + + {table + .getHeaderGroups()[0] + ?.headers.map( + (header: Header, unknown>) => ( + + ) + )} + + + {/* Drag Overlay */} + + {activeId && activeHeader && } + + + + ); +}; + +DataGridHeader.displayName = 'DataGridHeader'; diff --git a/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridRow.tsx b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridRow.tsx new file mode 100644 index 000000000..5389ee09b --- /dev/null +++ b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/DataGridRow.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Row } from '@tanstack/react-table'; +import { VirtualItem } from '@tanstack/react-virtual'; +import { DataGridCell } from './DataGridCell'; +import { cn } from '@/lib/classMerge'; + +interface DataGridRowProps { + row: Row>; + virtualRow: VirtualItem; +} + +export const DataGridRow: React.FC = ({ row, virtualRow }) => { + return ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ); +}; + +DataGridRow.displayName = 'DataGridRow'; diff --git a/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/TanStackDataGrid.tsx b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/TanStackDataGrid.tsx index eabdfaf67..446f7d9ff 100644 --- a/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/TanStackDataGrid.tsx +++ b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/TanStackDataGrid.tsx @@ -1,36 +1,20 @@ +'use client'; + import React, { useMemo, useRef, useState, useEffect, CSSProperties } from 'react'; import { ColumnDef, - flexRender, getCoreRowModel, getSortedRowModel, useReactTable, - SortingState, - Header + SortingState } from '@tanstack/react-table'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { - DndContext, - MouseSensor, - TouchSensor, - KeyboardSensor, - useSensor, - useSensors, - pointerWithin, - DragEndEvent, - DragOverlay, - DragStartEvent, - useDraggable, - useDroppable, - DragOverEvent -} from '@dnd-kit/core'; -import { arrayMove } from '@dnd-kit/sortable'; -import sampleSize from 'lodash/sampleSize'; import { defaultCellFormat, defaultHeaderFormat } from '../helpers'; import { cn } from '@/lib/classMerge'; -import { restrictToHorizontalAxis } from '@dnd-kit/modifiers'; -import { useMemoizedFn } from '@/hooks'; -import { CaretDown, CaretUp } from '../../../icons/NucleoIconFilled'; +import { DataGridHeader } from './DataGridHeader'; +import { DataGridRow } from './DataGridRow'; +import { CELL_HEIGHT, OVERSCAN } from './constants'; +import { initializeColumnWidths } from './initializeColumnWidths'; export interface TanStackDataGridProps { className?: string; @@ -51,125 +35,6 @@ export interface TanStackDataGridProps { ) => void; } -interface DraggableHeaderProps { - header: Header, unknown>; - resizable: boolean; - sortable: boolean; - overTargetId: string | null; -} - -// Constants for consistent sizing -const HEADER_HEIGHT = 36; // 9*4 = 36px (h-9 in Tailwind) - -const DraggableHeader: React.FC = React.memo( - ({ header, sortable, resizable, overTargetId }) => { - // Set up dnd-kit's useDraggable for this header cell - const { - attributes, - listeners, - isDragging, - setNodeRef: setDragNodeRef - } = useDraggable({ - id: header.id, - // This ensures the drag overlay matches the element's position exactly - data: { - type: 'header' - } - }); - - // Set up droppable area to detect when a header is over this target - const { setNodeRef: setDropNodeRef } = useDroppable({ - id: `droppable-${header.id}` - }); - - const isOverTarget = overTargetId === header.id; - - const style: CSSProperties = { - position: 'relative', - whiteSpace: 'nowrap', - width: header.column.getSize(), - opacity: isDragging ? 0.4 : 1, - transition: 'none', // Prevent any transitions for snappy changes - height: `${HEADER_HEIGHT}px` // Set fixed header height - }; - - return ( - -
- - {flexRender(header.column.columnDef.header, header.getContext())} - - {header.column.getIsSorted() === 'asc' && ( - - - - )} - {header.column.getIsSorted() === 'desc' && ( - - - - )} -
- {resizable && ( -
{ - e.stopPropagation(); - e.preventDefault(); - }}> -
-
- )} - - ); - } -); - -DraggableHeader.displayName = 'DraggableHeader'; - -// Header content component to use in the DragOverlay -const HeaderDragOverlay = ({ - header -}: { - header: Header, unknown>; -}) => { - return ( -
- {flexRender(header.column.columnDef.header, header.getContext())} - {header.column.getIsSorted() === 'asc' && 🔼} - {header.column.getIsSorted() === 'desc' && 🔽} -
- ); -}; - export const TanStackDataGrid: React.FC = React.memo( ({ className = '', @@ -189,30 +54,13 @@ export const TanStackDataGrid: React.FC = React.memo( 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; + return initializeColumnWidths(fields, rows, columnWidthsProp, cellFormat, headerFormat); }); const [colOrder, setColOrder] = useState(serverColumnOrder || fields); - // Track active drag item and over target - const [activeId, setActiveId] = useState(null); - const [overTargetId, setOverTargetId] = useState(null); - - // Store active header for overlay rendering - const [activeHeader, setActiveHeader] = useState, - unknown - > | null>(null); - // Build columns from fields. const columns = useMemo< ColumnDef, string | number | Date | null>[] @@ -246,6 +94,15 @@ export const TanStackDataGrid: React.FC = React.memo( columnResizeMode: 'onChange' }); + // Set up the virtualizer for infinite scrolling. + const parentRef = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: table.getRowModel().rows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => CELL_HEIGHT, // estimated row height + overscan: OVERSCAN + }); + // Notify when column sizing changes. useEffect(() => { if (onResizeColumns) { @@ -259,140 +116,17 @@ export const TanStackDataGrid: React.FC = React.memo( if (onReady) onReady(); }, [onReady]); - // Set up dnd-kit sensors. - const sensors = useSensors( - useSensor(MouseSensor, { - activationConstraint: { - distance: 2 - } - }), - useSensor(TouchSensor, { - activationConstraint: { - distance: 2 - } - }), - useSensor(KeyboardSensor) - ); - - // Reference to the style element for cursor handling - const styleRef = useRef(null); - - // Handle drag start to capture the active header - const handleDragStart = useMemoizedFn((event: DragStartEvent) => { - const { active } = event; - setActiveId(active.id as string); - - // Add global cursor style - const style = document.createElement('style'); - style.innerHTML = `* { cursor: grabbing !important; }`; - document.head.appendChild(style); - styleRef.current = style; - - // Find and store the active header for the overlay - const headerIndex = table - .getHeaderGroups()[0] - ?.headers.findIndex((header) => header.id === active.id); - - if (headerIndex !== undefined && headerIndex !== -1) { - setActiveHeader(table.getHeaderGroups()[0]?.headers[headerIndex]); - } - }); - - // Handle drag over to highlight the target - const handleDragOver = useMemoizedFn((event: DragOverEvent) => { - const { over } = event; - if (over) { - // Extract the actual header ID from the droppable ID - const headerId = over.id.toString().replace('droppable-', ''); - setOverTargetId(headerId); - } else { - setOverTargetId(null); - } - }); - - // Handle drag end to reorder columns. - const handleDragEnd = useMemoizedFn((event: DragEndEvent) => { - const { active, over } = event; - - // Remove global cursor style - if (styleRef.current) { - document.head.removeChild(styleRef.current); - styleRef.current = null; - } - - // Reset states immediately to prevent animation - setActiveId(null); - setActiveHeader(null); - setOverTargetId(null); - - if (active && over) { - // Extract the actual header ID from the droppable ID - const overId = over.id.toString().replace('droppable-', ''); - - if (active.id !== overId) { - const oldIndex = colOrder.indexOf(active.id as string); - const newIndex = colOrder.indexOf(overId); - const newOrder = arrayMove(colOrder, oldIndex, newIndex); - setColOrder(newOrder); - if (onReorderColumns) onReorderColumns(newOrder); - } - } - }); - - // Clean up any styles on unmount - useEffect(() => { - return () => { - // Clean up cursor style if component unmounts during a drag - if (styleRef.current) { - document.head.removeChild(styleRef.current); - styleRef.current = null; - } - }; - }, []); - - // Set up the virtualizer for infinite scrolling. - const parentRef = useRef(null); - const rowVirtualizer = useVirtualizer({ - count: table.getRowModel().rows.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 36, // estimated row height - overscan: 5 - }); - return ( -
+
- {/* Header */} - - - - {table - .getHeaderGroups()[0] - ?.headers.map((header) => ( - - ))} - - - {/* Drag Overlay */} - - {activeId && activeHeader && } - - - + {/* Body */} = React.memo( style={{ display: 'grid', height: `${rowVirtualizer.getTotalSize()}px` }}> {rowVirtualizer.getVirtualItems().map((virtualRow) => { const row = table.getRowModel().rows[virtualRow.index]; - return ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ); + return ; })}
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
diff --git a/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/constants.ts b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/constants.ts new file mode 100644 index 000000000..a1455b626 --- /dev/null +++ b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/constants.ts @@ -0,0 +1,7 @@ +// Constants for consistent sizing +export const HEADER_HEIGHT = 28; // 9*4 = 36px (h-9 in Tailwind) +export const CELL_HEIGHT = 28; // 9*4 = 36px (h-9 in Tailwind) +export const OVERSCAN = 5; + +export const MIN_COLUMN_WIDTH = 80; +export const MAX_COLUMN_WIDTH = 350; diff --git a/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/index.ts b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/index.ts new file mode 100644 index 000000000..47711ed4c --- /dev/null +++ b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/index.ts @@ -0,0 +1 @@ +export * from './TanStackDataGrid'; diff --git a/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/initializeColumnWidths.ts b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/initializeColumnWidths.ts new file mode 100644 index 000000000..362f6bb60 --- /dev/null +++ b/web/src/components/ui/table/AppDataGrid/TanStackDataGrid/initializeColumnWidths.ts @@ -0,0 +1,38 @@ +import { measureTextWidth } from '@/lib'; +import sampleSize from 'lodash/sampleSize'; +import clamp from 'lodash/clamp'; +import { MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH } from './constants'; + +export const initializeColumnWidths = ( + fields: string[], + rows: Record[], + columnWidthsProp: Record | undefined, + cellFormat: (value: string | number | null | Date, field: string) => string, + headerFormat: (value: string | number | Date | null, columnName: string) => string +) => { + const sampleOfRows = sampleSize(rows, 15); + const initial: Record = {}; + fields.forEach((field) => { + initial[field] = + columnWidthsProp?.[field] || + getDefaultColumnWidth(sampleOfRows, field, cellFormat, headerFormat); + }); + return initial; +}; + +const getDefaultColumnWidth = ( + rows: Record[], + field: string, + cellFormat: (value: string | number | null | Date, field: string) => string, + headerFormat: (value: string | number | Date | null, columnName: string) => string +) => { + const headerString = headerFormat(field, field); + const longestString = rows.reduce((acc, curr) => { + const currString = cellFormat(curr[field], field); + if (!currString) return acc; + return acc.length > currString.length ? acc : currString; + }, headerString); + const longestWidth = measureTextWidth(longestString).width + 10; + const width = clamp(longestWidth, MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH); + return width; +};