Skip to content

Commit 63a45ad

Browse files
committed
feat: Adds support for table data grouping (2nd attempt, see: #126)
This reverts commit 86ce17a.
1 parent 86ce17a commit 63a45ad

File tree

14 files changed

+1366
-177
lines changed

14 files changed

+1366
-177
lines changed

package-lock.json

Lines changed: 972 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,14 @@
4242
"LICENSE",
4343
"NOTICE"
4444
],
45+
"dependencies": {
46+
"@cloudscape-design/component-toolkit": "^1.0.0-beta"
47+
},
4548
"peerDependencies": {
4649
"react": ">=16.8.0"
4750
},
4851
"devDependencies": {
52+
"@cloudscape-design/build-tools": "github:cloudscape-design/build-tools#main",
4953
"@testing-library/react": "^11.2.7",
5054
"@types/react": "^16.14.21",
5155
"@types/react-dom": "^16.9.14",
@@ -73,6 +77,9 @@
7377
"lint-staged": {
7478
"*.{js,jsx,ts,tsx}": [
7579
"eslint --fix"
80+
],
81+
"package-lock.json": [
82+
"prepare-package-lock"
7683
]
7784
},
7885
"overrides": {

src/__tests__/stubs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import * as React from 'react';
44
import { render as testRender } from '@testing-library/react';
55
import { UseCollectionResult, CollectionRef } from '../index.js';
6-
import { getTrackableValue } from '../operations/trackby-utils.js';
6+
import { getTrackableValue } from '@cloudscape-design/component-toolkit/internal';
77

88
export type Item = { id: string; date?: Date };
99

src/__tests__/use-collection-expadable-rows.test.tsx

Lines changed: 232 additions & 101 deletions
Large diffs are not rendered by default.

src/__tests__/utils.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,45 @@
44
import React from 'react';
55
import { useCollection, UseCollectionOptions, UseCollectionResult } from '..';
66
import { render } from '@testing-library/react';
7-
import { getTrackableValue } from '../operations/trackby-utils.js';
7+
import { getTrackableValue } from '@cloudscape-design/component-toolkit/internal';
8+
import { Item } from './stubs';
89

910
export function renderUseCollection<T>(allItems: readonly T[], options: UseCollectionOptions<T>) {
10-
const result: {
11-
collection: UseCollectionResult<T>;
11+
const current: {
12+
result: UseCollectionResult<T>;
1213
rerender: (allItems: readonly T[], options: UseCollectionOptions<T>) => void;
1314
// Derived props for simpler assertions
1415
visibleItems: T[];
1516
} = {} as any;
1617

1718
const onResult = (collection: UseCollectionResult<T>) => {
18-
result.collection = collection;
19-
result.visibleItems = getVisibleItems(collection);
19+
current.result = collection;
20+
current.visibleItems = getVisibleItems(collection);
2021
};
2122
const { rerender } = render(<App allItems={allItems} options={options} onResult={onResult} />);
2223

23-
result.rerender = (allItems: readonly T[], options: UseCollectionOptions<T>) =>
24+
current.rerender = (allItems: readonly T[], options: UseCollectionOptions<T>) =>
2425
rerender(<App allItems={allItems} options={options} onResult={onResult} />);
2526

26-
return result;
27+
return current;
2728
}
2829

30+
// Generates random items tree to be used for property-based tests.
31+
export const generateRandomNestedItems = ({ totalItems }: { totalItems: number }) => {
32+
const items: Item[] = [];
33+
let nextIndex = 0;
34+
let level = 1;
35+
while (nextIndex < totalItems - 1) {
36+
for (; nextIndex < totalItems && nextIndex < nextIndex + Math.floor(Math.random() * 5); nextIndex++) {
37+
const levelParents = items.filter(i => i.id.split('.').length === level - 1);
38+
const parent = levelParents[Math.floor(Math.random() * levelParents.length)];
39+
items.push({ id: !parent ? `${nextIndex}` : `${parent.id}.${nextIndex}` });
40+
}
41+
level = Math.random() > 0.5 ? level + 1 : level;
42+
}
43+
return items;
44+
};
45+
2946
function App<T>({
3047
allItems,
3148
options,

src/interfaces.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ export interface SelectionChangeDetail<T> {
2626
selectedItems: ReadonlyArray<T>;
2727
}
2828

29+
export interface GroupSelectionState<T> {
30+
inverted: boolean;
31+
toggledItems: readonly T[];
32+
}
33+
34+
export interface GroupSelectionChangeDetail<T> {
35+
groupSelection: GroupSelectionState<T>;
36+
}
37+
2938
export type TrackBy<T> = string | ((item: T) => string);
3039

3140
export interface UseCollectionOptions<T> {
@@ -57,15 +66,23 @@ export interface ExpandableRowsProps<ItemType> {
5766
getId(item: ItemType): string;
5867
getParentId(item: ItemType): null | string;
5968
defaultExpandedItems?: ReadonlyArray<ItemType>;
69+
// When set, only leaf nodes (those with no children) are reflected in the counters,
70+
// and selection is replaced by group selection.
71+
dataGrouping?: DataGroupingProps;
6072
}
6173

74+
// There is no configuration for data grouping yet, but it might come in future releases.
75+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
76+
export interface DataGroupingProps {}
77+
6278
export interface CollectionState<T> {
6379
filteringText: string;
6480
propertyFilteringQuery: PropertyFilterQuery;
6581
currentPageIndex: number;
6682
sortingState?: SortingState<T>;
6783
selectedItems: ReadonlyArray<T>;
6884
expandedItems: ReadonlyArray<T>;
85+
groupSelection: GroupSelectionState<T>;
6986
}
7087

7188
export interface CollectionActions<T> {
@@ -75,6 +92,7 @@ export interface CollectionActions<T> {
7592
setSelectedItems(selectedItems: ReadonlyArray<T>): void;
7693
setPropertyFiltering(query: PropertyFilterQuery): void;
7794
setExpandedItems(items: ReadonlyArray<T>): void;
95+
setGroupSelection(state: GroupSelectionState<T>): void;
7896
}
7997

8098
interface UseCollectionResultBase<T> {
@@ -86,17 +104,29 @@ interface UseCollectionResultBase<T> {
86104
onSortingChange?(event: CustomEventLike<SortingState<T>>): void;
87105
sortingColumn?: SortingColumn<T>;
88106
sortingDescending?: boolean;
107+
// When expandableRows.dataGrouping={}, the selected items are derived from the expandableRows.groupSelection,
108+
// and include all effectively selected leaf nodes.
89109
selectedItems?: ReadonlyArray<T>;
90110
onSelectionChange?(event: CustomEventLike<SelectionChangeDetail<T>>): void;
91111
expandableRows?: {
92112
getItemChildren: (item: T) => T[];
93113
isItemExpandable: (item: T) => boolean;
94114
expandedItems: ReadonlyArray<T>;
95115
onExpandableItemToggle(event: CustomEventLike<{ item: T; expanded: boolean }>): void;
116+
// The groupSelection property is only added in case selection is configured, and expandableRows.dataGrouping={}.
117+
groupSelection?: GroupSelectionState<T>;
118+
onGroupSelectionChange(event: CustomEventLike<GroupSelectionChangeDetail<T>>): void;
119+
// The counts reflect the number of nested selectable/selected nodes (deeply), including the given one.
120+
// When expandableRows.dataGrouping={}, only leaf nodes are considered. They return 1 when called on leaf nodes.
121+
getItemsCount?: (item: T) => number;
122+
getSelectedItemsCount?: (item: T) => number;
96123
};
97124
trackBy?: string | ((item: T) => string);
98125
ref: React.RefObject<CollectionRef>;
126+
// The counts reflect the number of selectable/selected nodes (deeply).
127+
// When expandableRows.dataGrouping={}, only leaf nodes are considered.
99128
totalItemsCount: number;
129+
totalSelectedItemsCount: number;
100130
firstIndex: number;
101131
};
102132
filterProps: {

src/operations/index.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,50 +6,71 @@ import { createPropertyFilterPredicate } from './property-filter.js';
66
import { createComparator } from './sort.js';
77
import { createPageProps } from './pagination.js';
88
import { composeFilters } from './compose-filters.js';
9-
import { getTrackableValue } from './trackby-utils.js';
9+
import { getTrackableValue, SelectionTree } from '@cloudscape-design/component-toolkit/internal';
1010
import { computeFlatItems, computeTreeItems } from './items-tree.js';
1111

1212
export function processItems<T>(
1313
allItems: ReadonlyArray<T>,
14-
{ filteringText, sortingState, currentPageIndex, propertyFilteringQuery }: Partial<CollectionState<T>>,
15-
{ filtering, sorting, pagination, propertyFiltering, expandableRows }: UseCollectionOptions<T>
14+
state: Partial<CollectionState<T>>,
15+
{ filtering, sorting, pagination, propertyFiltering, expandableRows, selection }: UseCollectionOptions<T>
1616
): {
1717
items: readonly T[];
1818
allPageItems: readonly T[];
1919
pagesCount: number | undefined;
2020
actualPageIndex: number | undefined;
21+
totalItemsCount: number;
2122
filteredItemsCount: number | undefined;
23+
selectedItems: undefined | T[];
24+
getItemsCount?: (item: T) => number;
25+
getSelectedItemsCount?: (item: T) => number;
2226
getChildren: (item: T) => T[];
2327
} {
2428
const filterPredicate = composeFilters(
25-
createPropertyFilterPredicate(propertyFiltering, propertyFilteringQuery),
26-
createFilterPredicate(filtering, filteringText)
29+
createPropertyFilterPredicate(propertyFiltering, state.propertyFilteringQuery),
30+
createFilterPredicate(filtering, state.filteringText)
2731
);
28-
const sortingComparator = createComparator(sorting, sortingState);
29-
const { items, size, getChildren } = expandableRows
32+
const sortingComparator = createComparator(sorting, state.sortingState);
33+
const { items, totalItemsCount, getChildren, getItemsCount } = expandableRows
3034
? computeTreeItems(allItems, expandableRows, filterPredicate, sortingComparator)
3135
: computeFlatItems(allItems, filterPredicate, sortingComparator);
36+
const filteredItemsCount = filterPredicate ? totalItemsCount : undefined;
3237

33-
const filteredItemsCount = filterPredicate ? size : undefined;
38+
let getSelectedItemsCount: undefined | ((item: T) => number) = undefined;
39+
let selectedItems: undefined | T[] = undefined;
40+
if (selection && expandableRows?.dataGrouping && state.groupSelection) {
41+
const trackBy = selection?.trackBy ?? expandableRows?.getId;
42+
const selectionTreeProps = { getChildren: getChildren, trackBy };
43+
const selectionTree = new SelectionTree(items, selectionTreeProps, state.groupSelection);
44+
getSelectedItemsCount = selectionTree.getSelectedItemsCount;
45+
selectedItems = selectionTree.getSelectedItems();
46+
}
3447

35-
const pageProps = createPageProps(pagination, currentPageIndex, items);
48+
const pageProps = createPageProps(pagination, state.currentPageIndex, items);
3649
if (pageProps) {
3750
return {
3851
items: items.slice((pageProps.pageIndex - 1) * pageProps.pageSize, pageProps.pageIndex * pageProps.pageSize),
3952
allPageItems: items,
53+
totalItemsCount,
4054
filteredItemsCount,
4155
pagesCount: pageProps?.pagesCount,
4256
actualPageIndex: pageProps?.pageIndex,
57+
selectedItems,
58+
getItemsCount,
59+
getSelectedItemsCount,
4360
getChildren,
4461
};
4562
}
4663

4764
return {
4865
items: items,
4966
allPageItems: items,
67+
totalItemsCount,
5068
filteredItemsCount,
5169
pagesCount: undefined,
5270
actualPageIndex: undefined,
71+
selectedItems,
72+
getItemsCount,
73+
getSelectedItemsCount,
5374
getChildren,
5475
};
5576
}

src/operations/items-tree.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import { DataGroupingProps } from '../interfaces';
5+
46
interface TreeProps<T> {
57
getId(item: T): string;
68
getParentId(item: T): null | string;
9+
dataGrouping?: DataGroupingProps;
710
}
811

912
export function computeFlatItems<T>(
@@ -17,7 +20,7 @@ export function computeFlatItems<T>(
1720
if (sortingComparator) {
1821
items = items.slice().sort(sortingComparator);
1922
}
20-
return { items, size: items.length, getChildren: () => [] };
23+
return { items, totalItemsCount: items.length, getChildren: () => [], getItemsCount: undefined };
2124
}
2225

2326
export function computeTreeItems<T>(
@@ -27,8 +30,9 @@ export function computeTreeItems<T>(
2730
sortingComparator: null | ((a: T, b: T) => number)
2831
) {
2932
const idToChildren = new Map<string, T[]>();
33+
const idToCount = new Map<string, number>();
3034
let items: T[] = [];
31-
let size = allItems.length;
35+
let totalItemsCount = 0;
3236

3337
for (const item of allItems) {
3438
const parentId = treeProps.getParentId(item);
@@ -47,13 +51,10 @@ export function computeTreeItems<T>(
4751
const filterNode = (item: T): boolean => {
4852
const children = getChildren(item);
4953
const filteredChildren = children.filter(filterNode);
50-
size -= children.length - filteredChildren.length;
5154
setChildren(item, filteredChildren);
5255
return filterPredicate(item) || filteredChildren.length > 0;
5356
};
54-
const prevLength = items.length;
5557
items = items.filter(filterNode);
56-
size -= prevLength - items.length;
5758
}
5859

5960
if (sortingComparator) {
@@ -66,5 +67,19 @@ export function computeTreeItems<T>(
6667
sortLevel(items);
6768
}
6869

69-
return { items, size, getChildren };
70+
function computeGroupCounts(item: T) {
71+
const children = getChildren(item);
72+
let itemCount = children.length === 0 ? 1 : 0;
73+
for (const child of children) {
74+
itemCount += computeGroupCounts(child);
75+
}
76+
idToCount.set(treeProps.getId(item), itemCount);
77+
return itemCount;
78+
}
79+
for (const item of items) {
80+
totalItemsCount += treeProps.dataGrouping ? computeGroupCounts(item) : 1;
81+
}
82+
const getItemsCount = treeProps.dataGrouping ? (item: T) => idToCount.get(treeProps.getId(item)) ?? 0 : undefined;
83+
84+
return { items, totalItemsCount, getChildren, getItemsCount };
7085
}

src/operations/trackby-utils.ts

Lines changed: 0 additions & 14 deletions
This file was deleted.

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
declare global {
99
const process: { env: { NODE_ENV?: string } };
1010
const console: { warn: (...args: Array<any>) => void };
11+
type AbortSignal = any; // Used in the component-toolkit dependency
1112
}
1213

1314
// dummy export to make typescript treat this file as ES module

0 commit comments

Comments
 (0)