From ba9aec02d0dd56491b6292ce56f4741b943fa10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20G=C3=BCell=20Segarra?= Date: Tue, 17 Sep 2024 09:55:03 +0200 Subject: [PATCH] feat: allow sorting and persist column state in infinite table (#22) * feat: adjustments for restoring column state * chore: remove comments * fix: undefined array * fix: weird bug * fix: try to debug weird bug * fix: weird bug debugging * fix: debug weird ag grid issue * fix: debug ag grid bug * fix: debug weird bug * fix: try to debug weird production only glitch * fix: suppressDragLeaveHidesColumns adjustment * fix: try to fix weird order bug * fix: more adjustments * feat: add sort feature to columns * fix: improvements in sort columns feature * fix: do not notify when sort has changed in order to persist it --- package-lock.json | 1 + package.json | 1 + .../InfiniteTable/InfiniteTable.tsx | 179 ++++++++++++------ .../InfiniteTable/columnStateHelper.ts | 12 +- .../InfiniteTable/useAutoFitColumns.ts | 65 ------- .../InfiniteTable/useColumnState.ts | 142 ++++++++++++++ .../InfiniteTable/useRowSelection.ts | 2 +- src/types/index.ts | 3 + 8 files changed, 275 insertions(+), 130 deletions(-) delete mode 100644 src/components/InfiniteTable/useAutoFitColumns.ts create mode 100644 src/components/InfiniteTable/useColumnState.ts diff --git a/package-lock.json b/package-lock.json index 79551d4..4f7efc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "ag-grid-community": "^31.2.1", "ag-grid-react": "^31.2.1", + "dequal": "^2.0.3", "lodash.debounce": "^4.0.8", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/package.json b/package.json index 8026807..3ef7f1a 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "dependencies": { "ag-grid-community": "^31.2.1", "ag-grid-react": "^31.2.1", + "dequal": "^2.0.3", "lodash.debounce": "^4.0.8", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/src/components/InfiniteTable/InfiniteTable.tsx b/src/components/InfiniteTable/InfiniteTable.tsx index b6aab60..e4d6250 100644 --- a/src/components/InfiniteTable/InfiniteTable.tsx +++ b/src/components/InfiniteTable/InfiniteTable.tsx @@ -13,36 +13,44 @@ import "@/styles/ag-theme-quartz.css"; import { BodyScrollEvent, ColDef, - ColumnMovedEvent, ColumnResizedEvent, ColumnState, FirstDataRenderedEvent, GridReadyEvent, IGetRowsParams, RowDoubleClickedEvent, + SortDirection, } from "ag-grid-community"; import { TableProps } from "@/types"; import { useDeepArrayMemo } from "@/hooks/useDeepArrayMemo"; -import debounce from "lodash/debounce"; import { HeaderCheckbox } from "./HeaderCheckbox"; import { useRowSelection } from "./useRowSelection"; -import { useAutoFitColumns } from "./useAutoFitColumns"; -import { getPersistedColumnState } from "./columnStateHelper"; +import { areStatesEqual, useColumnState } from "./useColumnState"; +import { CHECKBOX_COLUMN, STATUS_COLUMN } from "./columnStateHelper"; const DEBOUNCE_TIME = 50; +const DEFAULT_TOTAL_ROWS_VALUE = Number.MAX_SAFE_INTEGER; export type InfiniteTableProps = Omit< TableProps, "dataSource" & "loading" & "loadingComponent" & "height" > & { - onRequestData: (startRow: number, endRow: number) => Promise; + onRequestData: ({ + startRow, + endRow, + sortFields, + }: { + startRow: number; + endRow: number; + sortFields?: Record; + }) => Promise; height?: number; onColumnChanged?: (columnsState: ColumnState[]) => void; onGetColumnsState?: () => ColumnState[] | undefined; onGetFirstVisibleRowIndex?: () => number | undefined; onChangeFirstVisibleRowIndex?: (index: number) => void; onGetSelectedRowKeys?: () => any[] | undefined; - totalRows: number; + totalRows?: number; allRowSelectedMode?: boolean; onAllRowSelectedModeChange?: (allRowSelectedMode: boolean) => void; footer?: React.ReactNode; @@ -71,7 +79,7 @@ const InfiniteTableComp = forwardRef( onChangeFirstVisibleRowIndex, onGetFirstVisibleRowIndex, onGetSelectedRowKeys, - totalRows, + totalRows = DEFAULT_TOTAL_ROWS_VALUE, onAllRowSelectedModeChange, allRowSelectedMode: allRowSelectedModeProps, footer, @@ -84,39 +92,10 @@ const InfiniteTableComp = forwardRef( const gridRef = useRef(null); const firstTimeOnBodyScroll = useRef(true); const allRowSelectedModeRef = useRef(false); - const columnsPersistedStateRef = useRef(); const containerRef = useRef(null); - const columnChangeListenerReady = useRef(false); const totalHeight = footer ? heightProps + footerHeight : heightProps; const tableHeight = footer ? heightProps - footerHeight : heightProps; - const { autoSizeColumnsIfNecessary } = useAutoFitColumns({ - gridRef, - containerRef, - columnsPersistedStateRef, - hasStatusColumn, - }); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedOnColumnChanged = useCallback( - debounce((state) => { - if (!columnChangeListenerReady.current) { - columnChangeListenerReady.current = true; - return; - } - onColumnsChangedProps?.(state); - }, DEBOUNCE_TIME), - [onColumnsChangedProps], - ); - - const onColumnChanged = useCallback( - (event: ColumnResizedEvent | ColumnMovedEvent) => { - const state = event.api.getColumnState(); - debouncedOnColumnChanged(state); - }, - [debouncedOnColumnChanged], - ); - useImperativeHandle(ref, () => ({ unselectAll: () => { setSelectedRowKeysPendingToRender([]); @@ -148,17 +127,79 @@ const InfiniteTableComp = forwardRef( const columns = useDeepArrayMemo(columnsProps, "key"); - const defaultColDef = useMemo(() => ({}), []); + const { + loadPersistedColumnState, + columnsPersistedStateRef, + applyAndUpdateNewState, + } = useColumnState({ + gridRef, + containerRef, + hasStatusColumn, + columns, + onGetColumnsState, + }); + + const onColumnChanged = useCallback(() => { + const state = gridRef?.current?.api.getColumnState(); + if (!state) { + return; + } + if (areStatesEqual(state, columnsPersistedStateRef.current)) { + return; + } + applyAndUpdateNewState(state); + onColumnsChangedProps?.(state); + }, [ + applyAndUpdateNewState, + columnsPersistedStateRef, + onColumnsChangedProps, + ]); + + const onColumnMoved = useCallback(() => { + onColumnChanged(); + }, [onColumnChanged]); + + const onColumnResized = useCallback( + (event: ColumnResizedEvent) => { + if (!event.finished) { + return; + } + onColumnChanged(); + }, + [onColumnChanged], + ); + + const getSortedFields = useCallback((): + | Record + | undefined => { + const state = gridRef?.current?.api.getColumnState()!; + + const columnsWithSort = state.filter((col) => col.sort); + if (columnsWithSort.length === 0) { + return undefined; + } + const sortFields = columnsWithSort.reduce( + (acc, col) => ({ + ...acc, + [col.colId]: col.sort, + }), + {}, + ); + + return sortFields; + }, []); const colDefs = useMemo((): ColDef[] => { const checkboxColumn = { checkboxSelection: true, suppressMovable: true, sortable: false, - lockPosition: true, pinned: "left", + lockPosition: "left", + lockPinned: true, maxWidth: 50, resizable: false, + field: CHECKBOX_COLUMN, headerComponent: () => ( ( ), } as ColDef; - const restOfColumns = columns.map((column) => ({ + const storedState = columnsPersistedStateRef.current; + const storedStateKeys = storedState?.map((col: any) => col.colId); + + const restOfColumns: ColDef[] = columns.map((column) => ({ field: column.key, - sortable: false, + sortable: column.isSortable, headerName: column.title, cellRenderer: column.render ? (cell: any) => column.render(cell.value) : undefined, })); + // restOfColumns should be sorted by the order of the storedState + storedState && + storedStateKeys && + restOfColumns.sort((a, b) => { + const aIndex = storedStateKeys.indexOf(a.field); + const bIndex = storedStateKeys.indexOf(b.field); + return aIndex - bIndex; + }); + const statusColumn = { - field: "$status", + field: STATUS_COLUMN, suppressMovable: true, sortable: false, - lockPosition: true, + lockPosition: "left", + lockPinned: true, maxWidth: 30, - type: "leftAligned", pinned: "left", resizable: false, headerComponent: () => null, cellRenderer: (cell: any) => statusComponent?.(cell.value), } as ColDef; - return [ + const finalColumns = [ checkboxColumn, ...(hasStatusColumn ? [statusColumn] : []), ...restOfColumns, ]; + + return finalColumns; }, [ allRowSelectedMode, columns, + columnsPersistedStateRef, hasStatusColumn, internalSelectedRowKeys.length, onHeaderCheckboxChange, @@ -213,7 +269,15 @@ const InfiniteTableComp = forwardRef( async (params: IGetRowsParams) => { gridRef.current?.api.showLoadingOverlay(); const { startRow, endRow } = params; - const data = await onRequestData(startRow, endRow); + const data = await onRequestData({ + startRow, + endRow, + sortFields: getSortedFields(), + }); + if (!data) { + params.failCallback(); + return; + } let lastRow = -1; if (data.length < endRow - startRow) { lastRow = startRow + data.length; @@ -257,10 +321,9 @@ const InfiniteTableComp = forwardRef( } } gridRef.current?.api.hideOverlay(); - autoSizeColumnsIfNecessary(); }, [ - autoSizeColumnsIfNecessary, + getSortedFields, hasStatusColumn, onGetSelectedRowKeys, onRequestData, @@ -272,22 +335,12 @@ const InfiniteTableComp = forwardRef( const onGridReady = useCallback( (params: GridReadyEvent) => { - columnsPersistedStateRef.current = getPersistedColumnState({ - actualColumnKeys: columns.map((column) => column.key), - persistedColumnState: onGetColumnsState?.(), - }); - if (columnsPersistedStateRef.current) { - params.api.applyColumnState({ - state: columnsPersistedStateRef.current, - applyOrder: true, - }); - } - + loadPersistedColumnState(); params.api.setGridOption("datasource", { getRows, }); }, - [columns, getRows, onGetColumnsState], + [getRows, loadPersistedColumnState], ); const onRowDoubleClicked = useCallback( @@ -335,7 +388,6 @@ const InfiniteTableComp = forwardRef( ( suppressRowClickSelection={true} rowBuffer={0} rowSelection={"multiple"} - onColumnMoved={onColumnChanged} - onColumnResized={onColumnChanged} + onDragStopped={onColumnMoved} + onColumnResized={onColumnResized} rowModelType={"infinite"} - cacheBlockSize={20} + cacheBlockSize={200} onSelectionChanged={onSelectionChangedDebounced} cacheOverflowSize={2} maxConcurrentDatasourceRequests={1} @@ -358,6 +410,7 @@ const InfiniteTableComp = forwardRef( onFirstDataRendered={onFirstDataRendered} onBodyScroll={onBodyScroll} blockLoadDebounceMillis={DEBOUNCE_TIME} + suppressDragLeaveHidesColumns={true} /> {footer &&
{footer}
} diff --git a/src/components/InfiniteTable/columnStateHelper.ts b/src/components/InfiniteTable/columnStateHelper.ts index 2fa96eb..8145d21 100644 --- a/src/components/InfiniteTable/columnStateHelper.ts +++ b/src/components/InfiniteTable/columnStateHelper.ts @@ -1,3 +1,11 @@ +export const STATUS_COLUMN = "$status"; +export const CHECKBOX_COLUMN = "$checkbox"; +export const FIXED_COLUMNS_TO_IGNORE = [CHECKBOX_COLUMN]; +export const ALL_COLUMNS_TO_IGNORE = [ + ...FIXED_COLUMNS_TO_IGNORE, + STATUS_COLUMN, +]; + export const getPersistedColumnState = ({ actualColumnKeys, persistedColumnState, @@ -15,7 +23,9 @@ export const getPersistedColumnState = ({ // we have to remove the "0" (checkbox column) from the persistedColumnKeys const sortedActualColumnKeys = [...actualColumnKeys].sort(); const sortedPersistedColumnKeys = [ - ...persistedColumnKeys.filter((key) => key !== "0"), + ...persistedColumnKeys.filter( + (key) => !ALL_COLUMNS_TO_IGNORE.includes(key), + ), ].sort(); const areColumnKeysEqual = JSON.stringify(sortedActualColumnKeys) === diff --git a/src/components/InfiniteTable/useAutoFitColumns.ts b/src/components/InfiniteTable/useAutoFitColumns.ts deleted file mode 100644 index 40ea020..0000000 --- a/src/components/InfiniteTable/useAutoFitColumns.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Column } from "ag-grid-community"; -import { AgGridReact } from "ag-grid-react"; -import { RefObject, useCallback, useRef } from "react"; - -export const useAutoFitColumns = ({ - gridRef, - containerRef, - columnsPersistedStateRef, - hasStatusColumn, -}: { - gridRef: RefObject; - containerRef: RefObject; - columnsPersistedStateRef: RefObject; - hasStatusColumn: boolean; -}) => { - const firstTimeResized = useRef(false); - - const columnsToIgnore = ["0"]; // 0 is for header checkbox column - if (hasStatusColumn) { - columnsToIgnore.push("$status"); - } - - const remainingBlankSpace = useCallback( - (allColumns: Array>) => { - const totalColumnWidth = allColumns?.reduce( - (acc, column) => acc + column.getActualWidth(), - 0, - ); - const gridRefWidth = containerRef?.current?.clientWidth; - if (!gridRefWidth || !totalColumnWidth) return 0; - return gridRefWidth - totalColumnWidth; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); - - const autoSizeColumnsIfNecessary = useCallback(() => { - if (!columnsPersistedStateRef.current && !firstTimeResized.current) { - firstTimeResized.current = true; - setTimeout(() => { - gridRef?.current?.api.autoSizeAllColumns(); - const allColumns = gridRef?.current?.api.getAllGridColumns(); - if (!allColumns) return; - const blankSpace = remainingBlankSpace(allColumns); - if (blankSpace > 0) { - const spacePerColumn = - blankSpace / (allColumns.length - columnsToIgnore.length); - const state = gridRef?.current?.api.getColumnState()!; - const newState = state.map((col: any) => ({ - ...col, - width: columnsToIgnore.includes(col.colId) - ? col.width - : col.width + spacePerColumn, - })); - gridRef?.current?.api.applyColumnState({ state: newState }); - } - }, 50); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [remainingBlankSpace]); - - return { - autoSizeColumnsIfNecessary, - }; -}; diff --git a/src/components/InfiniteTable/useColumnState.ts b/src/components/InfiniteTable/useColumnState.ts new file mode 100644 index 0000000..107324e --- /dev/null +++ b/src/components/InfiniteTable/useColumnState.ts @@ -0,0 +1,142 @@ +import { Column, ColumnState } from "ag-grid-community"; +import { AgGridReact } from "ag-grid-react"; +import { RefObject, useCallback, useRef } from "react"; +import { + FIXED_COLUMNS_TO_IGNORE, + getPersistedColumnState, + STATUS_COLUMN, +} from "./columnStateHelper"; +import { TableColumn } from "@/types"; +import { useDeepCompareCallback } from "use-deep-compare"; +import { dequal } from "dequal"; + +const DEBOUNCE_DELAY = 50; + +export const useColumnState = ({ + gridRef, + containerRef, + hasStatusColumn, + columns, + onGetColumnsState, +}: { + gridRef: RefObject; + containerRef: RefObject; + hasStatusColumn: boolean; + columns: TableColumn[]; + onGetColumnsState?: () => ColumnState[] | undefined; +}) => { + const firstTimeResized = useRef(false); + const columnsPersistedStateRef = useRef(); + + const columnsToIgnore = FIXED_COLUMNS_TO_IGNORE; + if (hasStatusColumn) { + columnsToIgnore.push(STATUS_COLUMN); + } + + const remainingBlankSpace = useCallback( + (allColumns: Array>) => { + const totalColumnWidth = allColumns?.reduce( + (acc, column) => acc + column.getActualWidth(), + 0, + ); + const gridRefWidth = containerRef?.current?.clientWidth; + if (!gridRefWidth || !totalColumnWidth) return 0; + return gridRefWidth - totalColumnWidth; + }, + [containerRef], + ); + + const runDeferredCallback = useCallback((callback: Function) => { + setTimeout(() => { + callback(); + }, DEBOUNCE_DELAY); + }, []); + + const applyPersistedState = useCallback(() => { + runDeferredCallback(() => { + gridRef?.current?.api.applyColumnState({ + state: columnsPersistedStateRef.current, + applyOrder: true, + }); + }); + }, [gridRef, runDeferredCallback]); + + const applyAndUpdateNewState = useCallback( + (state: ColumnState[]) => { + columnsPersistedStateRef.current = state; + gridRef?.current?.api.applyColumnState({ + state: columnsPersistedStateRef.current, + applyOrder: true, + }); + }, + [gridRef], + ); + + const applyAutoFitState = useDeepCompareCallback(() => { + runDeferredCallback(() => { + gridRef?.current?.api.autoSizeAllColumns(); + const allColumns = gridRef?.current?.api.getAllGridColumns(); + if (!allColumns) return; + const blankSpace = remainingBlankSpace(allColumns); + if (blankSpace > 0) { + const spacePerColumn = + blankSpace / (allColumns.length - columnsToIgnore.length); + const state = gridRef?.current?.api.getColumnState()!; + const newState = state.map((col: any) => ({ + ...col, + width: columnsToIgnore.includes(col.colId) + ? col.width + : col.width + spacePerColumn, + })); + gridRef?.current?.api.applyColumnState({ state: newState }); + } + }); + }, [columnsToIgnore, gridRef, remainingBlankSpace, runDeferredCallback]); + + const loadPersistedColumnState = useDeepCompareCallback(() => { + columnsPersistedStateRef.current = getPersistedColumnState({ + actualColumnKeys: columns.map((column) => column.key), + persistedColumnState: onGetColumnsState?.(), + }); + + if (columnsPersistedStateRef.current) { + applyPersistedState(); + return; + } + + if (!columnsPersistedStateRef.current && !firstTimeResized.current) { + firstTimeResized.current = true; + applyAutoFitState(); + } + }, [applyAutoFitState, applyPersistedState, columns, onGetColumnsState]); + + return { + loadPersistedColumnState, + columnsPersistedStateRef, + applyAndUpdateNewState, + }; +}; + +const removeNullsFromState = (state: ColumnState): Partial => { + return Object.entries(state).reduce>( + (acc, [key, value]) => { + if (value != null) { + acc[key as keyof ColumnState] = value; + } + return acc; + }, + {}, + ); +}; + +export const areStatesEqual = ( + a?: ColumnState[], + b?: ColumnState[], +): boolean => { + if (!a || !b) { + return false; + } + const cleanA = a.map(removeNullsFromState); + const cleanB = b.map(removeNullsFromState); + return dequal(cleanA, cleanB); +}; diff --git a/src/components/InfiniteTable/useRowSelection.ts b/src/components/InfiniteTable/useRowSelection.ts index 6d402b5..0f95e9b 100644 --- a/src/components/InfiniteTable/useRowSelection.ts +++ b/src/components/InfiniteTable/useRowSelection.ts @@ -87,7 +87,7 @@ export const useRowSelection = ({ } setAllRowSelectedMode(false); - const allSelectedNodes = event.api.getSelectedNodes(); + const allSelectedNodes = event.api.getSelectedNodes() || []; let selectedKeys = allSelectedNodes.map( (node: { data: any }) => node.data.id, ); diff --git a/src/types/index.ts b/src/types/index.ts index 5192936..eab55b9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,7 @@ export type TableColumn = { title: string; render?: (item: any) => React.ReactNode; sorter?: (a: any, b: any, column: string, desc: boolean) => number; + isSortable?: boolean; }; export type Sorter = { @@ -64,3 +65,5 @@ export type TableProps = { export interface TableRef { unselectAll: () => void; } + +export type SortDirection = "asc" | "desc" | null | undefined;