import { useRef, useMemo, useCallback, useEffect } from "react"; import { Table } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useAtom } from "jotai"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, horizontalListSortingStrategy, } from "@dnd-kit/sortable"; import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types"; import { editingCellAtom, activePropertyMenuAtom, propertyMenuDirtyAtom, propertyMenuCloseRequestAtom } from "@/features/base/atoms/base-atoms"; import { useColumnResize } from "@/features/base/hooks/use-column-resize"; import { useGridKeyboardNav } from "@/features/base/hooks/use-grid-keyboard-nav"; import { useRowDrag } from "@/features/base/hooks/use-row-drag"; import { useRowSelection } from "@/features/base/hooks/use-row-selection"; import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows"; import { GridHeader } from "./grid-header"; import { GridRow } from "./grid-row"; import { AddRowButton } from "./add-row-button"; import { SelectionActionBar } from "./selection-action-bar"; import classes from "@/features/base/styles/grid.module.css"; const ROW_HEIGHT = 36; const OVERSCAN = 10; type GridContainerProps = { table: Table; properties: IBaseProperty[]; onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void; onAddRow?: () => void; baseId?: string; onColumnReorder?: (columnId: string, overColumnId: string) => void; onResizeEnd?: () => void; onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void; hasNextPage?: boolean; isFetchingNextPage?: boolean; onFetchNextPage?: () => void; }; export function GridContainer({ table, properties, onCellUpdate, onAddRow, baseId, onColumnReorder, onResizeEnd, onRowReorder, hasNextPage, isFetchingNextPage, onFetchNextPage, }: GridContainerProps) { const scrollRef = useRef(null); // Records the `rows.length` at which we last triggered a page fetch. // The trigger effect re-runs on every render (its `virtualItems` dep // has a new identity each call) and can't rely on `isFetchingNextPage` // alone: once a page commits, `isFetchingNextPage` flips to false for // one render, the "near bottom" condition still holds because the // virtualizer anchors on the old scroll position, and we'd fire again. // Gating on `rows.length` guarantees at most one fire per new page. const lastTriggeredRowsLenRef = useRef(0); const rows = table.getRowModel().rows; const [editingCell, setEditingCell] = useAtom(editingCellAtom) as unknown as [EditingCell, (val: EditingCell) => void]; const [, setActivePropertyMenu] = useAtom(activePropertyMenuAtom) as unknown as [string | null, (val: string | null) => void]; const [propertyMenuDirty] = useAtom(propertyMenuDirtyAtom) as unknown as [boolean]; const [, setCloseRequest] = useAtom(propertyMenuCloseRequestAtom) as unknown as [number, (val: number) => void]; const propertyMenuDirtyRef = useRef(propertyMenuDirty); propertyMenuDirtyRef.current = propertyMenuDirty; const closeRequestCounterRef = useRef(0); const { selectionCount, clear: clearSelection } = useRowSelection(); const { deleteSelected } = useDeleteSelectedRows(baseId ?? ""); useEffect(() => { const handleMouseDown = (e: MouseEvent) => { const target = e.target as HTMLElement; if (target.closest(`.${classes.headerCell}`)) return; if (target.closest("[role=\"dialog\"]")) return; if (target.closest("[role=\"listbox\"]")) return; if (target.closest("[data-mantine-shared-portal-node]")) return; if (target.closest(`.${classes.cellEditing}`)) return; if (propertyMenuDirtyRef.current) { closeRequestCounterRef.current += 1; setCloseRequest(closeRequestCounterRef.current); } else { setActivePropertyMenu(null); } setEditingCell(null); }; document.addEventListener("mousedown", handleMouseDown); return () => document.removeEventListener("mousedown", handleMouseDown); }, [setActivePropertyMenu, setEditingCell, setCloseRequest]); useColumnResize(table, onResizeEnd ?? (() => {})); useGridKeyboardNav({ table, editingCell, setEditingCell, containerRef: scrollRef, }); const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollRef.current, estimateSize: () => ROW_HEIGHT, overscan: OVERSCAN, }); const virtualItems = virtualizer.getVirtualItems(); useEffect(() => { if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return; const lastItem = virtualItems[virtualItems.length - 1]; if (!lastItem) return; if (lastItem.index < rows.length - OVERSCAN * 2) return; if (rows.length <= lastTriggeredRowsLenRef.current) return; lastTriggeredRowsLenRef.current = rows.length; onFetchNextPage(); }, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]); useEffect(() => { // When the underlying row set shrinks (filter changed, sort toggled, // view switched) or resets to zero, we're on a fresh pagination // sequence — un-gate the trigger so the first page triggers a // potential next fetch correctly. if (rows.length === 0 || rows.length < lastTriggeredRowsLenRef.current) { lastTriggeredRowsLenRef.current = 0; } }, [rows.length]); useEffect(() => { const el = scrollRef.current; if (!el || !baseId) return; const handler = (e: KeyboardEvent) => { if (editingCell) return; const active = document.activeElement as HTMLElement | null; if (!active || !el.contains(active)) return; const tag = active.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || active.isContentEditable) { return; } if (e.key === "Escape" && selectionCount > 0) { clearSelection(); return; } if ((e.key === "Delete" || e.key === "Backspace") && selectionCount > 0) { e.preventDefault(); void deleteSelected(); } }; el.addEventListener("keydown", handler); return () => el.removeEventListener("keydown", handler); }, [editingCell, selectionCount, clearSelection, deleteSelected, baseId]); const gridTemplateColumns = useMemo(() => { const visibleColumns = table.getVisibleLeafColumns(); const columnWidths = visibleColumns.map((col) => `${col.getSize()}px`); return columnWidths.join(" ") + (baseId ? " 40px" : ""); }, [table, table.getState().columnSizing, table.getState().columnVisibility, table.getState().columnOrder, baseId]); const totalHeight = virtualizer.getTotalSize(); const paddingTop = virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0; const paddingBottom = virtualItems.length > 0 ? totalHeight - (virtualItems[virtualItems.length - 1]?.end ?? 0) : 0; const rowIds = useMemo(() => rows.map((r) => r.id), [rows]); const handleRowReorder = useCallback( (rowId: string, targetRowId: string, position: "above" | "below") => { onRowReorder?.(rowId, targetRowId, position); }, [onRowReorder], ); const { dragState: rowDragState, handleDragStart: handleRowDragStart, handleDragOver: handleRowDragOver, handleDragEnd: handleRowDragEnd, handleDragLeave: handleRowDragLeave, } = useRowDrag({ rowIds, onReorder: handleRowReorder }); const handleAddRow = useCallback(() => { onAddRow?.(); }, [onAddRow]); const handlePropertyCreated = useCallback(() => { // Wait for React to re-render with the new column, then scroll to it requestAnimationFrame(() => { requestAnimationFrame(() => { scrollRef.current?.scrollTo({ left: scrollRef.current.scrollWidth, behavior: "smooth", }); }); }); }, []); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, }), useSensor(KeyboardSensor), ); const sortableColumnIds = useMemo(() => { return table .getVisibleLeafColumns() .filter((col) => col.id !== "__row_number") .map((col) => col.id); }, [table, table.getState().columnOrder]); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; onColumnReorder?.(active.id as string, over.id as string); }, [onColumnReorder], ); const modifiers = useMemo(() => [restrictToHorizontalAxis], []); return (
{paddingTop > 0 && (
)} {virtualItems.map((virtualRow) => { const row = rows[virtualRow.index]; if (!row) return null; return ( ); })} {paddingBottom > 0 && (
)} {baseId && }
); }