diff --git a/src/components/InfiniteTable/HeaderCheckbox.tsx b/src/components/InfiniteTable/HeaderCheckbox.tsx index 18b6569..7db841c 100644 --- a/src/components/InfiniteTable/HeaderCheckbox.tsx +++ b/src/components/InfiniteTable/HeaderCheckbox.tsx @@ -1,29 +1,36 @@ -import React, { memo, useEffect } from "react"; +import React, { memo, useEffect, useCallback } from "react"; + +interface HeaderCheckboxCompProps { + value: boolean | null; + onChange: (event: React.ChangeEvent) => void; +} const HeaderCheckboxComp = memo( - ({ - value, - onChange, - }: { - value: boolean | null; - onChange: (value: boolean | null) => void; - }) => { - const checkboxRef = React.useRef(); + ({ value, onChange }: HeaderCheckboxCompProps) => { + const checkboxRef = React.useRef(null); useEffect(() => { - const cbRef = checkboxRef.current as any; - - if (value === true) { - cbRef.checked = true; - cbRef.indeterminate = false; - } else if (value === false) { - cbRef.checked = false; - cbRef.indeterminate = false; - } else if (value === null) { - cbRef.checked = false; - cbRef.indeterminate = true; + const cbRef = checkboxRef.current; + if (cbRef) { + if (value === true) { + cbRef.checked = true; + cbRef.indeterminate = false; + } else if (value === false) { + cbRef.checked = false; + cbRef.indeterminate = false; + } else if (value === null) { + cbRef.checked = false; + cbRef.indeterminate = true; + } } - }); + }, [value]); + + const handleChange = useCallback( + (event: React.ChangeEvent) => { + onChange(event); + }, + [onChange], + ); return ( { e.stopPropagation(); }} - ref={checkboxRef as any} + ref={checkboxRef} type="checkbox" - onChange={onChange as any} + onChange={handleChange} + checked={value === true} tabIndex={-1} /> ); @@ -48,40 +56,31 @@ const HeaderCheckboxComp = memo( HeaderCheckboxComp.displayName = "HeaderCheckboxComp"; +interface HeaderCheckboxProps { + selectedRowKeysLength: number; + totalRows: number; + onSelectionCheckboxClicked?: ( + event: React.ChangeEvent, + ) => void; +} + export const HeaderCheckbox = memo( ({ selectedRowKeysLength, totalRows, - allRowSelected, - onHeaderCheckboxChange, - allRowSelectedMode, - }: { - selectedRowKeysLength: number; - totalRows: number; - allRowSelected: boolean; - onHeaderCheckboxChange: (value: boolean | null) => void; - allRowSelectedMode: boolean; - }) => { - const noRowsSelected = selectedRowKeysLength === 0; - const someRowsSelected = - selectedRowKeysLength > 0 && totalRows !== selectedRowKeysLength; - - let value: boolean | null = false; - - if (allRowSelectedMode) { - value = true; - } else if (totalRows === selectedRowKeysLength && totalRows > 0) { - value = true; - } else if (allRowSelected) { - value = true; - } else if (noRowsSelected) { - value = false; - } else if (someRowsSelected) { - value = null; - } + onSelectionCheckboxClicked, + }: HeaderCheckboxProps) => { + const value = React.useMemo(() => { + if (selectedRowKeysLength === 0) return false; + if (selectedRowKeysLength === totalRows && totalRows > 0) return true; + return null; + }, [selectedRowKeysLength, totalRows]); return ( - + ); }, ); diff --git a/src/components/InfiniteTable/InfiniteTable.tsx b/src/components/InfiniteTable/InfiniteTable.tsx index e4d6250..94f2fea 100644 --- a/src/components/InfiniteTable/InfiniteTable.tsx +++ b/src/components/InfiniteTable/InfiniteTable.tsx @@ -1,6 +1,7 @@ import { forwardRef, memo, + ReactNode, useCallback, useEffect, useImperativeHandle, @@ -15,7 +16,6 @@ import { ColDef, ColumnResizedEvent, ColumnState, - FirstDataRenderedEvent, GridReadyEvent, IGetRowsParams, RowDoubleClickedEvent, @@ -24,11 +24,12 @@ import { import { TableProps } from "@/types"; import { useDeepArrayMemo } from "@/hooks/useDeepArrayMemo"; import { HeaderCheckbox } from "./HeaderCheckbox"; -import { useRowSelection } from "./useRowSelection"; import { areStatesEqual, useColumnState } from "./useColumnState"; import { CHECKBOX_COLUMN, STATUS_COLUMN } from "./columnStateHelper"; +import debounce from "lodash/debounce"; +import { useDeepCompareEffect } from "use-deep-compare"; -const DEBOUNCE_TIME = 50; +const DEBOUNCE_TIME = 100; const DEFAULT_TOTAL_ROWS_VALUE = Number.MAX_SAFE_INTEGER; export type InfiniteTableProps = Omit< @@ -49,18 +50,19 @@ export type InfiniteTableProps = Omit< onGetColumnsState?: () => ColumnState[] | undefined; onGetFirstVisibleRowIndex?: () => number | undefined; onChangeFirstVisibleRowIndex?: (index: number) => void; - onGetSelectedRowKeys?: () => any[] | undefined; + selectedRowKeys?: number[]; totalRows?: number; allRowSelectedMode?: boolean; - onAllRowSelectedModeChange?: (allRowSelectedMode: boolean) => void; - footer?: React.ReactNode; + onSelectionCheckboxClicked?: () => void; + footer?: ReactNode; footerHeight?: number; hasStatusColumn?: boolean; onRowStatus?: (item: any) => any; - statusComponent?: (status: any) => React.ReactNode; + statusComponent?: (status: any) => ReactNode; }; export type InfiniteTableRef = { + setSelectedRows: (keys: number[]) => void; unselectAll: () => void; refresh: () => void; }; @@ -78,27 +80,48 @@ const InfiniteTableComp = forwardRef( onGetColumnsState, onChangeFirstVisibleRowIndex, onGetFirstVisibleRowIndex, - onGetSelectedRowKeys, + selectedRowKeys = [], totalRows = DEFAULT_TOTAL_ROWS_VALUE, - onAllRowSelectedModeChange, - allRowSelectedMode: allRowSelectedModeProps, + onSelectionCheckboxClicked, footer, - footerHeight = 50, + footerHeight = 30, onRowStatus, statusComponent, hasStatusColumn = false, } = props; const gridRef = useRef(null); + const firstTimeDataLoaded = useRef(true); const firstTimeOnBodyScroll = useRef(true); - const allRowSelectedModeRef = useRef(false); + const dataIsLoading = useRef(false); const containerRef = useRef(null); const totalHeight = footer ? heightProps + footerHeight : heightProps; const tableHeight = footer ? heightProps - footerHeight : heightProps; + const datasourceRef = useRef<{ + getRows: (params: IGetRowsParams) => void; + }>(); + + useDeepCompareEffect(() => { + gridRef.current?.api?.forEachNode((node) => { + if (node?.data?.id && selectedRowKeys.includes(node.data.id)) { + node.setSelected(true); + } else { + node.setSelected(false); + } + }); + }, [selectedRowKeys]); useImperativeHandle(ref, () => ({ + setSelectedRows: (keys: number[]) => { + gridRef.current?.api?.forEachNode((node) => { + if (node?.data?.id && keys.includes(node.data.id)) { + node.setSelected(true); + } else { + node.setSelected(false); + } + }); + }, unselectAll: () => { - setSelectedRowKeysPendingToRender([]); gridRef.current?.api?.deselectAll(); }, refresh: () => { @@ -106,25 +129,6 @@ const InfiniteTableComp = forwardRef( }, })); - const { - onHeaderCheckboxChange, - onSelectionChangedDebounced, - selectedRowKeysPendingToRender, - allRowSelectedMode, - internalSelectedRowKeys, - setSelectedRowKeysPendingToRender, - } = useRowSelection({ - gridRef, - onRowSelectionChange, - onAllRowSelectedModeChange, - totalRows, - allRowSelectedModeProps, - }); - - useEffect(() => { - allRowSelectedModeRef.current = allRowSelectedMode; - }, [allRowSelectedMode]); - const columns = useDeepArrayMemo(columnsProps, "key"); const { @@ -189,6 +193,12 @@ const InfiniteTableComp = forwardRef( return sortFields; }, []); + const MemoizedStatusComponent = useMemo(() => { + if (!statusComponent) return undefined; + // eslint-disable-next-line react/display-name + return memo((props: { status: any }) => statusComponent(props.status)); + }, [statusComponent]); + const colDefs = useMemo((): ColDef[] => { const checkboxColumn = { checkboxSelection: true, @@ -203,12 +213,8 @@ const InfiniteTableComp = forwardRef( headerComponent: () => ( 0 - } - allRowSelectedMode={allRowSelectedMode} - onHeaderCheckboxChange={onHeaderCheckboxChange} + selectedRowKeysLength={selectedRowKeys?.length || 0} + onSelectionCheckboxClicked={onSelectionCheckboxClicked} /> ), } as ColDef; @@ -244,7 +250,9 @@ const InfiniteTableComp = forwardRef( pinned: "left", resizable: false, headerComponent: () => null, - cellRenderer: (cell: any) => statusComponent?.(cell.value), + cellRenderer: MemoizedStatusComponent + ? (cell: any) => + : undefined, } as ColDef; const finalColumns = [ @@ -255,123 +263,181 @@ const InfiniteTableComp = forwardRef( return finalColumns; }, [ - allRowSelectedMode, - columns, columnsPersistedStateRef, + columns, + MemoizedStatusComponent, hasStatusColumn, - internalSelectedRowKeys.length, - onHeaderCheckboxChange, - statusComponent, totalRows, + selectedRowKeys?.length, + onSelectionCheckboxClicked, ]); - const getRows = useCallback( - async (params: IGetRowsParams) => { - gridRef.current?.api.showLoadingOverlay(); - const { startRow, endRow } = params; - 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; + const scrollToSavedPosition = useCallback(() => { + const firstVisibleRowIndex = onGetFirstVisibleRowIndex?.(); + if (firstVisibleRowIndex && gridRef.current?.api) { + gridRef.current.api.ensureIndexVisible(firstVisibleRowIndex, "top"); + } + }, [onGetFirstVisibleRowIndex]); + + const memoizedOnRowStatus = useCallback( + (item: any) => { + if (onRowStatus) { + return onRowStatus(item); } + return undefined; + }, + [onRowStatus], + ); - // We must call onRowStatus for each item of the data array and merge the result - // with the data array - const finalData = hasStatusColumn - ? await Promise.all( - data.map(async (item) => { - const status = await onRowStatus?.(item); - return { - ...item, - $status: status, - }; - }), - ) - : data; - - params.successCallback(finalData, lastRow); - if (allRowSelectedModeRef.current) { - gridRef?.current?.api.forEachNode((node) => { - node.setSelected(true); + const getRows = useCallback( + async (params: IGetRowsParams) => { + try { + if (dataIsLoading.current) { + return; + } + dataIsLoading.current = true; + const { startRow, endRow } = params; + if (startRow === 0) { + gridRef.current?.api.showLoadingOverlay(); + } + const data = await onRequestData({ + startRow, + endRow, + sortFields: getSortedFields(), }); - } else { - const selectedRowKeys = onGetSelectedRowKeys?.(); - setSelectedRowKeysPendingToRender(selectedRowKeys || []); + + if (!data) { + throw new Error("Data is undefined"); + } + + let lastRow = -1; + if (data.length < endRow - startRow) { + lastRow = startRow + data.length; + } + + // We must call onRowStatus for each item of the data array and merge the result + // with the data array + const finalData = hasStatusColumn + ? await Promise.all( + data.map(async (item) => { + const status = memoizedOnRowStatus + ? await memoizedOnRowStatus(item) + : undefined; + return { + ...item, + $status: status, + }; + }), + ) + : data; + + params.successCallback(finalData, lastRow); if (selectedRowKeys && selectedRowKeys.length > 0) { gridRef?.current?.api.forEachNode((node) => { if (node?.data?.id && selectedRowKeys.includes(node.data.id)) { - // remove from selectedRowKeysPendingToRender node.setSelected(true); - setSelectedRowKeysPendingToRender( - selectedRowKeysPendingToRender.filter( - (key) => node.data.id && key !== node.data.id, - ), - ); } }); } + + dataIsLoading.current = false; + gridRef.current?.api.hideOverlay(); + if (firstTimeDataLoaded.current) { + firstTimeDataLoaded.current = false; + scrollToSavedPosition(); + } + } catch (error) { + dataIsLoading.current = false; + params.failCallback(); + gridRef.current?.api.hideOverlay(); } - gridRef.current?.api.hideOverlay(); }, [ + onRequestData, getSortedFields, hasStatusColumn, - onGetSelectedRowKeys, - onRequestData, - onRowStatus, - selectedRowKeysPendingToRender, - setSelectedRowKeysPendingToRender, + memoizedOnRowStatus, + selectedRowKeys, + scrollToSavedPosition, ], ); + useEffect(() => { + datasourceRef.current = { getRows }; + }, [getRows]); + const onGridReady = useCallback( (params: GridReadyEvent) => { loadPersistedColumnState(); params.api.setGridOption("datasource", { - getRows, + getRows: (params: IGetRowsParams) => { + datasourceRef.current?.getRows(params); + }, }); }, - [getRows, loadPersistedColumnState], + [datasourceRef, loadPersistedColumnState], ); - const onRowDoubleClicked = useCallback( + const memoizedOnRowDoubleClick = useCallback( ({ data: item }: RowDoubleClickedEvent) => { onRowDoubleClick?.(item); }, [onRowDoubleClick], ); - const onFirstDataRendered = useCallback( - (params: FirstDataRenderedEvent) => { - const firstVisibleRowIndex = onGetFirstVisibleRowIndex?.(); - if (firstVisibleRowIndex) { - params.api.ensureIndexVisible(firstVisibleRowIndex, "top"); - } - }, - [onGetFirstVisibleRowIndex], - ); - - const onBodyScroll = useCallback( - (params: BodyScrollEvent) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedOnBodyScroll = useCallback( + debounce((params: BodyScrollEvent) => { if (!firstTimeOnBodyScroll.current) { onChangeFirstVisibleRowIndex?.( params.api.getFirstDisplayedRowIndex(), ); } firstTimeOnBodyScroll.current = false; - }, + }, DEBOUNCE_TIME), [onChangeFirstVisibleRowIndex], ); + // useWhyDidYouRender("InfiniteTable", props); + + const getAllNodeKeys = useCallback(() => { + const allNodes: number[] = []; + gridRef.current?.api?.forEachNode((node) => { + if (node?.data?.id) { + allNodes.push(node.data.id); + } + }); + return allNodes; + }, []); + + const onSelectionChanged = useCallback( + (event: { api: { getSelectedNodes: () => any } }) => { + const allNodesInTable = getAllNodeKeys(); + const allSelectedNodes = event.api.getSelectedNodes() || []; + + // get the records that are not in allNodesInTable but they exist in selectedRowKeys + const rowKeysInSelectedRowKeysButNotInAllNodes = selectedRowKeys.filter( + (key) => !allNodesInTable.includes(key), + ); + + const selectedKeys = allSelectedNodes.map( + (node: { data: any }) => node.data.id, + ); + onRowSelectionChange?.([ + ...selectedKeys, + ...rowKeysInSelectedRowKeysButNotInAllNodes, + ]); + }, + [getAllNodeKeys, onRowSelectionChange, selectedRowKeys], + ); + + const rowStyle = useMemo(() => { + return { + cursor: "pointer", + }; + }, []); + return (
(
- {footer &&
{footer}
} + {footer && ( +
+ {footer} +
+ )} ); }, diff --git a/src/components/InfiniteTable/useRowSelection.ts b/src/components/InfiniteTable/useRowSelection.ts deleted file mode 100644 index 0f95e9b..0000000 --- a/src/components/InfiniteTable/useRowSelection.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { AgGridReact } from "ag-grid-react"; -import { RefObject, useCallback, useEffect, useRef, useState } from "react"; -import debounce from "lodash/debounce"; - -const DEBOUNCE_TIME = 50; - -export const useRowSelection = ({ - gridRef, - onRowSelectionChange, - onAllRowSelectedModeChange, - totalRows, - allRowSelectedModeProps = false, -}: { - gridRef: RefObject; - onRowSelectionChange?: (selectedKeys: any[]) => void; - onAllRowSelectedModeChange?: (allRowSelectedMode: boolean) => void; - totalRows: number; - allRowSelectedModeProps?: boolean; -}) => { - const [internalSelectedRowKeys, setInternalSelectedRowKeys] = useState( - [], - ); - const [allRowSelectedMode, setAllRowSelectedMode] = useState( - allRowSelectedModeProps, - ); - const prevAllRowSelectedMode = useRef(false); - - const selectedRowKeysPendingToRender = useRef([]); - const allRowSelectedModeLock = useRef(false); - const timeoutRef = useRef(null); - - useEffect(() => { - prevAllRowSelectedMode.current = allRowSelectedMode; - }, [allRowSelectedMode]); - - useEffect(() => { - setAllRowSelectedMode(allRowSelectedModeProps); - if (allRowSelectedModeProps) { - setInternalSelectedRowKeys([]); - allRowSelectedModeLock.current = true; - } - }, [allRowSelectedModeProps]); - - useEffect(() => { - onAllRowSelectedModeChange?.(allRowSelectedMode); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allRowSelectedMode]); - - const onHeaderCheckboxChange = useCallback(() => { - // Determine the new selection state based on current conditions - let newAllSelectedState = false; - - if (allRowSelectedMode) { - newAllSelectedState = false; - } else if (!allRowSelectedMode && internalSelectedRowKeys.length === 0) { - newAllSelectedState = true; // No rows are selected and selection should be toggled to all - } else if ( - !allRowSelectedMode && - internalSelectedRowKeys.length === totalRows - ) { - newAllSelectedState = false; // All rows are selected and selection should be toggled to none - } else if (allRowSelectedMode || internalSelectedRowKeys.length > 0) { - newAllSelectedState = true; - } - - allRowSelectedModeLock.current = true; - // Apply the determined state to all nodes - gridRef?.current?.api.forEachNode((node) => { - node.setSelected(newAllSelectedState); - }); - - setAllRowSelectedMode(newAllSelectedState); - - timeoutRef.current && clearTimeout(timeoutRef.current); - // this is a hacky tweak in order to make this work with the new selection mechanism - timeoutRef.current = setTimeout(() => { - allRowSelectedModeLock.current = false; - }, 1000); - }, [allRowSelectedMode, internalSelectedRowKeys.length, totalRows, gridRef]); - - const onSelectionChanged = useCallback( - (event: { api: { getSelectedNodes: () => any } }) => { - if (allRowSelectedModeLock.current) { - onRowSelectionChange?.([]); - setInternalSelectedRowKeys([]); - return; - } - setAllRowSelectedMode(false); - - const allSelectedNodes = event.api.getSelectedNodes() || []; - let selectedKeys = allSelectedNodes.map( - (node: { data: any }) => node.data.id, - ); - // merge the pending selected rows - selectedKeys = selectedKeys.concat( - selectedRowKeysPendingToRender.current, - ); - onRowSelectionChange?.(selectedKeys); - setInternalSelectedRowKeys(selectedKeys); - }, - [onRowSelectionChange], - ); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const onSelectionChangedDebounced = useCallback( - debounce((event: { api: { getSelectedNodes: () => any } }) => { - onSelectionChanged?.(event); - }, DEBOUNCE_TIME), - [onSelectionChanged], - ); - - const setSelectedRowKeysPendingToRender = useCallback((value: any[]) => { - selectedRowKeysPendingToRender.current = value; - }, []); - - return { - internalSelectedRowKeys, - onHeaderCheckboxChange, - allRowSelectedMode, - onSelectionChangedDebounced, - selectedRowKeysPendingToRender: selectedRowKeysPendingToRender.current, - setSelectedRowKeysPendingToRender, - }; -}; diff --git a/src/hooks/useWhyDidYouRender.ts b/src/hooks/useWhyDidYouRender.ts new file mode 100644 index 0000000..997f4c1 --- /dev/null +++ b/src/hooks/useWhyDidYouRender.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from "react"; + +export function useWhyDidYouRender(componentName: string, props: any) { + const previousProps = useRef(props); + + useEffect(() => { + if (previousProps.current) { + const allKeys = Object.keys({ ...previousProps.current, ...props }); + const changesObj: Record = {}; + allKeys.forEach((key) => { + if (previousProps.current[key] !== props[key]) { + changesObj[key] = { + old: previousProps.current[key], + new: props[key], + }; + } + }); + + if (Object.keys(changesObj).length) { + console.log("[why-did-you-render]", componentName, changesObj); + } + } + + previousProps.current = props; + }); +}