|
| 1 | +/** |
| 2 | + * DataGrid — advanced data grid with virtual scrolling. |
| 3 | + * |
| 4 | + * Renders large record sets efficiently using virtualized rows. |
| 5 | + * Supports column sorting, resizing indicators, and selection. |
| 6 | + */ |
| 7 | + |
| 8 | +import { useState, useCallback, useMemo, useRef } from 'react'; |
| 9 | +import { Link } from 'react-router-dom'; |
| 10 | +import { ArrowUp, ArrowDown } from 'lucide-react'; |
| 11 | +import { FieldRenderer } from '@/components/records/FieldRenderer'; |
| 12 | +import type { ObjectDefinition, RecordData, ResolvedField } from '@/types/metadata'; |
| 13 | +import { resolveFields } from '@/types/metadata'; |
| 14 | + |
| 15 | +interface DataGridProps { |
| 16 | + objectDef: ObjectDefinition; |
| 17 | + records: RecordData[]; |
| 18 | + basePath: string; |
| 19 | + /** Height of the grid container in pixels */ |
| 20 | + height?: number; |
| 21 | + /** Height of each row in pixels */ |
| 22 | + rowHeight?: number; |
| 23 | + /** Whether to show row selection checkboxes */ |
| 24 | + selectable?: boolean; |
| 25 | + onSelectionChange?: (selectedIds: string[]) => void; |
| 26 | +} |
| 27 | + |
| 28 | +type SortDirection = 'asc' | 'desc'; |
| 29 | + |
| 30 | +interface SortState { |
| 31 | + column: string; |
| 32 | + direction: SortDirection; |
| 33 | +} |
| 34 | + |
| 35 | +const ROW_HEIGHT = 40; |
| 36 | +const HEADER_HEIGHT = 44; |
| 37 | +const OVERSCAN = 5; |
| 38 | + |
| 39 | +export function DataGrid({ |
| 40 | + objectDef, |
| 41 | + records, |
| 42 | + basePath, |
| 43 | + height = 500, |
| 44 | + rowHeight = ROW_HEIGHT, |
| 45 | + selectable = false, |
| 46 | + onSelectionChange, |
| 47 | +}: DataGridProps) { |
| 48 | + const [sort, setSort] = useState<SortState | null>(null); |
| 49 | + const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); |
| 50 | + const [scrollTop, setScrollTop] = useState(0); |
| 51 | + const containerRef = useRef<HTMLDivElement>(null); |
| 52 | + |
| 53 | + const allResolved = resolveFields(objectDef.fields, ['id']); |
| 54 | + let columns: ResolvedField[]; |
| 55 | + if (objectDef.listFields) { |
| 56 | + const fieldMap = new Map(allResolved.map((f) => [f.name, f])); |
| 57 | + columns = objectDef.listFields |
| 58 | + .map((name) => fieldMap.get(name)) |
| 59 | + .filter((f): f is ResolvedField => !!f); |
| 60 | + } else { |
| 61 | + columns = allResolved.filter((f) => !f.readonly); |
| 62 | + } |
| 63 | + |
| 64 | + // Sort records |
| 65 | + const sortedRecords = useMemo(() => { |
| 66 | + if (!sort) return records; |
| 67 | + const sorted = [...records].sort((a, b) => { |
| 68 | + const aVal = a[sort.column]; |
| 69 | + const bVal = b[sort.column]; |
| 70 | + if (aVal == null && bVal == null) return 0; |
| 71 | + if (aVal == null) return 1; |
| 72 | + if (bVal == null) return -1; |
| 73 | + const comparison = String(aVal).localeCompare(String(bVal), undefined, { numeric: true }); |
| 74 | + return sort.direction === 'asc' ? comparison : -comparison; |
| 75 | + }); |
| 76 | + return sorted; |
| 77 | + }, [records, sort]); |
| 78 | + |
| 79 | + const handleSort = useCallback((column: string) => { |
| 80 | + setSort((prev) => { |
| 81 | + if (prev?.column === column) { |
| 82 | + return prev.direction === 'asc' |
| 83 | + ? { column, direction: 'desc' } |
| 84 | + : null; |
| 85 | + } |
| 86 | + return { column, direction: 'asc' }; |
| 87 | + }); |
| 88 | + }, []); |
| 89 | + |
| 90 | + const handleToggleSelect = useCallback( |
| 91 | + (id: string) => { |
| 92 | + setSelectedIds((prev) => { |
| 93 | + const next = new Set(prev); |
| 94 | + if (next.has(id)) next.delete(id); |
| 95 | + else next.add(id); |
| 96 | + onSelectionChange?.(Array.from(next)); |
| 97 | + return next; |
| 98 | + }); |
| 99 | + }, |
| 100 | + [onSelectionChange], |
| 101 | + ); |
| 102 | + |
| 103 | + const handleSelectAll = useCallback(() => { |
| 104 | + setSelectedIds((prev) => { |
| 105 | + if (prev.size === sortedRecords.length) { |
| 106 | + onSelectionChange?.([]); |
| 107 | + return new Set(); |
| 108 | + } |
| 109 | + const allIds = sortedRecords.map((r) => String(r.id ?? '')); |
| 110 | + onSelectionChange?.(allIds); |
| 111 | + return new Set(allIds); |
| 112 | + }); |
| 113 | + }, [sortedRecords, onSelectionChange]); |
| 114 | + |
| 115 | + const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { |
| 116 | + setScrollTop(e.currentTarget.scrollTop); |
| 117 | + }, []); |
| 118 | + |
| 119 | + // Virtual scrolling calculations |
| 120 | + const totalHeight = sortedRecords.length * rowHeight; |
| 121 | + const visibleCount = Math.ceil((height - HEADER_HEIGHT) / rowHeight); |
| 122 | + const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN); |
| 123 | + const endIndex = Math.min(sortedRecords.length, startIndex + visibleCount + OVERSCAN * 2); |
| 124 | + const visibleRecords = sortedRecords.slice(startIndex, endIndex); |
| 125 | + |
| 126 | + if (records.length === 0) { |
| 127 | + return ( |
| 128 | + <div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12"> |
| 129 | + <p className="text-lg font-medium"> |
| 130 | + No {(objectDef.pluralLabel ?? objectDef.label ?? 'records').toLowerCase()} yet |
| 131 | + </p> |
| 132 | + <p className="text-sm text-muted-foreground"> |
| 133 | + Records will appear here once they are created. |
| 134 | + </p> |
| 135 | + </div> |
| 136 | + ); |
| 137 | + } |
| 138 | + |
| 139 | + return ( |
| 140 | + <div className="rounded-md border" data-testid="data-grid"> |
| 141 | + <div |
| 142 | + ref={containerRef} |
| 143 | + className="overflow-auto" |
| 144 | + style={{ height }} |
| 145 | + onScroll={handleScroll} |
| 146 | + > |
| 147 | + <div style={{ minWidth: `${columns.length * 150}px` }}> |
| 148 | + {/* Header */} |
| 149 | + <div |
| 150 | + className="sticky top-0 z-10 flex border-b bg-muted/50" |
| 151 | + style={{ height: HEADER_HEIGHT }} |
| 152 | + > |
| 153 | + {selectable && ( |
| 154 | + <div className="flex w-10 shrink-0 items-center justify-center border-r"> |
| 155 | + <input |
| 156 | + type="checkbox" |
| 157 | + checked={selectedIds.size === sortedRecords.length && sortedRecords.length > 0} |
| 158 | + onChange={handleSelectAll} |
| 159 | + className="size-4 rounded" |
| 160 | + aria-label="Select all rows" |
| 161 | + /> |
| 162 | + </div> |
| 163 | + )} |
| 164 | + {columns.map((col) => ( |
| 165 | + <div |
| 166 | + key={col.name} |
| 167 | + className="flex flex-1 cursor-pointer items-center gap-1 px-3 text-sm font-medium text-muted-foreground hover:text-foreground" |
| 168 | + style={{ minWidth: 120 }} |
| 169 | + onClick={() => handleSort(col.name)} |
| 170 | + role="columnheader" |
| 171 | + aria-sort={ |
| 172 | + sort?.column === col.name |
| 173 | + ? sort.direction === 'asc' ? 'ascending' : 'descending' |
| 174 | + : 'none' |
| 175 | + } |
| 176 | + > |
| 177 | + <span>{col.label}</span> |
| 178 | + {sort?.column === col.name && ( |
| 179 | + sort.direction === 'asc' ? <ArrowUp className="size-3" /> : <ArrowDown className="size-3" /> |
| 180 | + )} |
| 181 | + </div> |
| 182 | + ))} |
| 183 | + </div> |
| 184 | + |
| 185 | + {/* Virtual rows */} |
| 186 | + <div style={{ height: totalHeight, position: 'relative' }}> |
| 187 | + {visibleRecords.map((record, idx) => { |
| 188 | + const id = String(record.id ?? ''); |
| 189 | + const top = (startIndex + idx) * rowHeight; |
| 190 | + return ( |
| 191 | + <div |
| 192 | + key={id} |
| 193 | + className="absolute flex w-full border-b hover:bg-muted/50" |
| 194 | + style={{ height: rowHeight, top }} |
| 195 | + > |
| 196 | + {selectable && ( |
| 197 | + <div className="flex w-10 shrink-0 items-center justify-center border-r"> |
| 198 | + <input |
| 199 | + type="checkbox" |
| 200 | + checked={selectedIds.has(id)} |
| 201 | + onChange={() => handleToggleSelect(id)} |
| 202 | + className="size-4 rounded" |
| 203 | + aria-label={`Select row ${id}`} |
| 204 | + /> |
| 205 | + </div> |
| 206 | + )} |
| 207 | + {columns.map((col, colIdx) => ( |
| 208 | + <div |
| 209 | + key={col.name} |
| 210 | + className="flex flex-1 items-center px-3 text-sm" |
| 211 | + style={{ minWidth: 120 }} |
| 212 | + > |
| 213 | + {colIdx === 0 ? ( |
| 214 | + <Link |
| 215 | + to={`${basePath}/${id}`} |
| 216 | + className="font-medium text-primary underline-offset-4 hover:underline" |
| 217 | + > |
| 218 | + <FieldRenderer field={col} value={record[col.name]} /> |
| 219 | + </Link> |
| 220 | + ) : ( |
| 221 | + <FieldRenderer field={col} value={record[col.name]} /> |
| 222 | + )} |
| 223 | + </div> |
| 224 | + ))} |
| 225 | + </div> |
| 226 | + ); |
| 227 | + })} |
| 228 | + </div> |
| 229 | + </div> |
| 230 | + </div> |
| 231 | + </div> |
| 232 | + ); |
| 233 | +} |
0 commit comments