From 27b9e2c61a1a961cabc2322f3d608fe75563da65 Mon Sep 17 00:00:00 2001 From: radubrehar Date: Tue, 8 Oct 2024 17:28:53 +0300 Subject: [PATCH] implement repeat group rows in horizontal layout --- ...ll-selection-duplicate-group-rows.page.tsx | 118 ++++++++++++++++++ .../DataSource/state/getInitialState.ts | 3 + .../components/DataSource/state/reducer.ts | 6 + source/src/components/DataSource/types.ts | 5 + .../src/components/HeadlessTable/RawTable.tsx | 12 +- source/src/components/HeadlessTable/index.tsx | 3 + .../hooks/useHorizontalLayout.ts | 51 ++++++++ source/src/components/InfiniteTable/index.tsx | 5 + .../InfiniteTable/state/getInitialState.ts | 3 + .../InfiniteTable/types/InfiniteTableProps.ts | 2 + .../InfiniteTable/types/InfiniteTableState.ts | 4 + .../components/VirtualBrain/MatrixBrain.ts | 24 +++- source/src/utils/groupAndPivot/index.ts | 67 +++++++++- 13 files changed, 291 insertions(+), 12 deletions(-) create mode 100644 examples/src/pages/tests/horizontal-layout/cell-selection-duplicate-group-rows.page.tsx create mode 100644 source/src/components/InfiniteTable/hooks/useHorizontalLayout.ts diff --git a/examples/src/pages/tests/horizontal-layout/cell-selection-duplicate-group-rows.page.tsx b/examples/src/pages/tests/horizontal-layout/cell-selection-duplicate-group-rows.page.tsx new file mode 100644 index 00000000..dfbef2be --- /dev/null +++ b/examples/src/pages/tests/horizontal-layout/cell-selection-duplicate-group-rows.page.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; + +import { + InfiniteTable, + DataSource, + DataSourcePropCellSelection_MultiCell, +} from '@infinite-table/infinite-react'; + +import type { InfiniteTablePropColumns } from '@infinite-table/infinite-react'; + +import { useState } from 'react'; + +type Developer = { + id: number; + + firstName: string; + lastName: string; + country: string; + city: string; + currency: string; + preferredLanguage: string; + stack: string; + canDesign: 'yes' | 'no'; + hobby: string; + salary: number; + age: number; +}; + +const dataSource = () => { + return fetch(process.env.NEXT_PUBLIC_BASE_URL + '/developers100') + .then((r) => r.json()) + .then((data: Developer[]) => data); +}; + +const columns: InfiniteTablePropColumns = { + id: { field: 'id', renderSelectionCheckBox: true }, + + firstName: { + field: 'firstName', + }, + + // preferredLanguage: { field: 'preferredLanguage' }, + // stack: { field: 'stack' }, +}; + +const domProps = { + style: { + height: '80vh', + margin: 10, + }, +}; + +const group_COL = {}; + +export default function GroupByExample() { + const [cellSelection, _setCellSelection] = + useState({ + selectedCells: [ + // [2, 'firstName'], + ['*', 'firstName'], + // [7, 'preferredLanguage'], + // [3, 'id'], + [3, '*'], + [4, '*'], + [5, '*'], + [11, 'preferredLanguage'], + [15, 'stack'], + ], + // deselectedCells: [[3, 'stack']], + defaultSelection: false, + }); + + const [wrapRowsHorizontally, setWrapRowsHorizontally] = useState(true); + const [repeatWrappedGroupRows, setRepeatWrappedGroupRows] = useState(true); + return ( + <> + + + + primaryKey="id" + data={dataSource} + defaultGroupBy={[ + { + field: 'stack', + }, + { + field: 'preferredLanguage', + }, + ]} + selectionMode="multi-cell" + defaultCellSelection={cellSelection} + > + + domProps={domProps} + columns={columns} + groupColumn={group_COL} + repeatWrappedGroupRows={repeatWrappedGroupRows} + keyboardSelection={true} + keyboardNavigation={'cell'} + wrapRowsHorizontally={wrapRowsHorizontally} + columnDefaultWidth={200} + /> + + + ); +} diff --git a/source/src/components/DataSource/state/getInitialState.ts b/source/src/components/DataSource/state/getInitialState.ts index 7bed3612..c4cec907 100644 --- a/source/src/components/DataSource/state/getInitialState.ts +++ b/source/src/components/DataSource/state/getInitialState.ts @@ -68,6 +68,8 @@ export function initSetupState(): DataSourceSetupState { // TODO cleanup indexer on unmount indexer: new Indexer(), + repeatWrappedGroupRows: false, + destroyedRef: { current: false, }, @@ -86,6 +88,7 @@ export function initSetupState(): DataSourceSetupState { timestamp: 0, mutations: undefined, }, + rowsPerPage: null, lazyLoadCacheOfLoadedBatches: new DeepMap(), dataParams: undefined, onCleanup: buildSubscriptionCallback>(), diff --git a/source/src/components/DataSource/state/reducer.ts b/source/src/components/DataSource/state/reducer.ts index 861e6af5..d534f75f 100644 --- a/source/src/components/DataSource/state/reducer.ts +++ b/source/src/components/DataSource/state/reducer.ts @@ -342,6 +342,8 @@ export function concludeReducer(params: { 'pivotTotalColumnPosition', 'pivotGrandTotalColumnPosition', 'showSeparatePivotColumnForSingleAggregation', + 'repeatWrappedGroupRows', + 'rowsPerPage', ]); const rowInfoReducersChanged = haveDepsChanged(previousState, state, [ @@ -529,6 +531,10 @@ export function concludeReducer(params: { withRowInfo, + repeatWrappedGroupRows: + state.repeatWrappedGroupRows && state.rowsPerPage != null, + rowsPerPage: state.rowsPerPage, + groupRowsState: state.groupRowsState, generateGroupRows: state.generateGroupRows, }); diff --git a/source/src/components/DataSource/types.ts b/source/src/components/DataSource/types.ts index ae7214cd..78fe99c5 100644 --- a/source/src/components/DataSource/types.ts +++ b/source/src/components/DataSource/types.ts @@ -257,6 +257,11 @@ export interface DataSourceSetupState { getDataSourceMasterContextRef: React.MutableRefObject< () => DataSourceMasterDetailContextValue | undefined >; + repeatWrappedGroupRows: boolean; + /** + * This is just used for horizontal layout and when repeatWrappedGroupRows is TRUE!!! + */ + rowsPerPage: number | null; destroyedRef: React.MutableRefObject; idToIndexMap: Map; detailDataSourcesStateToRestore: Map< diff --git a/source/src/components/HeadlessTable/RawTable.tsx b/source/src/components/HeadlessTable/RawTable.tsx index f58cb820..d8ef5274 100644 --- a/source/src/components/HeadlessTable/RawTable.tsx +++ b/source/src/components/HeadlessTable/RawTable.tsx @@ -21,10 +21,11 @@ export type RawTableProps = { cellHoverClassNames?: string[]; renderer?: ReactHeadlessTableRenderer; onRenderUpdater?: SubscriptionCallback; + forceRerenderTimestamp?: number; }; export function RawTableFn(props: RawTableProps) { - const { brain, renderCell, renderDetailRow } = props; + const { brain, renderCell, renderDetailRow, forceRerenderTimestamp } = props; const { renderer, onRenderUpdater } = useMemo(() => { return props.onRenderUpdater && props.renderer @@ -53,7 +54,14 @@ export function RawTableFn(props: RawTableProps) { renderCell, renderDetailRow, }); - }, [renderer, brain, renderCell, renderDetailRow, onRenderUpdater]); + }, [ + renderer, + brain, + renderCell, + renderDetailRow, + onRenderUpdater, + forceRerenderTimestamp, + ]); useEffect(() => { const remove = brain.onRenderRangeChange((renderRange) => { diff --git a/source/src/components/HeadlessTable/index.tsx b/source/src/components/HeadlessTable/index.tsx index 0def12f6..82ed735b 100644 --- a/source/src/components/HeadlessTable/index.tsx +++ b/source/src/components/HeadlessTable/index.tsx @@ -36,6 +36,7 @@ export type HeadlessTableProps = { scrollerDOMRef?: MutableRefObject; wrapRowsHorizontally?: boolean; brain: MatrixBrain; + forceRerenderTimestamp?: number; debugId?: string; activeCellRowHeight: number | ((rowIndex: number) => number) | undefined; renderCell: TableRenderCellFn; @@ -164,6 +165,7 @@ export function HeadlessTable( activeCellIndex, onRenderUpdater, wrapRowsHorizontally, + forceRerenderTimestamp, ...domProps } = props; @@ -244,6 +246,7 @@ export function HeadlessTable( data-name="scroll-transform-target" > { + if (!wrapRowsHorizontally) { + return; + } + + const onVerticalRenderRangeChange = () => { + const { + rowsPerPage: currentRowsPerPage, + repeatWrappedGroupRows: currentRepeatWrappedGroupRows, + } = getDataSourceState(); + + const rowsPerPage = (brain as HorizontalLayoutMatrixBrain).rowsPerPage; + const newRowsPerPage = groupBy && groupBy.length > 0 ? rowsPerPage : null; + + if (currentRowsPerPage != newRowsPerPage) { + dataSourceActions.rowsPerPage = newRowsPerPage; + } + + if (currentRepeatWrappedGroupRows != !!repeatWrappedGroupRows) { + dataSourceActions.repeatWrappedGroupRows = !!repeatWrappedGroupRows; + } + }; + + onVerticalRenderRangeChange(); + + let timeoutId: any; + const off = brain.onVerticalRenderRangeChange(() => { + onVerticalRenderRangeChange(); + timeoutId = setTimeout(() => { + actions.forceBodyRerenderTimestamp = Date.now(); + }); + }); + + return () => { + clearTimeout(timeoutId); + off(); + }; + }, [brain, wrapRowsHorizontally, groupBy, repeatWrappedGroupRows]); +} diff --git a/source/src/components/InfiniteTable/index.tsx b/source/src/components/InfiniteTable/index.tsx index eedce66f..ed9f7504 100644 --- a/source/src/components/InfiniteTable/index.tsx +++ b/source/src/components/InfiniteTable/index.tsx @@ -91,6 +91,7 @@ import { useVisibleColumnSizes } from './hooks/useVisibleColumnSizes'; import { DEBUG_NAME } from './InfiniteDebugName'; import { useToggleWrapRowsHorizontally } from './hooks/useToggleWrapRowsHorizontally'; +import { useHorizontalLayout } from './hooks/useHorizontalLayout'; export const InfiniteTableClassName = internalProps.rootClassName; @@ -256,6 +257,7 @@ function InfiniteTableBody() { return ( (); const { menuPortal } = useColumnMenu(); @@ -570,6 +574,7 @@ const InfiniteTable: InfiniteTableComponent = function ( const table = ( //@ts-ignore ({ columnsWhenInlineGroupRenderStrategy: undefined, editingCell: null, + forceBodyRerenderTimestamp: 0, }; } @@ -258,6 +259,8 @@ export const forwardProps = ( rowClassName: 1, cellClassName: 1, + repeatWrappedGroupRows: 1, + pinnedStartMaxWidth: 1, pinnedEndMaxWidth: 1, pivotColumn: 1, diff --git a/source/src/components/InfiniteTable/types/InfiniteTableProps.ts b/source/src/components/InfiniteTable/types/InfiniteTableProps.ts index 0b903a9f..b11b71e2 100644 --- a/source/src/components/InfiniteTable/types/InfiniteTableProps.ts +++ b/source/src/components/InfiniteTable/types/InfiniteTableProps.ts @@ -610,6 +610,8 @@ export interface InfiniteTableProps { keyboardShortcuts?: InfiniteTablePropKeyboardShorcut[]; + repeatWrappedGroupRows?: boolean; + viewportReservedWidth?: number; onViewportReservedWidthChange?: (viewportReservedWidth: number) => void; diff --git a/source/src/components/InfiniteTable/types/InfiniteTableState.ts b/source/src/components/InfiniteTable/types/InfiniteTableState.ts index 10d0df0a..607c6deb 100644 --- a/source/src/components/InfiniteTable/types/InfiniteTableState.ts +++ b/source/src/components/InfiniteTable/types/InfiniteTableState.ts @@ -63,6 +63,8 @@ export interface InfiniteTableSetupState { renderer: ReactHeadlessTableRenderer; onRenderUpdater: SubscriptionCallback; + forceBodyRerenderTimestamp: number; + lastRowToExpandRef: MutableRefObject; lastRowToCollapseRef: MutableRefObject; getDOMNodeForCell: (cellPos: CellPositionByIndex) => HTMLElement | null; @@ -166,6 +168,8 @@ export interface InfiniteTableMappedState { onKeyDown: InfiniteTableProps['onKeyDown']; onCellClick: InfiniteTableProps['onCellClick']; + repeatWrappedGroupRows: InfiniteTableProps['repeatWrappedGroupRows']; + wrapRowsHorizontally: InfiniteTableProps['wrapRowsHorizontally']; rowDetailCache: RowDetailCache; diff --git a/source/src/components/VirtualBrain/MatrixBrain.ts b/source/src/components/VirtualBrain/MatrixBrain.ts index 26f9c9e2..65754e73 100644 --- a/source/src/components/VirtualBrain/MatrixBrain.ts +++ b/source/src/components/VirtualBrain/MatrixBrain.ts @@ -20,6 +20,10 @@ const DEFAULT_EXTEND_BY = { end: 0, }; +const immediateCallback = (fn: Function) => { + fn(); +}; + export type { FixedPosition }; export type SpanFunction = ({ rowIndex, @@ -612,7 +616,7 @@ export class MatrixBrain extends Logger implements IBrain { }); }; - protected notifyRenderRangeChange() { + protected notifyRenderRangeChange(immediate: boolean = false) { if (this.destroyed) { return; } @@ -620,8 +624,10 @@ export class MatrixBrain extends Logger implements IBrain { const range = this.getRenderRange(); + const callback = immediate ? immediateCallback : raf; + fns.forEach((fn) => { - raf(() => { + callback(() => { if (this.destroyed) { return; } @@ -632,7 +638,7 @@ export class MatrixBrain extends Logger implements IBrain { }); }); } - protected notifyVerticalRenderRangeChange = () => { + protected notifyVerticalRenderRangeChange = (immediate: boolean = false) => { if (this.destroyed) { return; } @@ -640,8 +646,10 @@ export class MatrixBrain extends Logger implements IBrain { const range = this.verticalRenderRange; + const callback = immediate ? immediateCallback : raf; + fns.forEach((fn) => { - raf(() => { + callback(() => { if (this.destroyed) { return; } @@ -652,7 +660,9 @@ export class MatrixBrain extends Logger implements IBrain { }); }); }; - protected notifyHorizontalRenderRangeChange = () => { + protected notifyHorizontalRenderRangeChange = ( + immediate: boolean = false, + ) => { if (this.destroyed) { return; } @@ -660,8 +670,10 @@ export class MatrixBrain extends Logger implements IBrain { const range = this.horizontalRenderRange; + const callback = immediate ? immediateCallback : raf; + fns.forEach((fn) => { - raf(() => { + callback(() => { if (this.destroyed) { return; } diff --git a/source/src/utils/groupAndPivot/index.ts b/source/src/utils/groupAndPivot/index.ts index c0c15d9a..02602892 100644 --- a/source/src/utils/groupAndPivot/index.ts +++ b/source/src/utils/groupAndPivot/index.ts @@ -125,6 +125,8 @@ export type InfiniteTable_HasGrouping_RowInfoGroup = { reducerData?: Partial>; isGroupRow: true; + duplicateOf?: InfiniteTable_HasGrouping_RowInfoGroup['id']; + /** * This array contains all the (uncollapsed, so visible) row infos under this group, at any level of nesting, * in the order in which they are visible in the table @@ -1091,6 +1093,19 @@ function getEnhancedGroupData( return enhancedGroupData; } +function toDuplicateRow( + parent: InfiniteTable_HasGrouping_RowInfoGroup, + indexInAll: number, + currentPage: number, +) { + return { + ...parent, + id: `duplicate_row_on_page_${currentPage}_for__${parent.id}`, + indexInAll, + duplicateOf: parent.id, + }; +} + function completeReducers( reducers: Record>, reducerResults: Record, @@ -1113,6 +1128,8 @@ export type EnhancedFlattenParam = { lazyLoad: boolean; groupResult: DataGroupResult; + rowsPerPage?: number | null; + repeatWrappedGroupRows?: boolean; toPrimaryKey: (data: DataType, index: number) => any; groupRowsState?: GroupRowsState; isRowSelected?: (rowInfo: InfiniteTableRowInfo) => boolean | null; @@ -1130,6 +1147,8 @@ export function enhancedFlatten( const { lazyLoad, groupResult, + rowsPerPage, + repeatWrappedGroupRows, withRowInfo, toPrimaryKey, @@ -1223,6 +1242,20 @@ export function enhancedFlatten( } if (include) { + if (repeatWrappedGroupRows && rowsPerPage != null && rowsPerPage > 0) { + const indexInAll = enhancedGroupData.indexInAll; + const currentParents = enhancedGroupData.parents; + + if (currentParents.length > 0 && indexInAll % rowsPerPage === 0) { + const currentPage = indexInAll / rowsPerPage; + + // this is not a top-level group, so we can insert duplicate parents + currentParents.forEach((parent, i) => { + result.push(toDuplicateRow(parent, indexInAll + i, currentPage)); + }); + enhancedGroupData.indexInAll += currentParents.length; + } + } result.push(enhancedGroupData); groupRowsIndexes.push(result.length - 1); } @@ -1248,7 +1281,7 @@ export function enhancedFlatten( if (continueWithChildren) { if (!next) { if (!pivot) { - const startIndex = result.length; + let startIndex = result.length; // using items.map would have been easier // but we have sparse arrays, and if the last items are sparse @@ -1259,16 +1292,42 @@ export function enhancedFlatten( // this is a use-case we have when there is server-side batching - // we prefer index assignment, see we have to increment the length + // we prefer index assignment, so we have to increment the length // of the result array // by the number of items we want to add // this is in order to make the whole loop a tiny bit faster if (!collapsed) { result.length += items.length; } + + let extraArtificialGroupRows = 0; + for (let index = 0, len = items.length; index < len; index++) { const item = items[index]; - const indexInAll = startIndex + index; + + if ( + !collapsed && + repeatWrappedGroupRows && + rowsPerPage != null && + rowsPerPage > 0 + ) { + const currentInsertIndex = + startIndex + index + extraArtificialGroupRows; + + if (currentInsertIndex % rowsPerPage === 0) { + const currentPage = currentInsertIndex / rowsPerPage; + + result.length += parents.length; + // for each group, we want to repeat it + parents.forEach((parent) => { + const i = startIndex + index + extraArtificialGroupRows; + result[i] = toDuplicateRow(parent, i, currentPage); + extraArtificialGroupRows++; + }); + } + } + const indexInAll = startIndex + index + extraArtificialGroupRows; + const itemId = item ? toPrimaryKey(item, indexInAll) : `${groupKeys}-${index}`; @@ -1321,7 +1380,7 @@ export function enhancedFlatten( if (!collapsed) { // we prefer index assignment, see above - result[startIndex + index] = rowInfo; + result[startIndex + index + extraArtificialGroupRows] = rowInfo; } } }