From c68e2b2c5b35f8ec2c961b1f383c6e734300c030 Mon Sep 17 00:00:00 2001 From: Andrei Zhaleznichenka Date: Thu, 17 Jul 2025 19:27:59 +0200 Subject: [PATCH] wip --- pages/app/index.tsx | 1 + pages/table/editable.page.tsx | 11 +- pages/table/grouped-table-test.page.tsx | 371 +++++++++++ .../grouped-table/grouped-table-common.tsx | 48 ++ .../grouped-table/grouped-table-configs.tsx | 173 +++++ .../grouped-table/grouped-table-data.tsx | 240 +++++++ .../grouped-table-update-query.ts | 128 ++++ .../__snapshots__/documenter.test.ts.snap | 22 +- src/table/__tests__/a11y.test.tsx | 2 +- src/table/__tests__/group-selection.test.tsx | 603 ++++++++++++++++++ src/table/__tests__/selection.test.tsx | 34 +- .../expandable-rows/expandable-rows-utils.ts | 18 +- src/table/index.tsx | 2 + src/table/interfaces.tsx | 15 +- src/table/internal.tsx | 52 +- src/table/selection/use-group-selection.ts | 123 ++++ src/table/selection/use-selection.ts | 10 +- src/table/selection/utils.ts | 227 ++++++- 18 files changed, 2033 insertions(+), 47 deletions(-) create mode 100644 pages/table/grouped-table-test.page.tsx create mode 100644 pages/table/grouped-table/grouped-table-common.tsx create mode 100644 pages/table/grouped-table/grouped-table-configs.tsx create mode 100644 pages/table/grouped-table/grouped-table-data.tsx create mode 100644 pages/table/grouped-table/grouped-table-update-query.ts create mode 100644 src/table/__tests__/group-selection.test.tsx create mode 100644 src/table/selection/use-group-selection.ts diff --git a/pages/app/index.tsx b/pages/app/index.tsx index 5678ad8cb3..fb6a98149b 100644 --- a/pages/app/index.tsx +++ b/pages/app/index.tsx @@ -37,6 +37,7 @@ function isAppLayoutPage(pageId?: string) { 'content-layout', 'grid-navigation-custom', 'expandable-rows-test', + 'grouped-table-test', 'container/sticky-permutations', 'copy-to-clipboard/scenario-split-panel', 'prompt-input/simple', diff --git a/pages/table/editable.page.tsx b/pages/table/editable.page.tsx index a3637e223c..f4167223ee 100644 --- a/pages/table/editable.page.tsx +++ b/pages/table/editable.page.tsx @@ -307,10 +307,13 @@ const Demo = forwardRef( expandableRows={ expandableRows ? { - getItemChildren: item => [ - { ...item, Id: item.Id + '-1' }, - { ...item, Id: item.Id + '-2' }, - ], + getItemChildren: item => + item.Id.split('-').length < 3 + ? [ + { ...item, Id: item.Id + '-1' }, + { ...item, Id: item.Id + '-2' }, + ] + : [], isItemExpandable: item => !item.Id.endsWith('-1') && !item.Id.endsWith('-2'), expandedItems, onExpandableItemToggle: ({ detail }) => { diff --git a/pages/table/grouped-table-test.page.tsx b/pages/table/grouped-table-test.page.tsx new file mode 100644 index 0000000000..f6ae6a2c88 --- /dev/null +++ b/pages/table/grouped-table-test.page.tsx @@ -0,0 +1,371 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useState } from 'react'; + +import { useCollection } from '@cloudscape-design/collection-hooks'; + +import { + AppLayout, + AttributeEditor, + Box, + Button, + ExpandableSection, + Modal, + PropertyFilter, + Select, + StatusIndicator, + TableProps, +} from '~components'; +import Header from '~components/header'; +import I18nProvider from '~components/i18n'; +import messages from '~components/i18n/messages/all.en'; +import SpaceBetween from '~components/space-between'; +import Table from '~components/table'; + +import { TransactionRow } from './grouped-table/grouped-table-common'; +import { createColumnDefinitions, filteringProperties } from './grouped-table/grouped-table-configs'; +import { allTransactions, getGroupedTransactions, GroupDefinition } from './grouped-table/grouped-table-data'; +import { createIdsQuery, createWysiwygQuery, findSelectionIds } from './grouped-table/grouped-table-update-query'; +import { EmptyState, getMatchesCountText, renderAriaLive } from './shared-configs'; + +type LoadingState = Map; + +const groupOptions = [ + { value: 'date_year', label: 'Date (year)' }, + { value: 'date_quarter', label: 'Date (quarter)' }, + { value: 'date_month', label: 'Date (month)' }, + { value: 'date_day', label: 'Date (day)' }, + { value: 'type', label: 'Type' }, + { value: 'origin', label: 'Origin' }, + { value: 'recipient', label: 'Recipient' }, + { value: 'currency', label: 'Currency' }, + { value: 'amountEur_100', label: 'Amount EUR (100)' }, + { value: 'amountEur_500', label: 'Amount EUR (500)' }, + { value: 'amountEur_1000', label: 'Amount EUR (1000)' }, + { value: 'amountUsd_100', label: 'Amount USD (100)' }, + { value: 'amountUsd_500', label: 'Amount USD (500)' }, + { value: 'amountUsd_1000', label: 'Amount USD (1000)' }, + { value: 'paymentMethod', label: 'Payment Method' }, +] as const; + +const sortOptions = [ + { value: 'asc', label: 'Ascending (A to Z)' }, + { value: 'desc', label: 'Descending (Z to A)' }, +] as const; + +function getHeaderCounterText(items: number, selectedItems: ReadonlyArray | undefined) { + return selectedItems && selectedItems?.length > 0 ? `(${selectedItems.length}/${items})` : `(${items})`; +} + +export default () => { + const [updateModalVisible, setUpdateModalVisible] = useState(false); + const tableData = useTableData(); + const [selectedIds, selectedGroups] = findSelectionIds(tableData); + return ( + + g.property) })} + items={tableData.items} + ariaLabels={{ + tableLabel: 'Transactions table', + selectionGroupLabel: 'Transactions selection', + allItemsSelectionLabel: () => + `${selectedIds.length} ${selectedIds.length === 1 ? 'item' : 'items'} selected`, + itemSelectionLabel: (_, item) => { + const isSelected = selectedGroups.some(id => id === item.group); + return `${item.group} is ${isSelected ? '' : 'not'} selected`; + }, + }} + wrapLines={false} + variant="full-page" + renderAriaLive={renderAriaLive} + empty={tableData.collectionProps.empty} + header={ + +
+ + + setUpdateModalVisible(false)} + > + Selected transactions: {selectedIds.length} +
+ + + + {JSON.stringify( + { + selectionInverted: tableData.selectionInverted, + selectedItems: tableData.selectedItems.map(item => ({ key: item.group })), + }, + null, + 2 + )} + + + + + {createWysiwygQuery(tableData)} + + + + {createIdsQuery(selectedIds)} + +
+ + } + > + Transactions +
+ + + + tableData.actions.addGroup()} + onRemoveButtonClick={({ detail: { itemIndex } }) => tableData.actions.deleteGroup(itemIndex)} + items={tableData.groups} + addButtonText="Add new group" + definition={[ + { + label: 'Property', + control: (item, index) => ( + o.value === item.sorting)!} + options={sortOptions} + onChange={({ detail }) => + tableData.actions.setGroupSorting(index, detail.selectedOption.value as 'asc' | 'desc') + } + /> + ), + }, + ]} + empty="No groups" + /> + + +
+ } + filter={ + o.value !== '[object Object]' + )} + countText={getMatchesCountText(tableData.filteredItemsCount ?? 0)} + filteringPlaceholder="Search transactions" + /> + } + getLoadingStatus={tableData.getLoadingStatus} + renderLoaderLoading={() => Loading items} + renderLoaderPending={({ item }) => ( + + )} + /> + } + /> +
+ ); +}; + +const ROOT_PAGE_SIZE = 10; +const NESTED_PAGE_SIZE = 10; +function useTableData() { + const [groups, setGroups] = useState([ + { + property: 'date_year', + sorting: 'desc', + }, + { + property: 'date_quarter', + sorting: 'desc', + }, + { + property: 'amountEur_500', + sorting: 'desc', + }, + ]); + const collectionResultTransactions = useCollection(allTransactions, { + sorting: {}, + propertyFiltering: { + filteringProperties, + noMatch: ( + collectionResult.actions.setPropertyFiltering({ operation: 'and', tokens: [] })}> + Clear filter + + } + /> + ), + }, + }); + const collectionResult = useCollection(getGroupedTransactions(collectionResultTransactions.items, groups), { + pagination: undefined, + expandableRows: { + getId: item => item.key, + getParentId: item => item.parent, + }, + }); + + const [selectionInverted, setSelectionInverted] = useState(false); + const [selectedItems, setSelectedItems] = useState([]); + + // Decorate path options to only show the last node and not the full path. + collectionResult.propertyFilterProps.filteringOptions = collectionResult.propertyFilterProps.filteringOptions.map( + option => (option.propertyKey === 'path' ? { ...option, value: option.value.split(',')[0] } : option) + ); + + // Using a special id="ROOT" for progressive loading at the root level. + const [loadingState, setLoadingState] = useState(new Map([['ROOT', { status: 'pending', pages: 1 }]])); + const nextLoading = (id: string) => (state: LoadingState) => + new Map([...state, [id, { status: 'loading', pages: state.get(id)?.pages ?? 0 }]]) as LoadingState; + const nextPending = (id: string) => (state: LoadingState) => + new Map([...state, [id, { status: 'pending', pages: (state.get(id)?.pages ?? 0) + 1 }]]) as LoadingState; + const loadItems = (id: string) => { + setLoadingState(nextLoading(id)); + setTimeout(() => setLoadingState(nextPending(id)), 1000); + }; + + const getItemChildren = collectionResult.collectionProps.expandableRows + ? collectionResult.collectionProps.expandableRows.getItemChildren.bind(null) + : undefined; + const onExpandableItemToggle = collectionResult.collectionProps.expandableRows + ? collectionResult.collectionProps.expandableRows.onExpandableItemToggle.bind(null) + : undefined; + + if (collectionResult.collectionProps.expandableRows) { + // Decorate getItemChildren to paginate nested items. + collectionResult.collectionProps.expandableRows.getItemChildren = item => { + const children = getItemChildren!(item); + const pages = loadingState.get(item.key)?.pages ?? 0; + return children.slice(0, pages * NESTED_PAGE_SIZE); + }; + // Decorate onExpandableItemToggle to trigger loading when expanded. + collectionResult.collectionProps.expandableRows.onExpandableItemToggle = event => { + onExpandableItemToggle!(event); + if (event.detail.expanded) { + loadItems(event.detail.item.key); + } + }; + } + + const rootPages = loadingState.get('ROOT')!.pages; + + const allItems = collectionResult.items; + const paginatedItems = allItems.slice(0, rootPages * ROOT_PAGE_SIZE); + + const getLoadingStatus = (item: null | TransactionRow): TableProps.LoadingStatus => { + const id = item ? item.key : 'ROOT'; + const state = loadingState.get(id); + if (state && state.status === 'loading') { + return state.status; + } + const pages = state?.pages ?? 0; + const pageSize = item ? NESTED_PAGE_SIZE : ROOT_PAGE_SIZE; + const totalItems = item ? getItemChildren!(item).length : allItems.length; + return pages * pageSize < totalItems ? 'pending' : 'finished'; + }; + + const addGroup = () => { + setGroups(prev => [...prev, { property: 'date_year', sorting: 'asc' }]); + }; + const deleteGroup = (index: number) => { + setGroups(prev => { + const tmpGroups = [...prev]; + tmpGroups.splice(index, 1); + return tmpGroups; + }); + }; + const setGroupProperty = (index: number, property: string) => { + setGroups(prev => + prev.map((group, groupIndex) => { + if (index !== groupIndex) { + return group; + } + return { property, sorting: group.sorting }; + }) + ); + }; + const setGroupSorting = (index: number, sorting: GroupDefinition['sorting']) => { + setGroups(prev => + prev.map((group, groupIndex) => { + if (index !== groupIndex) { + return group; + } + return { ...group, sorting }; + }) + ); + }; + + return { + ...collectionResult, + collectionProps: { + ...collectionResult.collectionProps, + sortingColumn: collectionResultTransactions.collectionProps.sortingColumn as any, + sortingDescending: collectionResultTransactions.collectionProps.sortingDescending, + onSortingChange: collectionResultTransactions.collectionProps.onSortingChange as any, + }, + propertyFilterProps: collectionResultTransactions.propertyFilterProps, + filteredItemsCount: collectionResultTransactions.filteredItemsCount, + totalItemsCount: allTransactions.length, + items: paginatedItems, + groups, + selectedItems, + selectionInverted, + onSelectionChange: ({ detail }: { detail: TableProps.SelectionChangeDetail }) => { + setSelectionInverted(detail.selectionInverted ?? false); + setSelectedItems(detail.selectedItems); + }, + trackBy: (row: TransactionRow) => row.key, + getItemChildren, + actions: { + loadItems, + addGroup, + deleteGroup, + setGroupProperty, + setGroupSorting, + }, + getLoadingStatus, + }; +} diff --git a/pages/table/grouped-table/grouped-table-common.tsx b/pages/table/grouped-table/grouped-table-common.tsx new file mode 100644 index 0000000000..c4ddaea16f --- /dev/null +++ b/pages/table/grouped-table/grouped-table-common.tsx @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type TransactionType = 'DEPOSIT' | 'WITHDRAWAL' | 'TRANSFER' | 'PAYMENT'; + +// The interface for transaction data entry. It includes precomputed group bases like date_quarter +// or amountEur_500 that are used as keys for data grouping. +export interface Transaction { + id: string; + type: TransactionType; + date: Date; + date_year: string; + date_quarter: string; + date_month: string; + date_day: string; + origin: string; + recipient: string; + currency: string; + amountEur: number; + amountEur_100: number; + amountEur_500: number; + amountEur_1000: number; + amountUsd: number; + amountUsd_100: number; + amountUsd_500: number; + amountUsd_1000: number; + paymentMethod: string; +} + +// The interface for grouped table row. The row can represent a data entry (transaction, includes a single transaction in +// the transactions array, and zero children), or a transaction group (includes one or more transactions and children). +// It includes precomputed aggregations for all properties, such as amountEur (total or average), or type (unique count). +export interface TransactionRow { + key: string; + group: string; + groupKey: string; + transactions: Transaction[]; + parent: null | string; + children: TransactionRow[]; + type: TransactionType | { uniqueTypes: number }; + date: Date | [from: Date, until: Date]; + origin: string | { uniqueOrigins: number }; + recipient: string | { uniqueRecipients: number }; + currency: string | { uniqueCurrencies: number }; + amountEur: number | { totalAmount: number; averageAmount: number }; + amountUsd: number | { totalAmount: number; averageAmount: number }; + paymentMethod: string | { uniquePaymentMethods: number }; +} diff --git a/pages/table/grouped-table/grouped-table-configs.tsx b/pages/table/grouped-table/grouped-table-configs.tsx new file mode 100644 index 0000000000..ba1b10794a --- /dev/null +++ b/pages/table/grouped-table/grouped-table-configs.tsx @@ -0,0 +1,173 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { format } from 'date-fns'; + +import { Box, Link, PropertyFilterProps, SpaceBetween, TableProps } from '~components'; + +import { columnLabel } from '../shared-configs'; +import { TransactionRow } from './grouped-table-common'; + +export const createColumnDefinitions = ({ + selectedIds, + groups, +}: { + selectedIds: string[]; + groups: string[]; +}): TableProps.ColumnDefinition[] => { + const propNames: Record = { + group: 'Group', + type: 'Type', + date: 'Date', + date_year: 'Year', + date_quarter: 'Quarter', + date_month: 'Month', + date_day: 'Day', + origin: 'Origin', + recipient: 'Recipient', + currency: 'Currency', + amountEur: 'Amount EUR', + amountEur_100: 'Amount (€100)', + amountEur_500: 'Amount (€500)', + amountEur_1000: 'Amount (€1k)', + amountUsd: 'Amount USD', + amountUsd_100: 'Amount ($100)', + amountUsd_500: 'Amount ($500)', + amountUsd_1000: 'Amount ($1k)', + paymentMethod: 'Payment method', + }; + const getColumnProps = (id: string) => ({ id, header: propNames[id] ?? id }); + return [ + { + id: 'group', + header: [...groups.map(g => propNames[g] ?? g), 'ID'].join(' / '), + cell: item => { + const totalCounter = item.transactions.length; + const selectedCounter = item.transactions.filter(t => selectedIds.includes(t.id)).length; + const counterText = + totalCounter > 0 && selectedCounter > 0 + ? `(${selectedCounter}/${totalCounter})` + : totalCounter > 0 + ? `(${totalCounter})` + : ''; + return ( + + {item.children.length === 0 ? {item.group} : item.group} + {item.children.length > 0 && counterText && ( + + {counterText} + + )} + + ); + }, + minWidth: 200, + width: 300, + isRowHeader: true, + }, + { + ...getColumnProps('type'), + cell: item => (typeof item.type === 'string' ? item.type : `${item.type.uniqueTypes} types`), + ariaLabel: columnLabel('Type'), + sortingField: 'type', + }, + { + ...getColumnProps('date'), + cell: item => + item.date instanceof Date + ? format(item.date, 'yyyy-MM-dd HH:mm') + : `${format(item.date[0], 'yyyy-MM-dd')} - ${format(item.date[1], 'yyyy-MM-dd')}`, + ariaLabel: columnLabel('Date'), + sortingField: 'date', + width: 200, + }, + { + ...getColumnProps('origin'), + cell: item => (typeof item.origin === 'string' ? item.origin : `${item.origin.uniqueOrigins} origin(s)`), + ariaLabel: columnLabel('Origin'), + sortingField: 'origin', + }, + { + ...getColumnProps('recipient'), + cell: item => + typeof item.recipient === 'string' ? item.recipient : `${item.recipient.uniqueRecipients} recipient(s)`, + ariaLabel: columnLabel('Recipient'), + sortingField: 'recipient', + }, + { + ...getColumnProps('currency'), + cell: item => + typeof item.currency === 'string' ? item.currency : `${item.currency.uniqueCurrencies} currency(es)`, + ariaLabel: columnLabel('Currency'), + sortingField: 'currency', + }, + { + ...getColumnProps('amountEur'), + cell: item => + typeof item.amountEur === 'number' + ? eurFormatter(item.amountEur) + : `${eurFormatter(item.amountEur.totalAmount)} / ${eurFormatter(item.amountEur.averageAmount)}`, + ariaLabel: columnLabel('Amount EUR'), + sortingField: 'amountEur', + }, + { + ...getColumnProps('amountUsd'), + cell: item => + typeof item.amountUsd === 'number' + ? usdFormatter(item.amountUsd) + : `${usdFormatter(item.amountUsd.totalAmount)} / ${usdFormatter(item.amountUsd.averageAmount)}`, + ariaLabel: columnLabel('Amount USD'), + sortingField: 'amountUsd', + }, + { + ...getColumnProps('paymentMethod'), + cell: item => + typeof item.paymentMethod === 'string' + ? item.paymentMethod + : `${item.paymentMethod.uniquePaymentMethods} payment method(s)`, + ariaLabel: columnLabel('Payment method'), + sortingField: 'paymentMethod', + }, + ]; +}; + +function usdFormatter(value: number) { + return '$' + value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} +function eurFormatter(value: number) { + return '€' + value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +export const filteringProperties: PropertyFilterProps.FilteringProperty[] = [ + { + key: 'type', + propertyLabel: 'Type', + groupValuesLabel: 'Type values', + operators: ['=', '!='], + }, + { + key: 'origin', + propertyLabel: 'Origin', + groupValuesLabel: 'Origin values', + operators: ['=', '!='], + }, + { + key: 'recipient', + propertyLabel: 'Recipient', + groupValuesLabel: 'Recipient values', + operators: ['=', '!='], + }, + { + key: 'currency', + propertyLabel: 'Currency', + groupValuesLabel: 'Currency values', + operators: ['=', '!='], + }, + { + key: 'paymentMethod', + propertyLabel: 'Payment Method', + groupValuesLabel: 'Payment Method values', + operators: ['=', '!='], + }, +]; diff --git a/pages/table/grouped-table/grouped-table-data.tsx b/pages/table/grouped-table/grouped-table-data.tsx new file mode 100644 index 0000000000..904d945561 --- /dev/null +++ b/pages/table/grouped-table/grouped-table-data.tsx @@ -0,0 +1,240 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + addMonths, + addSeconds, + addWeeks, + format, + max, + min, + startOfDay, + startOfMonth, + startOfQuarter, + startOfYear, +} from 'date-fns'; +import { groupBy, orderBy, sumBy, uniq } from 'lodash'; + +import pseudoRandom from '../../utils/pseudo-random'; +import { id as generateId } from '../generate-data'; +import { Transaction, TransactionRow } from './grouped-table-common'; + +export interface GroupDefinition { + property: string; + sorting: 'asc' | 'desc'; +} + +type TransactionDefinition = Pick & { + amount: () => number; +}; + +export const allTransactions: Transaction[] = []; + +let currentMoment = new Date('2000-01-01T12:00:00'); +const reset = (date = new Date('2000-01-01T12:00:00')) => (currentMoment = date); + +function addTransaction({ amount: getAmount, ...t }: TransactionDefinition) { + let amountUsd = 0; + let amountEur = 0; + const amount = getAmount(); + switch (t.currency) { + case 'EUR': + amountUsd = amount * 1.1; + amountEur = amount; + break; + case 'USD': + amountUsd = amount; + amountEur = amount * 0.9; + break; + default: + throw new Error('Unsupported currency'); + } + allTransactions.push({ + id: generateId(), + ...t, + date: currentMoment, + date_year: getDateBase(currentMoment, 'year'), + date_quarter: getDateBase(currentMoment, 'quarter'), + date_month: getDateBase(currentMoment, 'month'), + date_day: getDateBase(currentMoment, 'day'), + amountUsd, + amountEur, + amountUsd_100: getNumericBase(amountUsd, 100), + amountUsd_500: getNumericBase(amountUsd, 500), + amountUsd_1000: getNumericBase(amountUsd, 1000), + amountEur_100: getNumericBase(amountEur, 100), + amountEur_500: getNumericBase(amountEur, 500), + amountEur_1000: getNumericBase(amountEur, 1000), + }); +} +function getNumericBase(value: number, basis: number): number { + return parseInt((Math.ceil(value / basis) * basis).toFixed(0)); +} +function getDateBase(value: Date, basis: string) { + switch (basis) { + case 'year': + return format(startOfYear(value), 'yyyy'); + case 'quarter': + return format(startOfQuarter(value), 'QQQ yyyy'); + case 'month': + return format(startOfMonth(value), 'MMMM yyyy'); + case 'day': + return format(startOfDay(value), 'yyyy-MM-dd'); + default: + throw new Error('Unsupported date base.'); + } +} +function transfer(from: string, to: string, currency: string, amount: () => number): TransactionDefinition { + return { type: 'TRANSFER', origin: from, recipient: to, currency, amount, paymentMethod: 'Bank transfer' }; +} +function withdraw(from: string, currency: string, amount: () => number): TransactionDefinition { + return { + type: 'WITHDRAWAL', + origin: from, + recipient: 'Cash', + currency, + amount, + paymentMethod: 'Cash withdrawal', + }; +} + +function repeat(transaction: TransactionDefinition, increment: (date: Date) => Date, until = new Date('2015-01-01')) { + while (currentMoment < until && currentMoment < new Date('2015-01-01')) { + addTransaction(transaction); + currentMoment = increment(currentMoment); + } +} + +const monthly = (date: Date) => addMonths(date, 1); +const everyWeekOrSo = (date: Date) => + addSeconds(addWeeks(date, 1), Math.floor(pseudoRandom() * 3600 * 24 * 3 - 3600 * 24 * 1.5)); + +// John Doe Salary +reset(); +repeat( + transfer('Lovers GmbH', 'John Doe', 'EUR', () => 2500), + monthly, + new Date('2008-05-01') +); +repeat( + transfer('Haters GmbH', 'John Doe', 'EUR', () => 3100), + monthly, + new Date('2012-01-01') +); +repeat( + transfer('Lovers International', 'John Doe', 'USD', () => 4500), + monthly +); + +// Jane Doe Salary +reset(); +repeat( + transfer('Haters International', 'Jane Freeman', 'USD', () => 5000), + monthly, + new Date('2012-01-01') +); +repeat( + transfer('Lovers International', 'Jane Doe', 'USD', () => 4500), + monthly +); + +// John -> Jane compensation +reset(new Date('2012-01-01T13:00:00')); +repeat( + transfer('John Doe', 'Jane Doe', 'USD', () => 500), + monthly +); + +// John spending +reset(); +repeat( + withdraw('John Doe', 'EUR', () => pseudoRandom() * 100), + everyWeekOrSo +); + +// Jane spending +reset(); +repeat( + withdraw('Jane Freeman', 'EUR', () => pseudoRandom() * 90), + everyWeekOrSo, + new Date('2012-01-01') +); +repeat( + withdraw('Jane Doe', 'EUR', () => pseudoRandom() * 120), + everyWeekOrSo +); +allTransactions.sort((a, b) => b.date.getTime() - a.date.getTime()); + +export function getGroupedTransactions(data: readonly Transaction[], groups: GroupDefinition[]): TransactionRow[] { + function makeGroups( + transactions: readonly Transaction[], + groupIndex: number, + parent: null | string + ): TransactionRow[] { + const group = groups[groupIndex]; + if (!group) { + return transactions.map(t => ({ + ...t, + key: parent ? `${parent}-${t.id}` : t.id, + group: t.id, + groupKey: 'id', + transactions: [t], + children: [], + parent, + })); + } + const byProperty = groupBy(transactions, group.property); + const rows = orderBy( + Object.entries(byProperty).map(([groupKey, groupTransactions]) => { + const key = parent ? `${parent}-${groupKey}` : groupKey; + return { + key: key, + group: groupKey, + groupKey: group.property, + parent, + transactions: groupTransactions, + children: makeGroups(groupTransactions, groupIndex + 1, key), + type: { uniqueTypes: uniq(groupTransactions.map(t => t.type)).length }, + date: [min(groupTransactions.map(t => t.date)), max(groupTransactions.map(t => t.date))], + origin: { uniqueOrigins: uniq(groupTransactions.map(t => t.origin)).length }, + recipient: { uniqueRecipients: uniq(groupTransactions.map(t => t.recipient)).length }, + currency: { uniqueCurrencies: uniq(groupTransactions.map(t => t.currency)).length }, + amountEur: { + totalAmount: sumBy(groupTransactions, 'amountEur'), + averageAmount: averageBy(groupTransactions, 'amountEur'), + }, + amountUsd: { + totalAmount: sumBy(groupTransactions, 'amountUsd'), + averageAmount: averageBy(groupTransactions, 'amountUsd'), + }, + paymentMethod: { uniquePaymentMethods: uniq(groupTransactions.map(t => t.paymentMethod)).length }, + __property: (groupTransactions[0] as any)[group.property], + } as TransactionRow & { __property: any }; + }), + '__property', + group.sorting + ); + return rows; + } + + const roots = makeGroups(data, 0, null); + + const allRows: TransactionRow[] = []; + function traverse(rows: TransactionRow[]) { + for (const row of rows) { + allRows.push(row); + traverse(row.children); + } + } + traverse(roots); + + return allRows; +} + +function averageBy(transactions: Transaction[], property: 'amountEur' | 'amountUsd'): number { + if (transactions.length === 0) { + return 0; + } + const total = sumBy(transactions, property); + return total / transactions.length; +} diff --git a/pages/table/grouped-table/grouped-table-update-query.ts b/pages/table/grouped-table/grouped-table-update-query.ts new file mode 100644 index 0000000000..1efab352bc --- /dev/null +++ b/pages/table/grouped-table/grouped-table-update-query.ts @@ -0,0 +1,128 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PropertyFilterProps } from '~components'; +import { ItemSelectionTree } from '~components/table/selection/utils'; + +import { TransactionRow } from './grouped-table-common'; + +export function findSelectionIds({ + allPageItems: items, + selectionInverted, + selectedItems, + trackBy, + getItemChildren: getChildren, +}: { + allPageItems: readonly TransactionRow[]; + selectionInverted: boolean; + selectedItems: TransactionRow[]; + trackBy: (row: TransactionRow) => string; + getItemChildren?: (row: TransactionRow) => TransactionRow[]; +}) { + if (!getChildren) { + throw new Error('Missing getItemChildren'); + } + + const isComplete = () => true; + const treeProps = { rootItems: items, trackBy, getChildren, isComplete }; + const selectionTree = new ItemSelectionTree(selectionInverted, selectedItems, treeProps); + const allIds: string[] = []; + const allGroups: string[] = []; + + function traverseItem(item: TransactionRow) { + if (selectionTree.isItemSelected(item)) { + if (typeof item.type === 'string') { + allIds.push(item.group); + } + allGroups.push(item.group); + } + getChildren!(item).forEach(traverseItem); + } + items.forEach(traverseItem); + + return [allIds, allGroups]; +} + +export function createWysiwygQuery({ + allPageItems: items, + selectionInverted, + selectedItems, + propertyFilterProps: { query: filter }, + getItemChildren, +}: { + allPageItems: readonly TransactionRow[]; + selectionInverted: boolean; + selectedItems: TransactionRow[]; + propertyFilterProps: PropertyFilterProps; + getItemChildren?: (row: TransactionRow) => TransactionRow[]; +}): string { + if (!getItemChildren) { + throw new Error('Missing getItemChildren'); + } + + const whereTokens: string[] = []; + + function joinTokens(tokens: string[], operation: string) { + tokens = tokens.filter(Boolean); + if (tokens.length === 0) { + return ''; + } + if (tokens.length === 1) { + return tokens[0]; + } + return `(${tokens.join(' ' + operation.toUpperCase() + ' ')})`; + } + + if (filter.tokens.length > 0) { + const filterTokens: string[] = []; + for (const token of filter.tokens) { + filterTokens.push(`${token.propertyKey} ${token.operator} ${JSON.stringify(token.value)}`); + } + whereTokens.push(joinTokens(filterTokens, filter.operation)); + } + + if (selectedItems.length > 0) { + const generateQuery = (items: readonly TransactionRow[], isParentSelected: boolean): string[] => { + const itemTokens: string[] = []; + + for (const item of items) { + const isSelected = !!selectedItems.find(selected => selected.key === item.key); + const token = `${item.groupKey} ${isParentSelected ? '!=' : '='} ${JSON.stringify(item.group)}`; + if (isSelected) { + const childrenTokens = joinTokens( + generateQuery(getItemChildren(item), !isParentSelected), + isParentSelected ? 'OR' : 'AND' + ); + if (childrenTokens) { + itemTokens.push(joinTokens([token, childrenTokens], isParentSelected ? 'OR' : 'AND')); + } else { + itemTokens.push(token); + } + } else { + itemTokens.push(...generateQuery(getItemChildren(item), isParentSelected)); + } + } + + return itemTokens; + }; + + const selectionTokens = joinTokens(generateQuery(items, selectionInverted), selectionInverted ? 'AND' : 'OR'); + + whereTokens.push(selectionTokens); + } + + const whereClause = whereTokens.length > 0 ? ` WHERE ${joinTokens(whereTokens, 'AND')}` : ''; + return `UPDATE transactions SET reviewed = true${whereClause}`; +} + +export function createIdsQuery(selectedIds: string[]): string { + const limit = 100; + let whereClause = selectedIds + .slice(0, limit) + .map(id => `id = "${id}"`) + .join(' AND '); + if (selectedIds.length > limit) { + whereClause += ` AND ...${selectedIds.length - limit} more`; + } + return `UPDATE transactions SET reviewed = true WHERE ${whereClause}`; +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index f56b36b22b..9061450fac 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -17552,7 +17552,7 @@ the default browser context menu behavior.", { "cancelable": false, "description": "Fired when a user interaction triggers a change in the list of selected items. -The event \`detail\` contains the current list of \`selectedItems\`.", +The event \`detail\` contains the new state for \`selectedItems\` (and \`selectionInverted\`, when \`selectionType="group"\`).", "detailInlineType": { "name": "TableProps.SelectionChangeDetail", "properties": [ @@ -17561,6 +17561,11 @@ The event \`detail\` contains the current list of \`selectedItems\`.", "optional": false, "type": "Array", }, + { + "name": "selectionInverted", + "optional": true, + "type": "boolean", + }, ], "type": "object", }, @@ -18059,17 +18064,28 @@ the table items array is empty.", }, { "defaultValue": "[]", - "description": "List of selected items.", + "description": "List of selected items. + +When \`selectionType="group"\` the \`selectedItems\` represents selection tree and requires \`selectionInverted\` for completeness. +For example, the combination \`selectionInverted=true\` and \`selectedItems=[{ id: "1" }]\` means select all but the one with \`id="1"\`.", "name": "selectedItems", "optional": true, "type": "ReadonlyArray", }, { - "description": "Specifies the selection type (\`'single' | 'multi'\`).", + "defaultValue": "false", + "description": "Specifies if select all was used when \`selectionType="group"\`.", + "name": "selectionInverted", + "optional": true, + "type": "boolean", + }, + { + "description": "Specifies the selection type (\`'single' | 'multi' | 'group\`).", "inlineType": { "name": "TableProps.SelectionType", "type": "union", "values": [ + "group", "single", "multi", ], diff --git a/src/table/__tests__/a11y.test.tsx b/src/table/__tests__/a11y.test.tsx index 5b31974a4e..3db58e633a 100644 --- a/src/table/__tests__/a11y.test.tsx +++ b/src/table/__tests__/a11y.test.tsx @@ -173,7 +173,7 @@ describe('labels', () => { totalItemsCount, renderAriaLive: ({ totalItemsCount, visibleItemsCount }) => `${totalItemsCount} ${visibleItemsCount}`, expandableRows: { - getItemChildren: item => [{ ...item, id: item.id * 100 }], + getItemChildren: item => (item.id < 10000 ? [{ ...item, id: item.id * 100 }] : []), isItemExpandable: item => item.id < 100, expandedItems: defaultItems, onExpandableItemToggle: () => {}, diff --git a/src/table/__tests__/group-selection.test.tsx b/src/table/__tests__/group-selection.test.tsx new file mode 100644 index 0000000000..dda6d59c90 --- /dev/null +++ b/src/table/__tests__/group-selection.test.tsx @@ -0,0 +1,603 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useState } from 'react'; +import { render } from '@testing-library/react'; + +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper, { TableWrapper } from '../../../lib/components/test-utils/dom'; + +interface Item { + id: string; + children?: Item[]; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [{ header: 'id', cell: item => item.id }]; + +const items: Item[] = [ + { + id: '1', + children: [ + { id: '1.1' }, + { id: '1.2' }, + { + id: '1.3', + children: [{ id: '1.3.1' }, { id: '1.3.2' }], + }, + ], + }, + { + id: '2', + children: [{ id: '2.1' }, { id: '2.2' }], + }, + { id: '3', children: [{ id: '3.1' }] }, +]; + +function InteractiveTable(tableProps: TableProps) { + const [selectionInverted, setSelectionInverted] = useState(tableProps.selectionInverted ?? false); + const [selectedItems, setSelectedItems] = useState(tableProps.selectedItems); + const [expandedItems, setExpandedItems] = useState(tableProps.expandableRows?.expandedItems ?? []); + const expandableRows: TableProps['expandableRows'] = tableProps.expandableRows + ? { + ...tableProps.expandableRows, + expandedItems, + onExpandableItemToggle: event => { + if (event.detail.expanded) { + setExpandedItems(prev => [...prev, event.detail.item]); + } else { + setExpandedItems(prev => prev.filter(item => item.id !== event.detail.item.id)); + } + }, + } + : undefined; + return ( + { + setSelectionInverted(!!event.detail.selectionInverted); + setSelectedItems(event.detail.selectedItems); + tableProps.onSelectionChange?.(event); + }} + renderLoaderPending={({ item }) => (item ? `${item.id}-loader` : 'root-loader')} + renderLoaderLoading={({ item }) => (item ? `${item.id}-loader` : 'root-loader')} + renderLoaderError={({ item }) => (item ? `${item.id}-loader` : 'root-loader')} + /> + ); +} + +function renderTable(tableProps: Partial, selectedItems?: string[], expandedItems?: string[]) { + const makeProps = ( + tableProps: Partial, + selectedItems?: string[], + expandedItems?: string[] + ): TableProps => ({ + items, + columnDefinitions, + selectionType: 'group', + trackBy: 'id', + selectionInverted: selectedItems?.includes('ALL'), + selectedItems: selectedItems && selectedItems.filter(id => id !== 'ALL').map(id => ({ id })), + expandableRows: expandedItems + ? { + getItemChildren: item => item.children ?? [], + isItemExpandable: item => !!item.children, + expandedItems: expandedItems.map(id => ({ id })), + onExpandableItemToggle: () => {}, + } + : undefined, + ...tableProps, + }); + const { container, rerender } = render(); + return { + wrapper: createWrapper(container).findTable()!, + rerender: (tableProps: Partial, selectedItems?: string[], expandedItems?: string[]) => + rerender(), + }; +} + +function getTableSelection(tableWrapper: TableWrapper) { + const selectionState: Record = {}; + const getInputState = (input: HTMLInputElement): 'empty' | 'indeterminate' | 'checked' => { + return input.indeterminate ? 'indeterminate' : input.checked ? 'checked' : 'empty'; + }; + + const selectAllInput = tableWrapper.findSelectAllTrigger()!.find('input')!.getElement(); + selectionState.ALL = getInputState(selectAllInput); + + for (let i = 0; i < tableWrapper.findRows().length; i++) { + const key = tableWrapper.findBodyCell(i + 1, 2)!.getElement().textContent!; + const input = tableWrapper + .findRowSelectionArea(i + 1)! + .find('input')! + .getElement(); + selectionState[key] = getInputState(input); + } + + return selectionState; +} + +function clickRow(tableWrapper: TableWrapper, index: number) { + tableWrapper.findRowSelectionArea(index)?.find('input')!.click(); +} +function shiftClickRow(tableWrapper: TableWrapper, index: number) { + const input = tableWrapper.findRowSelectionArea(index)?.find('input'); + input?.fireEvent(new MouseEvent('mousedown', { shiftKey: true, bubbles: true })); + input?.fireEvent(new MouseEvent('click', { shiftKey: true, bubbles: true })); + input?.fireEvent(new MouseEvent('mouseup', { shiftKey: false, bubbles: true })); +} + +test('selects all items one by one and makes select-all indeterminate and then checked', () => { + const { wrapper } = renderTable({}); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'empty', + '1': 'empty', + '2': 'empty', + '3': 'empty', + }); + + wrapper.findRowSelectionArea(1)!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'checked', + '2': 'empty', + '3': 'empty', + }); + + wrapper.findRowSelectionArea(2)!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'checked', + '2': 'checked', + '3': 'empty', + }); + + wrapper.findRowSelectionArea(3)!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'checked', + '1': 'checked', + '2': 'checked', + '3': 'checked', + }); +}); + +test('selects all items using select-all when no items selected', () => { + const { wrapper } = renderTable({}); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'empty', + '1': 'empty', + '2': 'empty', + '3': 'empty', + }); + + wrapper.findSelectAllTrigger()!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'checked', + '1': 'checked', + '2': 'checked', + '3': 'checked', + }); +}); + +test('selects all items using select-all when some items selected', () => { + const { wrapper } = renderTable({}, ['2', '3']); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'empty', + '2': 'checked', + '3': 'checked', + }); + + wrapper.findSelectAllTrigger()!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'checked', + '1': 'checked', + '2': 'checked', + '3': 'checked', + }); +}); + +test('unselects all items using select-all when all items selected', () => { + const { wrapper } = renderTable({}, ['ALL']); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'checked', + '1': 'checked', + '2': 'checked', + '3': 'checked', + }); + + wrapper.findSelectAllTrigger()!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'empty', + '1': 'empty', + '2': 'empty', + '3': 'empty', + }); +}); + +describe('With progressive loading', () => { + test.each(['pending', 'loading', 'error'] as const)( + 'progressive loader with status=%s selection state is derived from parent selection choice', + status => { + const { wrapper } = renderTable({ getLoadingStatus: () => status }, []); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'empty', + '1': 'empty', + '2': 'empty', + '3': 'empty', + 'root-loader': 'empty', + }); + + wrapper.findRowSelectionArea(1)!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'checked', + '2': 'empty', + '3': 'empty', + 'root-loader': 'empty', + }); + + wrapper.findSelectAllTrigger()!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'checked', + '1': 'checked', + '2': 'checked', + '3': 'checked', + 'root-loader': 'checked', + }); + + wrapper.findRowSelectionArea(1)!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'empty', + '2': 'checked', + '3': 'checked', + 'root-loader': 'checked', + }); + } + ); + + test.each([false, true])( + 'selection is lifted when progressive loading status="finished", selectionInverted=%s', + selectionInverted => { + const { wrapper } = renderTable({ getLoadingStatus: () => 'finished' }, selectionInverted ? ['ALL'] : []); + const empty = !selectionInverted ? 'empty' : 'checked'; + const checked = !selectionInverted ? 'checked' : 'empty'; + + expect(getTableSelection(wrapper)).toEqual({ + ALL: empty, + '1': empty, + '2': empty, + '3': empty, + }); + + wrapper.findRowSelectionArea(1)!.click(); + wrapper.findRowSelectionArea(2)!.click(); + wrapper.findRowSelectionArea(3)!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: checked, + '1': checked, + '2': checked, + '3': checked, + }); + } + ); + + test.each([false, true])( + 'selection is not lifted when progressive loading status!="finished", selectionInverted=%s', + selectionInverted => { + const status = (['pending', 'loading', 'error'] as const)[Math.floor(Math.random() * 3)]; + const { wrapper } = renderTable({ getLoadingStatus: () => status }, selectionInverted ? ['ALL'] : []); + const empty = !selectionInverted ? 'empty' : 'checked'; + const checked = !selectionInverted ? 'checked' : 'empty'; + + expect(getTableSelection(wrapper)).toEqual({ + ALL: empty, + '1': empty, + '2': empty, + '3': empty, + 'root-loader': empty, + }); + + wrapper.findRowSelectionArea(1)!.click(); + wrapper.findRowSelectionArea(2)!.click(); + wrapper.findRowSelectionArea(3)!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': checked, + '2': checked, + '3': checked, + 'root-loader': empty, + }); + } + ); +}); + +describe('With expandable rows', () => { + test.each([false, true])( + 'selection is preserved when expandable state changes, selectionInverted=%s', + selectionInverted => { + const { wrapper } = renderTable({}, selectionInverted ? ['ALL'] : [], []); + const empty = !selectionInverted ? 'empty' : 'checked'; + const checked = !selectionInverted ? 'checked' : 'empty'; + + expect(getTableSelection(wrapper)).toEqual({ + ALL: empty, + '1': empty, + '2': empty, + '3': empty, + }); + + wrapper.findRowSelectionArea(1)!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': checked, + '2': empty, + '3': empty, + }); + + wrapper.findExpandToggle(1)!.click(); + wrapper.findRowSelectionArea(2)!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'indeterminate', + '1.1': empty, + '1.2': checked, + '1.3': checked, + '2': empty, + '3': empty, + }); + + wrapper.findExpandToggle(1)!.click(); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'indeterminate', + '2': empty, + '3': empty, + }); + } + ); + + test.each([false, true])('selection state is lifted, selectionInverted=%s', selectionInverted => { + const { wrapper } = renderTable({}, selectionInverted ? ['ALL'] : [], ['1', '1.3']); + const empty = !selectionInverted ? 'empty' : 'checked'; + const checked = !selectionInverted ? 'checked' : 'empty'; + + wrapper.findRowSelectionArea(2)!.click(); // click 1.1 + wrapper.findRowSelectionArea(3)!.click(); // click 1.2 + wrapper.findRowSelectionArea(5)!.click(); // click 1.3.1 + wrapper.findRowSelectionArea(7)!.click(); // click 2 + wrapper.findRowSelectionArea(8)!.click(); // click 3 + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'indeterminate', + '1.1': checked, + '1.2': checked, + '1.3': 'indeterminate', + '1.3.1': checked, + '1.3.2': empty, + '2': checked, + '3': checked, + }); + + wrapper.findRowSelectionArea(6)!.click(); // click 1.3.2 + expect(getTableSelection(wrapper)).toEqual({ + ALL: checked, + '1': checked, + '1.1': checked, + '1.2': checked, + '1.3': checked, + '1.3.1': checked, + '1.3.2': checked, + '2': checked, + '3': checked, + }); + }); + + test('selecting a parent makes all children selection consistent, selectionInverted=false', () => { + const { wrapper } = renderTable({}, [], ['1']); + + // Selecting intermediate group when group is not self selected. + wrapper.findRowSelectionArea(2)!.click(); // click 1.1 + wrapper.findRowSelectionArea(1)!.click(); // click 1 + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'checked', + '1.1': 'checked', + '1.2': 'checked', + '1.3': 'checked', + '2': 'empty', + '3': 'empty', + }); + + // Selecting intermediate group when group is self selected. + wrapper.findRowSelectionArea(2)!.click(); // click 1.1 + wrapper.findRowSelectionArea(1)!.click(); // click 1 + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'checked', + '1.1': 'checked', + '1.2': 'checked', + '1.3': 'checked', + '2': 'empty', + '3': 'empty', + }); + }); + + test('selecting a parent makes all children selection consistent, selectionInverted=true', () => { + const { wrapper } = renderTable({}, ['ALL'], ['1']); + + // Selecting intermediate group when group is not self selected. + wrapper.findRowSelectionArea(2)!.click(); // click 1.1 + wrapper.findRowSelectionArea(1)!.click(); // click 1 + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'checked', + '1': 'checked', + '1.1': 'checked', + '1.2': 'checked', + '1.3': 'checked', + '2': 'checked', + '3': 'checked', + }); + + // Selecting intermediate group when group is self selected. + wrapper.findRowSelectionArea(2)!.click(); // click 1.1 + wrapper.findRowSelectionArea(1)!.click(); // click 1 + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'checked', + '1': 'checked', + '1.1': 'checked', + '1.2': 'checked', + '1.3': 'checked', + '2': 'checked', + '3': 'checked', + }); + }); +}); + +describe('Shift selection', () => { + test.each([false, true])('shift selection on top level, selectionInverted=%s', selectionInverted => { + const items = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }, { id: '6' }, { id: '7' }]; + const { wrapper } = renderTable({ items }, selectionInverted ? ['ALL'] : []); + const empty = !selectionInverted ? 'empty' : 'checked'; + const checked = !selectionInverted ? 'checked' : 'empty'; + + clickRow(wrapper, 3); + shiftClickRow(wrapper, 5); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': empty, + '2': empty, + '3': checked, + '4': checked, + '5': checked, + '6': empty, + '7': empty, + }); + + clickRow(wrapper, 5); + shiftClickRow(wrapper, 7); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': empty, + '2': empty, + '3': checked, + '4': checked, + '5': checked, + '6': checked, + '7': checked, + }); + + clickRow(wrapper, 1); + shiftClickRow(wrapper, 4); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': empty, + '2': empty, + '3': empty, + '4': empty, + '5': checked, + '6': checked, + '7': checked, + }); + }); + + test('item can be selected when the first click is done with shift', () => { + const items = [{ id: '1' }, { id: '2' }, { id: '3' }]; + const { wrapper } = renderTable({ items }, []); + + shiftClickRow(wrapper, 2); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'empty', + '2': 'checked', + '3': 'empty', + }); + }); + + test('shift selection is not performed when last item is no longer present', () => { + const items = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }]; + const { wrapper, rerender } = renderTable({ items }, []); + + clickRow(wrapper, 3); + rerender({ items: items.filter(item => item.id !== '3') }, ['3']); + shiftClickRow(wrapper, 4); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'empty', + '2': 'empty', + '4': 'empty', + '5': 'checked', + }); + }); + + test.each([false, true])('shift selection on nested items, selectionInverted=%s', selectionInverted => { + const { wrapper } = renderTable({}, selectionInverted ? ['ALL'] : [], ['1']); + const empty = !selectionInverted ? 'empty' : 'checked'; + const checked = !selectionInverted ? 'checked' : 'empty'; + + clickRow(wrapper, 3); + shiftClickRow(wrapper, 4); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'indeterminate', + '1.1': empty, + '1.2': checked, + '1.3': checked, + '2': empty, + '3': empty, + }); + + clickRow(wrapper, 3); + shiftClickRow(wrapper, 2); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': checked, + '1.1': checked, + '1.2': checked, + '1.3': checked, + '2': empty, + '3': empty, + }); + + clickRow(wrapper, 3); + shiftClickRow(wrapper, 4); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'indeterminate', + '1.1': checked, + '1.2': empty, + '1.3': empty, + '2': empty, + '3': empty, + }); + }); + + test('shift selection across levels is not allowed', () => { + const { wrapper } = renderTable({}, [], ['2']); + + clickRow(wrapper, 1); + shiftClickRow(wrapper, 5); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'checked', + '2': 'empty', + '2.1': 'empty', + '2.2': 'empty', + '3': 'empty', + }); + + clickRow(wrapper, 3); + shiftClickRow(wrapper, 1); + expect(getTableSelection(wrapper)).toEqual({ + ALL: 'indeterminate', + '1': 'checked', + '2': 'indeterminate', + '2.1': 'checked', + '2.2': 'empty', + '3': 'empty', + }); + }); +}); diff --git a/src/table/__tests__/selection.test.tsx b/src/table/__tests__/selection.test.tsx index 0e451189d8..7df9b34aad 100644 --- a/src/table/__tests__/selection.test.tsx +++ b/src/table/__tests__/selection.test.tsx @@ -1,5 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + import * as React from 'react'; import { render } from '@testing-library/react'; @@ -27,18 +28,12 @@ const items: Item[] = [ ]; function renderTable(tableProps: Partial) { - const props: TableProps = { - items: items, - columnDefinitions: columnDefinitions, - ...tableProps, - }; - const { container, rerender, getByTestId, queryByTestId } = render(
); + const props: TableProps = { items, columnDefinitions, ...tableProps }; + const { container, rerender } = render(
); const wrapper = createWrapper(container).findTable()!; return { wrapper, rerender: (extraProps: Partial) => rerender(
), - getByTestId, - queryByTestId, }; } @@ -60,7 +55,7 @@ test('does not render selection controls when selectionType is not set', () => { expect(wrapper.findRowSelectionArea(1)).toBeFalsy(); }); -test.each(['single', 'multi', undefined])( +test.each(['single', 'multi', 'group', undefined])( 'Table headers with selectionType=%s are marked as columns for a11y', (selectionType: TableProps['selectionType']) => { const { wrapper: tableWrapper } = renderTable({ selectionType }); @@ -81,10 +76,13 @@ describe('Selection controls` labelling', () => { `${item.name} is ${selectedItems.indexOf(item) < 0 ? 'not ' : ''}selected`, }; - test('puts selectionGroupLabel and allItemsSelectionLabel on selectAll checkbox', () => { - tableWrapper = renderTable({ selectionType: 'multi', selectedItems: [items[0]], ariaLabels }).wrapper; - expect(tableWrapper.findSelectAllTrigger()?.getElement()).toHaveAttribute('aria-label', '1 item selected'); - }); + test.each(['multi', 'group'] as const)( + 'puts selectionGroupLabel and allItemsSelectionLabel on selectAll checkbox, selectionType=%s', + selectionType => { + tableWrapper = renderTable({ selectionType, selectedItems: [items[0]], ariaLabels }).wrapper; + expect(tableWrapper.findSelectAllTrigger()?.getElement()).toHaveAttribute('aria-label', '1 item selected'); + } + ); test('puts selectionGroupLabel on single selection column header', () => { tableWrapper = renderTable({ selectionType: 'single', ariaLabels }).wrapper; @@ -93,8 +91,8 @@ describe('Selection controls` labelling', () => { ); }); - describe.each(['single', 'multi'])( - '%s', + describe.each(['single', 'multi', 'group'])( + 'selectionType=%s', (selectionType: TableProps['selectionType']) => { test('leaves the controls without labels, when ariaLabels is omitted', () => { tableWrapper = renderTable({ selectionType }).wrapper; @@ -192,7 +190,7 @@ describe('Select all checkbox', () => { }); // Some other components may need this click, for example, popover (AWSUI-7864) -test.each(['single', 'multi'])( +test.each(['single', 'multi', 'group'])( 'Should propagate click event with selectionType=%s', (selectionType: TableProps['selectionType']) => { const { wrapper: tableWrapper } = renderTable({ selectionType }); @@ -353,13 +351,13 @@ describe('Row click event', () => { }); }); -describe('selection component with trackBy', function () { +describe.each(['multi', 'group'] as const)('selection component with trackBy, selectionType=%s', selectionType => { let tableWrapper: TableWrapper; let rerender: (props: Partial) => void; beforeEach(() => { const result = renderTable({ selectedItems: [{ name: items[0].name, id: items[1].id }], - selectionType: 'multi', + selectionType, isItemDisabled: item => item === items[1], trackBy: 'name', }); diff --git a/src/table/expandable-rows/expandable-rows-utils.ts b/src/table/expandable-rows/expandable-rows-utils.ts index caead00e36..09b4a9c2e9 100644 --- a/src/table/expandable-rows/expandable-rows-utils.ts +++ b/src/table/expandable-rows/expandable-rows-utils.ts @@ -45,19 +45,23 @@ export function useExpandableTableProps({ if (isExpandable) { const visibleItems = new Array(); - const traverse = (item: T, detail: Omit, 'children'>) => { + const traverse = (item: T, detail: Omit, 'children'>, visible: boolean) => { const children = expandableRows.getItemChildren(item); itemToDetail.set(item, { ...detail, children }); - visibleItems.push(item); - if (expandedSet.has(item)) { - children.forEach((child, index) => - traverse(child, { level: detail.level + 1, setSize: children.length, posInSet: index + 1, parent: item }) - ); + if (visible) { + visibleItems.push(item); } + children.forEach((child, index) => + traverse( + child, + { level: detail.level + 1, setSize: children.length, posInSet: index + 1, parent: item }, + visible && expandedSet.has(item) + ) + ); }; items.forEach((item, index) => - traverse(item, { level: 1, setSize: items.length, posInSet: index + 1, parent: null }) + traverse(item, { level: 1, setSize: items.length, posInSet: index + 1, parent: null }, true) ); for (let index = 0; index < visibleItems.length; index++) { diff --git a/src/table/index.tsx b/src/table/index.tsx index 394c6d5f3e..a2240edbad 100644 --- a/src/table/index.tsx +++ b/src/table/index.tsx @@ -21,6 +21,7 @@ const Table = React.forwardRef( { items = [], selectedItems = [], + selectionInverted = false, variant = 'container', contentDensity = 'comfortable', cellVerticalAlign = 'middle', @@ -93,6 +94,7 @@ const Table = React.forwardRef( const tableProps: Parameters>[0] = { items, selectedItems, + selectionInverted, variant, contentDensity, firstIndex, diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index c34dea251e..b50efbf60c 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -134,13 +134,20 @@ export interface TableProps extends BaseComponentProps { cellVerticalAlign?: 'middle' | 'top'; /** - * Specifies the selection type (`'single' | 'multi'`). + * Specifies the selection type (`'single' | 'multi' | 'group`). */ selectionType?: TableProps.SelectionType; /** * List of selected items. + * + * When `selectionType="group"` the `selectedItems` represents selection tree and requires `selectionInverted` for completeness. + * For example, the combination `selectionInverted=true` and `selectedItems=[{ id: "1" }]` means select all but the one with `id="1"`. */ selectedItems?: ReadonlyArray; + /** + * Specifies if select all was used when `selectionType="group"`. + */ + selectionInverted?: boolean; /** * Use this slot to add filtering controls to the table. @@ -269,7 +276,7 @@ export interface TableProps extends BaseComponentProps { /** * Fired when a user interaction triggers a change in the list of selected items. - * The event `detail` contains the current list of `selectedItems`. + * The event `detail` contains the new state for `selectedItems` (and `selectionInverted`, when `selectionType="group"`). */ onSelectionChange?: NonCancelableEventHandler>; @@ -482,13 +489,15 @@ export namespace TableProps { } export type VerticalAlign = 'middle' | 'top'; - export type SelectionType = 'single' | 'multi'; + export type SelectionType = 'single' | 'multi' | 'group'; export type Variant = 'container' | 'embedded' | 'borderless' | 'stacked' | 'full-page'; export interface SelectionState { selectedItems: ReadonlyArray; + selectionInverted?: boolean; } export interface SelectionChangeDetail { selectedItems: T[]; + selectionInverted?: boolean; } export type IsItemDisabled = (item: T) => boolean; export interface AriaLabels { diff --git a/src/table/internal.tsx b/src/table/internal.tsx index af0e8c1c6e..a63b884817 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -47,6 +47,7 @@ import { useProgressiveLoadingProps } from './progressive-loading/progressive-lo import { ResizeTracker } from './resizer'; import { focusMarkers, useSelection, useSelectionFocusMove } from './selection'; import { TableBodySelectionCell } from './selection/selection-cell'; +import { useGroupSelection } from './selection/use-group-selection'; import { useStickyColumns } from './sticky-columns'; import StickyHeader, { StickyHeaderRef } from './sticky-header'; import { StickyScrollbar } from './sticky-scrollbar'; @@ -76,7 +77,7 @@ const selectionColumnId = Symbol('selection-column-id'); type InternalTableProps = SomeRequired< TableProps, - 'items' | 'selectedItems' | 'variant' | 'firstIndex' | 'cellVerticalAlign' + 'items' | 'selectedItems' | 'selectionInverted' | 'variant' | 'firstIndex' | 'cellVerticalAlign' > & InternalBaseComponentProps & { __funnelSubStepProps?: InternalContainerProps['__funnelSubStepProps']; @@ -111,6 +112,7 @@ const InternalTable = React.forwardRef( loadingText, selectionType, selectedItems, + selectionInverted, isItemDisabled, ariaLabels, onSelectionChange, @@ -300,10 +302,11 @@ const InternalTable = React.forwardRef( visibleColumns, }); - const { isItemSelected, getSelectAllProps, getItemSelectionProps } = useSelection({ + const selectionProps = { items: allItems, trackBy, selectedItems, + selectionInverted, selectionType, isItemDisabled, onSelectionChange, @@ -313,9 +316,24 @@ const InternalTable = React.forwardRef( selectionGroupLabel: undefined, }, loading, + getExpandableItemProps, + getLoadingStatus, setLastUserAction, - }); - const isRowSelected = (row: TableRow) => row.type === 'data' && isItemSelected(row.item); + }; + const normalSelection = useSelection(selectionProps); + const groupSelection = useGroupSelection(selectionProps); + const { isItemSelected, getSelectAllProps, getItemSelectionProps } = + selectionType === 'group' ? groupSelection : normalSelection; + const isRowSelected = (row: TableRow) => { + if (row.type === 'data') { + return isItemSelected(row.item); + } + if (selectionType !== 'group') { + return false; + } + // Group loader is selected when its parent is selected. + return !row.item ? selectionInverted : isItemSelected(row.item); + }; if (isDevelopment) { if (resizableColumns) { @@ -606,7 +624,7 @@ const InternalTable = React.forwardRef( className={clsx(styles.row, sharedCellProps.isSelected && styles['row-selected'])} onFocus={({ currentTarget }) => { // When an element inside table row receives focus we want to adjust the scroll. - // However, that behaviour is unwanted when the focus is received as result of a click + // However, that behavior is unwanted when the focus is received as result of a click // as it causes the click to never reach the target element. if (!currentTarget.contains(getMouseDownTarget())) { stickyHeaderRef.current?.scrollToRow(currentTarget); @@ -700,6 +718,16 @@ const InternalTable = React.forwardRef( renderLoaderError, renderLoaderEmpty, }); + + const loaderSelectionProps = + getItemSelectionProps && + getSelectAllProps && + (row.item ? getItemSelectionProps(row.item) : getSelectAllProps()); + if (loaderSelectionProps) { + loaderSelectionProps.disabled = true; + loaderSelectionProps.indeterminate = false; + } + return ( loaderContent && ( - {getItemSelectionProps && ( + {selectionType === 'group' ? ( - )} + ) : selectionType ? ( + + ) : null} {visibleColumnDefinitions.map((column, colIndex) => ( = Pick< + TableProps, + 'ariaLabels' | 'items' | 'onSelectionChange' | 'selectionType' | 'trackBy' | 'getLoadingStatus' +> & + Pick, 'selectedItems' | 'selectionInverted'> & { + getExpandableItemProps: (item: T) => { level: number; children: readonly T[] }; + setLastUserAction?: (name: string) => void; + }; + +export function useGroupSelection({ + ariaLabels, + items, + onSelectionChange, + selectedItems, + selectionInverted, + selectionType, + trackBy, + getExpandableItemProps, + getLoadingStatus, + setLastUserAction, +}: SelectionOptions): { + isItemSelected: (item: T) => boolean; + getSelectAllProps?: () => SelectionProps; + getItemSelectionProps?: (item: T) => SelectionProps; +} { + // The name assigned to all controls to combine them in a single group. + const selectionControlName = useUniqueId(); + const [shiftPressed, setShiftPressed] = useState(false); + const [lastClickedItem, setLastClickedItem] = useState(null); + + if (selectionType !== 'group') { + return { isItemSelected: () => false }; + } + + const rootItems = items.filter(item => getExpandableItemProps(item).level === 1); + const getChildren = (item: T) => getExpandableItemProps(item).children; + const isComplete = (item: null | T) => !getLoadingStatus || getLoadingStatus(item) === 'finished'; + const treeProps = { rootItems, trackBy, getChildren, isComplete }; + const selectionTree = new ItemSelectionTree(selectionInverted, selectedItems, treeProps); + + // Shift-selection helpers. + const itemIndexesMap = new Map(); + items.forEach((item, i) => itemIndexesMap.set(getTrackableValue(trackBy, item), i)); + const getShiftSelectedItems = (item: T): T[] => { + const lastClickedItemIndex = lastClickedItem + ? itemIndexesMap.get(getTrackableValue(trackBy, lastClickedItem)) + : undefined; + // We use lastClickedItemIndex to determine if filtering/sorting/pagination + // made previously selected item invisible, therefore we reset state for shift-select. + if (lastClickedItemIndex !== undefined) { + const currentItemIndex = itemIndexesMap.get(getTrackableValue(trackBy, item))!; + const start = Math.min(currentItemIndex, lastClickedItemIndex); + const end = Math.max(currentItemIndex, lastClickedItemIndex); + const requestedItems = items.slice(start, end + 1); + return lastClickedItemIndex < currentItemIndex ? requestedItems : requestedItems.reverse(); + } + return [item]; + }; + + const handleToggleAll = () => { + fireNonCancelableEvent(onSelectionChange, selectionTree.toggleAll().getState()); + setLastUserAction?.('selection'); + }; + + const handleToggleItem = (item: T) => { + setLastClickedItem(item); + + const requestedItems = shiftPressed ? getShiftSelectedItems(item) : [item]; + const getLevel = (item: T) => getExpandableItemProps(item).level; + const requestedItemLevels = requestedItems.reduce((set, item) => set.add(getLevel(item)), new Set()); + // Shift-selection is only allowed on the items of the same level. + if (requestedItemLevels.size === 1) { + fireNonCancelableEvent(onSelectionChange, selectionTree.toggleSome(requestedItems).getState()); + setLastUserAction?.('selection'); + } + }; + + return { + isItemSelected: selectionTree.isItemSelected, + getSelectAllProps: (): SelectionProps => ({ + name: selectionControlName, + selectionType: 'multi', + disabled: false, + checked: selectionInverted, + indeterminate: selectionTree.isSomeItemsSelected(), + onChange: handleToggleAll, + ariaLabel: joinStrings( + ariaLabels?.selectionGroupLabel, + ariaLabels?.allItemsSelectionLabel?.({ selectedItems, selectionInverted }) + ), + }), + getItemSelectionProps: (item: T): SelectionProps => ({ + name: selectionControlName, + selectionType: 'multi', + disabled: false, + checked: selectionTree.isItemSelected(item), + indeterminate: selectionTree.isItemIndeterminate(item), + onChange: () => handleToggleItem(item), + onShiftToggle: (value: boolean) => setShiftPressed(value), + ariaLabel: joinStrings( + ariaLabels?.selectionGroupLabel, + ariaLabels?.itemSelectionLabel?.({ selectedItems, selectionInverted }, item) + ), + }), + }; +} diff --git a/src/table/selection/use-selection.ts b/src/table/selection/use-selection.ts index 9e9789e64a..9a6fba44b7 100644 --- a/src/table/selection/use-selection.ts +++ b/src/table/selection/use-selection.ts @@ -27,11 +27,17 @@ export function useSelection(options: SelectionOptions): { isItemSelected: (item: T) => boolean; getSelectAllProps?: () => SelectionProps; getItemSelectionProps?: (item: T) => SelectionProps; - setLastUserAction?: (name: string) => void; } { const singleSelectionProps = useSingleSelection(options); const multiSelectionProps = useMultiSelection(options); - return options.selectionType === 'single' ? singleSelectionProps : multiSelectionProps; + switch (options.selectionType) { + case 'single': + return singleSelectionProps; + case 'multi': + return multiSelectionProps; + default: + return { isItemSelected: () => false }; + } } function useSingleSelection({ diff --git a/src/table/selection/utils.ts b/src/table/selection/utils.ts index fcf5e29dad..75d79262c3 100644 --- a/src/table/selection/utils.ts +++ b/src/table/selection/utils.ts @@ -9,17 +9,240 @@ const SELECTION_ROOT = 'selection-root'; // A set, that compares items by their "trackables" (the results of applying `trackBy` to them) export class ItemSet { + private trackBy: TableProps.TrackBy | undefined; + private map = new Map(); + constructor(trackBy: TableProps.TrackBy | undefined, items: ReadonlyArray) { this.trackBy = trackBy; items.forEach(this.put); } - private trackBy: TableProps.TrackBy | undefined; - private map: Map = new Map(); put = (item: T) => this.map.set.call(this.map, getTrackableValue(this.trackBy, item), item); has = (item: T) => this.map.has.call(this.map, getTrackableValue(this.trackBy, item)); forEach = this.map.forEach.bind(this.map); } +type ItemKey = unknown; + +interface TreeProps { + rootItems: readonly T[]; + trackBy: TableProps.TrackBy | undefined; + getChildren: (item: T) => readonly T[]; + isComplete: (item: null | T) => boolean; +} + +const rootItemKey = Symbol('selection-tree-root'); + +export class ItemSelectionTree { + private treeProps: TreeProps; + private itemKeyToItem = new Map(); + private itemSelectionState = new Set(); + private itemProjectedSelectionState = new Set(); + private itemProjectedParentSelectionState = new Set(); + private itemProjectedIndeterminateState = new Set(); + + constructor(selectionInverted: boolean, selectedItems: readonly T[], treeProps: TreeProps) { + this.treeProps = treeProps; + + // Record input selection state as is. + if (selectionInverted) { + this.itemSelectionState.add(rootItemKey); + } + for (const item of selectedItems) { + this.itemSelectionState.add(this.getKey(item)); + } + // Populate item key to item mapping. + const traverse = (item: T) => { + this.itemKeyToItem.set(this.getKey(item), item); + treeProps.getChildren(item).forEach(traverse); + // console.log((item as any).key, (item as any).children.length, treeProps.getChildren(item).length); + }; + treeProps.rootItems.forEach(traverse); + + this.computeState(); + } + + private getKey(item: T): ItemKey { + return getTrackableValue(this.treeProps.trackBy, item); + } + + private getItemForKey(itemKey: ItemKey): null | T { + if (itemKey === rootItemKey) { + return null; + } + return this.itemKeyToItem.get(itemKey)!; + } + + private computeState() { + this.itemProjectedSelectionState = new Set(); + this.itemProjectedIndeterminateState = new Set(); + + // Transform input items tree to selection buckets. + // Selection buckets are organized in a map by level. + // Each bucket has a parent element (index=0) and might have children elements (index>=1). + const selectionBuckets = new Map(); + const createSelectionBuckets = (item: T, level: number) => { + const itemKey = this.getKey(item); + const levelBuckets = selectionBuckets.get(level) ?? []; + const children = this.treeProps.getChildren(item); + const bucket: ItemKey[] = [itemKey]; + for (const child of children) { + bucket.push(this.getKey(child)); + createSelectionBuckets(child, level + 1); + } + levelBuckets.push(bucket); + selectionBuckets.set(level, levelBuckets); + }; + // On level=0 there is a root bucket to hold the selection-inverted state. + // On level>0 there are buckets that represent selection for every item. + const rootBucket: ItemKey[] = [rootItemKey]; + for (const item of this.treeProps.rootItems) { + rootBucket.push(this.getKey(item)); + createSelectionBuckets(item, 1); + } + selectionBuckets.set(0, [rootBucket]); + + // Transform buckets map to an array of buckets where those with bigger levels come first. + const selectionBucketEntries = Array.from(selectionBuckets.entries()) + .sort(([a], [b]) => b - a) + .flatMap(([, v]) => v); + + // Normalize selection state. + for (const bucket of selectionBucketEntries) { + // Cannot normalize 1-element buckets. + if (bucket.length === 1) { + continue; + } + // Cannot optimize incomplete buckets (those where not all children are loaded). + // That is alright because the "show-more" item cannot be selected by the user, + // which means the normalization conditions are never met. + if (this.treeProps.isComplete(this.getItemForKey(bucket[0])) === false) { + continue; + } + let selectedCount = 0; + for (let i = bucket.length - 1; i >= 0; i--) { + if (this.itemSelectionState.has(bucket[i])) { + selectedCount++; + } else { + break; + } + } + // Normalize selection state when all children are selected but the parent is not. + if (selectedCount === bucket.length - 1 && !this.itemSelectionState.has(bucket[0])) { + bucket.forEach(itemKey => this.itemSelectionState.delete(itemKey)); + this.itemSelectionState.add(bucket[0]); + } + // Normalize selection state when all children and the parent are selected. + if (selectedCount === bucket.length) { + bucket.forEach(itemKey => this.itemSelectionState.delete(itemKey)); + } + } + + // Compute projected indeterminate state. + // The parent (bucket[0]) is indeterminate when any of its children (bucket[1+]) is selected or indeterminate. + for (const bucket of selectionBucketEntries) { + let indeterminate = false; + for (let i = 1; i < bucket.length; i++) { + if (this.itemSelectionState.has(bucket[i]) || this.itemProjectedIndeterminateState.has(bucket[i])) { + indeterminate = true; + break; + } + } + if (indeterminate) { + this.itemProjectedIndeterminateState.add(bucket[0]); + } + } + + // Compute projected selected state. + // An item is selected either when it is present in selection state but its parent is not selected, + // or when it is not present in selection state but its parent is selected. + // An item can be selected and indeterminate at the same time. + const setItemProjectedSelection = (item: T, isParentSelected: boolean) => { + const itemKey = this.getKey(item); + const isSelfSelected = this.itemSelectionState.has(itemKey); + const isSelected = (isSelfSelected && !isParentSelected) || (!isSelfSelected && isParentSelected); + if (isSelected) { + this.itemProjectedSelectionState.add(itemKey); + } + if (isParentSelected) { + this.itemProjectedParentSelectionState.add(itemKey); + } + this.treeProps.getChildren(item).forEach(child => setItemProjectedSelection(child, isSelected)); + }; + // The projected selection computation starts from the root pseudo-item (selection inverted state). + this.treeProps.rootItems.forEach(item => { + const isRootSelected = this.itemSelectionState.has(rootItemKey); + if (isRootSelected) { + this.itemProjectedSelectionState.add(rootItemKey); + } + setItemProjectedSelection(item, isRootSelected); + }); + } + + isItemSelected = (item: T) => this.itemProjectedSelectionState.has(this.getKey(item)); + + isItemIndeterminate = (item: T) => this.itemProjectedIndeterminateState.has(this.getKey(item)); + + isAllItemsSelected = () => + this.itemProjectedSelectionState.has(rootItemKey) && !this.itemProjectedIndeterminateState.has(rootItemKey); + + isSomeItemsSelected = () => this.itemProjectedIndeterminateState.has(rootItemKey); + + // The selection state might be different from the input selectionInverted and selectedItems + // because of the applied normalization. + getState = (): { selectionInverted: boolean; selectedItems: T[] } => { + const selectionInverted = this.itemSelectionState.has(rootItemKey); + const selectedItems: T[] = []; + for (const itemKey of Array.from(this.itemSelectionState)) { + const item = this.getItemForKey(itemKey); + if (item) { + selectedItems.push(item); + } + } + return { selectionInverted, selectedItems }; + }; + + toggleAll = (): ItemSelectionTree => { + return this.isAllItemsSelected() + ? new ItemSelectionTree(false, [], this.treeProps) + : new ItemSelectionTree(true, [], this.treeProps); + }; + + toggleSome = (requestedItems: readonly T[]): ItemSelectionTree => { + const clone = this.clone(); + const lastItemKey = clone.getKey(requestedItems[requestedItems.length - 1]); + const isParentSelected = clone.itemProjectedParentSelectionState.has(lastItemKey); + const isSelected = clone.itemProjectedSelectionState.has(lastItemKey); + const isIndeterminate = clone.itemProjectedIndeterminateState.has(lastItemKey); + const nextIsSelected = !(isSelected && !isIndeterminate); + const nextIsSelfSelected = (isParentSelected && !nextIsSelected) || (!isParentSelected && nextIsSelected); + + for (const requested of requestedItems) { + clone.unselectDeep(requested); + if (nextIsSelfSelected) { + clone.itemSelectionState.add(this.getKey(requested)); + } + } + clone.computeState(); + + return clone; + }; + + private unselectDeep = (item: T) => { + this.itemSelectionState.delete(this.getKey(item)); + this.treeProps.getChildren(item).forEach(child => this.unselectDeep(child)); + }; + + private clone(): ItemSelectionTree { + const clone = new ItemSelectionTree(false, [], this.treeProps); + clone.itemKeyToItem = new Map(this.itemKeyToItem); + clone.itemSelectionState = new Set(this.itemSelectionState); + clone.itemProjectedSelectionState = new Set(this.itemProjectedSelectionState); + clone.itemProjectedParentSelectionState = new Set(this.itemProjectedParentSelectionState); + clone.itemProjectedIndeterminateState = new Set(this.itemProjectedIndeterminateState); + return clone; + } +} + export const focusMarkers = { item: { ['data-' + SELECTION_ITEM]: 'item' }, all: { ['data-' + SELECTION_ITEM]: 'all' },