Skip to content

Commit ddb1f87

Browse files
committed
wip
1 parent 2481846 commit ddb1f87

File tree

15 files changed

+1364
-25
lines changed

15 files changed

+1364
-25
lines changed

pages/app/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ function isAppLayoutPage(pageId?: string) {
3737
'content-layout',
3838
'grid-navigation-custom',
3939
'expandable-rows-test',
40+
'grouped-table-test',
4041
'container/sticky-permutations',
4142
'copy-to-clipboard/scenario-split-panel',
4243
'prompt-input/simple',
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useState } from 'react';
5+
6+
import { useCollection } from '@cloudscape-design/collection-hooks';
7+
8+
import {
9+
AppLayout,
10+
AttributeEditor,
11+
Box,
12+
Button,
13+
ExpandableSection,
14+
Modal,
15+
PropertyFilter,
16+
Select,
17+
StatusIndicator,
18+
TableProps,
19+
} from '~components';
20+
import Header from '~components/header';
21+
import I18nProvider from '~components/i18n';
22+
import messages from '~components/i18n/messages/all.en';
23+
import SpaceBetween from '~components/space-between';
24+
import Table from '~components/table';
25+
26+
import { TransactionRow } from './grouped-table/grouped-table-common';
27+
import { columnDefinitions, filteringProperties } from './grouped-table/grouped-table-configs';
28+
import { allTransactions, getGroupedTransactions, GroupDefinition } from './grouped-table/grouped-table-data';
29+
import { createIdsQuery, createWysiwygQuery, findSelectionIds } from './grouped-table/grouped-table-update-query';
30+
import { EmptyState, getMatchesCountText, renderAriaLive } from './shared-configs';
31+
32+
type LoadingState = Map<string, { pages: number; status: TableProps.LoadingStatus }>;
33+
34+
const groupOptions = [
35+
{ value: 'date_year', label: 'Date (year)' },
36+
{ value: 'date_quarter', label: 'Date (quarter)' },
37+
{ value: 'date_month', label: 'Date (month)' },
38+
{ value: 'date_day', label: 'Date (day)' },
39+
{ value: 'type', label: 'Type' },
40+
{ value: 'origin', label: 'Origin' },
41+
{ value: 'recipient', label: 'Recipient' },
42+
{ value: 'currency', label: 'Currency' },
43+
{ value: 'amountEur_100', label: 'Amount EUR (100)' },
44+
{ value: 'amountEur_500', label: 'Amount EUR (500)' },
45+
{ value: 'amountEur_1000', label: 'Amount EUR (1000)' },
46+
{ value: 'amountUsd_100', label: 'Amount USD (100)' },
47+
{ value: 'amountUsd_500', label: 'Amount USD (500)' },
48+
{ value: 'amountUsd_1000', label: 'Amount USD (1000)' },
49+
{ value: 'paymentMethod', label: 'Payment Method' },
50+
] as const;
51+
52+
const sortOptions = [
53+
{ value: 'asc', label: 'Ascending (A to Z)' },
54+
{ value: 'desc', label: 'Descending (Z to A)' },
55+
] as const;
56+
57+
function getHeaderCounterText<T>(items: number, selectedItems: ReadonlyArray<T> | undefined) {
58+
return selectedItems && selectedItems?.length > 0 ? `(${selectedItems.length}/${items})` : `(${items})`;
59+
}
60+
61+
export default () => {
62+
const [updateModalVisible, setUpdateModalVisible] = useState(false);
63+
const tableData = useTableData();
64+
const [selectedIds, selectedGroups] = findSelectionIds(tableData);
65+
return (
66+
<I18nProvider messages={[messages]} locale="en">
67+
<AppLayout
68+
contentType="table"
69+
navigationHide={true}
70+
content={
71+
<Table
72+
{...tableData.collectionProps}
73+
stickyColumns={{ first: 1 }}
74+
resizableColumns={true}
75+
selectionType="group"
76+
selectionInverted={tableData.selectionInverted}
77+
selectedItems={tableData.selectedItems}
78+
onSelectionChange={tableData.onSelectionChange}
79+
columnDefinitions={columnDefinitions}
80+
items={tableData.items}
81+
ariaLabels={{
82+
tableLabel: 'Transactions table',
83+
selectionGroupLabel: 'Transactions selection',
84+
allItemsSelectionLabel: () =>
85+
`${selectedIds.length} ${selectedIds.length === 1 ? 'item' : 'items'} selected`,
86+
itemSelectionLabel: (_, item) => {
87+
const isSelected = selectedGroups.some(id => id === item.group);
88+
return `${item.group} is ${isSelected ? '' : 'not'} selected`;
89+
},
90+
}}
91+
wrapLines={false}
92+
variant="full-page"
93+
renderAriaLive={renderAriaLive}
94+
empty={tableData.collectionProps.empty}
95+
header={
96+
<SpaceBetween size="m">
97+
<Header
98+
variant="h1"
99+
description="Table with grouped rows example"
100+
counter={getHeaderCounterText(tableData.totalItemsCount, selectedIds)}
101+
actions={
102+
<SpaceBetween size="s" direction="horizontal" alignItems="center">
103+
<Button disabled={selectedIds.length === 0} onClick={() => setUpdateModalVisible(true)}>
104+
Update selected
105+
</Button>
106+
107+
<Modal
108+
header="Update query viewer"
109+
visible={updateModalVisible}
110+
onDismiss={() => setUpdateModalVisible(false)}
111+
>
112+
<Box>Selected transactions: {selectedIds.length}</Box>
113+
<hr />
114+
115+
<ExpandableSection headerText="Selection state" defaultExpanded={true}>
116+
<Box variant="code">
117+
{JSON.stringify(
118+
{
119+
selectionInverted: tableData.selectionInverted,
120+
selectedItems: tableData.selectedItems.map(item => ({ key: item.group })),
121+
},
122+
null,
123+
2
124+
)}
125+
</Box>
126+
</ExpandableSection>
127+
128+
<ExpandableSection headerText="WYSIWYG update query" defaultExpanded={true}>
129+
<Box variant="code">{createWysiwygQuery(tableData)}</Box>
130+
</ExpandableSection>
131+
132+
<ExpandableSection headerText="Long update query">
133+
<Box variant="code">{createIdsQuery(selectedIds)}</Box>
134+
</ExpandableSection>
135+
</Modal>
136+
</SpaceBetween>
137+
}
138+
>
139+
Transactions
140+
</Header>
141+
142+
<Box margin={{ bottom: 'xs' }}>
143+
<ExpandableSection headerText={`Groups (${tableData.groups.length})`}>
144+
<AttributeEditor
145+
onAddButtonClick={() => tableData.actions.addGroup()}
146+
onRemoveButtonClick={({ detail: { itemIndex } }) => tableData.actions.deleteGroup(itemIndex)}
147+
items={tableData.groups}
148+
addButtonText="Add new group"
149+
definition={[
150+
{
151+
label: 'Property',
152+
control: (item, index) => (
153+
<Select
154+
selectedOption={groupOptions.find(o => o.value === item.property)!}
155+
options={groupOptions}
156+
onChange={({ detail }) =>
157+
tableData.actions.setGroupProperty(index, detail.selectedOption.value!)
158+
}
159+
/>
160+
),
161+
},
162+
{
163+
label: 'Sorting',
164+
control: (item, index) => (
165+
<Select
166+
selectedOption={sortOptions.find(o => o.value === item.sorting)!}
167+
options={sortOptions}
168+
onChange={({ detail }) =>
169+
tableData.actions.setGroupSorting(index, detail.selectedOption.value as 'asc' | 'desc')
170+
}
171+
/>
172+
),
173+
},
174+
]}
175+
empty="No groups"
176+
/>
177+
</ExpandableSection>
178+
</Box>
179+
</SpaceBetween>
180+
}
181+
filter={
182+
<PropertyFilter
183+
{...tableData.propertyFilterProps}
184+
filteringOptions={tableData.propertyFilterProps.filteringOptions.filter(
185+
o => o.value !== '[object Object]'
186+
)}
187+
countText={getMatchesCountText(tableData.filteredItemsCount ?? 0)}
188+
filteringPlaceholder="Search transactions"
189+
/>
190+
}
191+
getLoadingStatus={tableData.getLoadingStatus}
192+
renderLoaderLoading={() => <StatusIndicator type="loading">Loading items</StatusIndicator>}
193+
renderLoaderPending={({ item }) => (
194+
<Button
195+
variant="inline-link"
196+
iconName="add-plus"
197+
onClick={() => tableData.actions.loadItems(item?.key ?? 'ROOT')}
198+
>
199+
Show more items
200+
</Button>
201+
)}
202+
/>
203+
}
204+
/>
205+
</I18nProvider>
206+
);
207+
};
208+
209+
const ROOT_PAGE_SIZE = 10;
210+
const NESTED_PAGE_SIZE = 10;
211+
function useTableData() {
212+
const [groups, setGroups] = useState<GroupDefinition[]>([
213+
{
214+
property: 'date_year',
215+
sorting: 'desc',
216+
},
217+
{
218+
property: 'date_quarter',
219+
sorting: 'desc',
220+
},
221+
{
222+
property: 'amountEur_500',
223+
sorting: 'desc',
224+
},
225+
]);
226+
const collectionResultTransactions = useCollection(allTransactions, {
227+
sorting: {},
228+
propertyFiltering: {
229+
filteringProperties,
230+
noMatch: (
231+
<EmptyState
232+
title="No matches"
233+
subtitle="We can’t find a match."
234+
action={
235+
<Button onClick={() => collectionResult.actions.setPropertyFiltering({ operation: 'and', tokens: [] })}>
236+
Clear filter
237+
</Button>
238+
}
239+
/>
240+
),
241+
},
242+
});
243+
const collectionResult = useCollection(getGroupedTransactions(collectionResultTransactions.items, groups), {
244+
pagination: undefined,
245+
expandableRows: {
246+
getId: item => item.key,
247+
getParentId: item => item.parent,
248+
},
249+
});
250+
251+
const [selectionInverted, setSelectionInverted] = useState(false);
252+
const [selectedItems, setSelectedItems] = useState<TransactionRow[]>([]);
253+
254+
// Decorate path options to only show the last node and not the full path.
255+
collectionResult.propertyFilterProps.filteringOptions = collectionResult.propertyFilterProps.filteringOptions.map(
256+
option => (option.propertyKey === 'path' ? { ...option, value: option.value.split(',')[0] } : option)
257+
);
258+
259+
// Using a special id="ROOT" for progressive loading at the root level.
260+
const [loadingState, setLoadingState] = useState<LoadingState>(new Map([['ROOT', { status: 'pending', pages: 1 }]]));
261+
const nextLoading = (id: string) => (state: LoadingState) =>
262+
new Map([...state, [id, { status: 'loading', pages: state.get(id)?.pages ?? 0 }]]) as LoadingState;
263+
const nextPending = (id: string) => (state: LoadingState) =>
264+
new Map([...state, [id, { status: 'pending', pages: (state.get(id)?.pages ?? 0) + 1 }]]) as LoadingState;
265+
const loadItems = (id: string) => {
266+
setLoadingState(nextLoading(id));
267+
setTimeout(() => setLoadingState(nextPending(id)), 1000);
268+
};
269+
270+
const getItemChildren = collectionResult.collectionProps.expandableRows
271+
? collectionResult.collectionProps.expandableRows.getItemChildren.bind(null)
272+
: undefined;
273+
const onExpandableItemToggle = collectionResult.collectionProps.expandableRows
274+
? collectionResult.collectionProps.expandableRows.onExpandableItemToggle.bind(null)
275+
: undefined;
276+
277+
if (collectionResult.collectionProps.expandableRows) {
278+
// Decorate getItemChildren to paginate nested items.
279+
collectionResult.collectionProps.expandableRows.getItemChildren = item => {
280+
const children = getItemChildren!(item);
281+
const pages = loadingState.get(item.key)?.pages ?? 0;
282+
return children.slice(0, pages * NESTED_PAGE_SIZE);
283+
};
284+
// Decorate onExpandableItemToggle to trigger loading when expanded.
285+
collectionResult.collectionProps.expandableRows.onExpandableItemToggle = event => {
286+
onExpandableItemToggle!(event);
287+
if (event.detail.expanded) {
288+
loadItems(event.detail.item.key);
289+
}
290+
};
291+
}
292+
293+
const rootPages = loadingState.get('ROOT')!.pages;
294+
295+
const allItems = collectionResult.items;
296+
const paginatedItems = allItems.slice(0, rootPages * ROOT_PAGE_SIZE);
297+
298+
const getLoadingStatus = (item: null | TransactionRow): TableProps.LoadingStatus => {
299+
const id = item ? item.key : 'ROOT';
300+
const state = loadingState.get(id);
301+
if (state && state.status === 'loading') {
302+
return state.status;
303+
}
304+
const pages = state?.pages ?? 0;
305+
const pageSize = item ? NESTED_PAGE_SIZE : ROOT_PAGE_SIZE;
306+
const totalItems = item ? getItemChildren!(item).length : allItems.length;
307+
return pages * pageSize < totalItems ? 'pending' : 'finished';
308+
};
309+
310+
const addGroup = () => {
311+
setGroups(prev => [...prev, { property: 'date_year', sorting: 'asc' }]);
312+
};
313+
const deleteGroup = (index: number) => {
314+
setGroups(prev => {
315+
const tmpGroups = [...prev];
316+
tmpGroups.splice(index, 1);
317+
return tmpGroups;
318+
});
319+
};
320+
const setGroupProperty = (index: number, property: string) => {
321+
setGroups(prev =>
322+
prev.map((group, groupIndex) => {
323+
if (index !== groupIndex) {
324+
return group;
325+
}
326+
return { property, sorting: group.sorting };
327+
})
328+
);
329+
};
330+
const setGroupSorting = (index: number, sorting: GroupDefinition['sorting']) => {
331+
setGroups(prev =>
332+
prev.map((group, groupIndex) => {
333+
if (index !== groupIndex) {
334+
return group;
335+
}
336+
return { ...group, sorting };
337+
})
338+
);
339+
};
340+
341+
return {
342+
...collectionResult,
343+
collectionProps: {
344+
...collectionResult.collectionProps,
345+
sortingColumn: collectionResultTransactions.collectionProps.sortingColumn as any,
346+
sortingDescending: collectionResultTransactions.collectionProps.sortingDescending,
347+
onSortingChange: collectionResultTransactions.collectionProps.onSortingChange as any,
348+
},
349+
propertyFilterProps: collectionResultTransactions.propertyFilterProps,
350+
filteredItemsCount: collectionResultTransactions.filteredItemsCount,
351+
totalItemsCount: collectionResultTransactions.allPageItems.length,
352+
items: paginatedItems,
353+
groups,
354+
selectedItems,
355+
selectionInverted,
356+
onSelectionChange: ({ detail }: { detail: TableProps.SelectionChangeDetail<TransactionRow> }) => {
357+
setSelectionInverted(detail.selectionInverted ?? false);
358+
setSelectedItems(detail.selectedItems);
359+
},
360+
trackBy: (row: TransactionRow) => row.key,
361+
getItemChildren,
362+
actions: {
363+
loadItems,
364+
addGroup,
365+
deleteGroup,
366+
setGroupProperty,
367+
setGroupSorting,
368+
},
369+
getLoadingStatus,
370+
};
371+
}

0 commit comments

Comments
 (0)