From 8205e72f3f96345b63c49b0613f7982deba312bc Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Fri, 4 Apr 2025 14:16:07 -0600 Subject: [PATCH] Update AppDataGrid2.tsx --- .../AppDataGrid/AppDataGrid2.stories.tsx | 19 +- .../ui/table/AppDataGrid/AppDataGrid2.tsx | 227 +++++++++++------- 2 files changed, 145 insertions(+), 101 deletions(-) diff --git a/web/src/components/ui/table/AppDataGrid/AppDataGrid2.stories.tsx b/web/src/components/ui/table/AppDataGrid/AppDataGrid2.stories.tsx index 5a8a257ee..91926a773 100644 --- a/web/src/components/ui/table/AppDataGrid/AppDataGrid2.stories.tsx +++ b/web/src/components/ui/table/AppDataGrid/AppDataGrid2.stories.tsx @@ -62,7 +62,7 @@ export const Default: Story = { args: { rows: sampleData, resizable: true, - draggable: true, + sortable: true }, render: (args) => ( @@ -76,7 +76,7 @@ export const NonResizable: Story = { args: { rows: sampleData, resizable: false, - draggable: true, + sortable: true } }; @@ -85,8 +85,7 @@ export const NonDraggable: Story = { args: { rows: sampleData, resizable: true, - draggable: false, - sortable: true + sortable: false } }; @@ -94,7 +93,7 @@ export const NonSortable: Story = { args: { rows: sampleData, resizable: true, - draggable: true, + sortable: false } }; @@ -104,7 +103,7 @@ export const CustomColumnOrder: Story = { rows: sampleData, columnOrder: ['name', 'email', 'age', 'id', 'joinDate'], resizable: true, - draggable: true, + sortable: true } }; @@ -120,7 +119,7 @@ export const CustomColumnWidths: Story = { joinDate: 120 }, resizable: true, - draggable: true, + sortable: true } }; @@ -139,7 +138,7 @@ export const CustomFormatting: Story = { return String(value); }, resizable: true, - draggable: true, + sortable: true } }; @@ -151,7 +150,7 @@ export const WithCallbacks: Story = { onResizeColumns: (columnSizes) => console.log('Columns resized:', columnSizes), onReady: () => console.log('Grid is ready'), resizable: true, - draggable: true, + sortable: true } }; @@ -170,7 +169,7 @@ export const ManyRows: Story = { ).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 index 77229251c..530bb14d7 100644 --- a/web/src/components/ui/table/AppDataGrid/AppDataGrid2.tsx +++ b/web/src/components/ui/table/AppDataGrid/AppDataGrid2.tsx @@ -17,31 +17,31 @@ import { useSensor, useSensors, closestCenter, + pointerWithin, DragEndEvent, DragOverlay, DragStartEvent, useDraggable, useDroppable, - DragOverEvent, - defaultDropAnimationSideEffects + DragOverEvent } from '@dnd-kit/core'; import { arrayMove } 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'; +import { useMemoizedFn } from '@/hooks'; +import { CaretDown, CaretUp } from '../../icons/NucleoIconFilled'; 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; + headerFormat?: (value: string | number | Date | null, columnName: string) => string; + cellFormat?: (value: string | number | Date | null, columnName: string) => string; onReorderColumns?: (columnIds: string[]) => void; onReady?: () => void; onResizeColumns?: ( @@ -53,95 +53,113 @@ export interface AppDataGridProps { } interface DraggableHeaderProps { - header: any; // Header - sortable: boolean; + header: Header, unknown>; resizable: boolean; - isOverTarget: 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 = ({ - header, - sortable, - resizable, - isOverTarget -}) => { - // 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' - } - }); +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}` - }); + // Set up droppable area to detect when a header is over this target + const { setNodeRef: setDropNodeRef } = useDroppable({ + id: `droppable-${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 - }; + const isOverTarget = overTargetId === header.id; - return ( -
+ 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 && ( + ref={setDropNodeRef} + style={style} + className={cn( + 'bg-background relative border select-none', + isOverTarget && 'bg-primary/10 border-primary rounded-sm border-dashed' + )} + // onClick toggles sorting if enabled + onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}>
{ - e.stopPropagation(); - e.preventDefault(); - }}> -
+ className="flex h-full flex-1 items-center space-x-1 p-2" + ref={sortable ? setDragNodeRef : undefined} + {...attributes} + {...listeners} + style={{ cursor: 'grab' }}> + + {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 }) => { +const HeaderDragOverlay = ({ + header +}: { + header: Header, unknown>; +}) => { return (
@@ -156,7 +174,6 @@ export const AppDataGrid2: React.FC = React.memo( ({ className = '', resizable = true, - draggable = true, sortable = true, columnWidths: columnWidthsProp, columnOrder: serverColumnOrder, @@ -191,10 +208,15 @@ export const AppDataGrid2: React.FC = React.memo( const [overTargetId, setOverTargetId] = useState(null); // Store active header for overlay rendering - const [activeHeader, setActiveHeader] = useState(null); + const [activeHeader, setActiveHeader] = useState, + unknown + > | null>(null); // Build columns from fields. - const columns = useMemo[]>( + const columns = useMemo< + ColumnDef, string | number | Date | null>[] + >( () => fields.map((field) => ({ id: field, @@ -252,11 +274,20 @@ export const AppDataGrid2: React.FC = React.memo( useSensor(KeyboardSensor) ); + // Reference to the style element for cursor handling + const styleRef = useRef(null); + // Handle drag start to capture the active header - const handleDragStart = (event: DragStartEvent) => { + 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] @@ -265,10 +296,10 @@ export const AppDataGrid2: React.FC = React.memo( if (headerIndex !== undefined && headerIndex !== -1) { setActiveHeader(table.getHeaderGroups()[0]?.headers[headerIndex]); } - }; + }); // Handle drag over to highlight the target - const handleDragOver = (event: DragOverEvent) => { + const handleDragOver = useMemoizedFn((event: DragOverEvent) => { const { over } = event; if (over) { // Extract the actual header ID from the droppable ID @@ -277,12 +308,18 @@ export const AppDataGrid2: React.FC = React.memo( } else { setOverTargetId(null); } - }; + }); // Handle drag end to reorder columns. - const handleDragEnd = (event: DragEndEvent) => { + 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); @@ -300,7 +337,18 @@ export const AppDataGrid2: React.FC = React.memo( 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); @@ -332,7 +380,7 @@ export const AppDataGrid2: React.FC = React.memo( @@ -345,7 +393,7 @@ export const AppDataGrid2: React.FC = React.memo( header={header} sortable={sortable} resizable={resizable} - isOverTarget={header.id === overTargetId} + overTargetId={overTargetId} /> ))}
@@ -372,12 +420,9 @@ export const AppDataGrid2: React.FC = React.memo( height: `${virtualRow.size}px` }}> {row.getVisibleCells().map((cell) => ( -
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} -
+ ))}
);