Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions pages/table/row-grouping.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';

import Box from '~components/box';
import Header from '~components/header';
import SpaceBetween from '~components/space-between';
import StatusIndicator from '~components/status-indicator';
import Table, { TableProps } from '~components/table';

interface Employee {
id: string;
name: string;
role: string;
department: string;
status: 'active' | 'on-leave';
}

const items: Employee[] = [
{ id: '1', name: 'Alice Johnson', role: 'Frontend Engineer', department: 'Engineering', status: 'active' },
{ id: '2', name: 'Bob Smith', role: 'Backend Engineer', department: 'Engineering', status: 'on-leave' },
{ id: '3', name: 'Carol White', role: 'Engineering Manager', department: 'Engineering', status: 'active' },
{ id: '4', name: 'Dan Brown', role: 'Product Manager', department: 'Product', status: 'active' },
{ id: '5', name: 'Eve Davis', role: 'Product Designer', department: 'Product', status: 'active' },
{ id: '6', name: 'Frank Miller', role: 'Account Executive', department: 'Sales', status: 'on-leave' },
{ id: '7', name: 'Grace Lee', role: 'Sales Development Rep', department: 'Sales', status: 'active' },
];

const columnDefinitions: TableProps.ColumnDefinition<Employee>[] = [
{ id: 'name', header: 'Name', cell: item => item.name, isRowHeader: true },
{ id: 'role', header: 'Role', cell: item => item.role },
{
id: 'status',
header: 'Status',
cell: item => (
<StatusIndicator type={item.status === 'active' ? 'success' : 'stopped'}>
{item.status === 'active' ? 'Active' : 'On leave'}
</StatusIndicator>
),
},
];

const rowGrouping: TableProps.RowGrouping<Employee> = {
getGroupId: item => item.department,
renderGroupHeader: ({ groupId, items }) => (
<SpaceBetween size="xs" direction="horizontal">
<Box variant="strong">{groupId}</Box>
<Box color="text-body-secondary">({items.length})</Box>
</SpaceBetween>
),
};

export default function RowGroupingPage() {
const [selectedItems, setSelectedItems] = useState<Employee[]>([]);
return (
<Box padding="l">
<h1>Table row group headers</h1>
<SpaceBetween size="l">
<Table
header={<Header headingTagOverride="h2">Employees grouped by department</Header>}
columnDefinitions={columnDefinitions}
items={items}
trackBy="id"
selectionType="multi"
selectedItems={selectedItems}
onSelectionChange={({ detail }) => setSelectedItems(detail.selectedItems)}
rowGrouping={rowGrouping}
ariaLabels={{
selectionGroupLabel: 'Employee selection',
allItemsSelectionLabel: () => 'Select all employees',
itemSelectionLabel: (_data, item) => `Select ${item.name}`,
tableLabel: 'Employees',
}}
/>

<Table
header={<Header headingTagOverride="h2">Grouped without selection</Header>}
columnDefinitions={columnDefinitions}
items={items}
trackBy="id"
rowGrouping={rowGrouping}
ariaLabels={{ tableLabel: 'Employees without selection' }}
/>
</SpaceBetween>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -28939,6 +28939,62 @@ the table items array is empty.",
"optional": true,
"type": "boolean",
},
{
"description": "Visually groups table rows under non-selectable group header rows. Use it to organize the items
into sections without changing the flat data structure of \`items\`.

The configuration contains:
* \`getGroupId\` ((Item) => string) - Returns the group identifier for the given item. Consecutive
items that share the same identifier form a group. A group header row is rendered above the first
item of every group.
* \`renderGroupHeader\` ((TableProps.RowGroupHeaderDetail<Item>) => ReactNode) - Renders the content
of a group header row. It is called once per group with the group identifier and the list of items
belonging to the group.

The group header row spans all columns and is not selectable, editable, or expandable.
The feature is opt-in and does not change the behavior of tables that do not define it.",
"inlineType": {
"name": "TableProps.RowGrouping<T>",
"properties": [
{
"inlineType": {
"name": "(item: T) => string",
"parameters": [
{
"name": "item",
"type": "T",
},
],
"returnType": "string",
"type": "function",
},
"name": "getGroupId",
"optional": false,
"type": "(item: T) => string",
},
{
"inlineType": {
"name": "(detail: TableProps.RowGroupHeaderDetail<T>) => React.ReactNode",
"parameters": [
{
"name": "detail",
"type": "TableProps.RowGroupHeaderDetail<T>",
},
],
"returnType": "React.ReactNode",
"type": "function",
},
"name": "renderGroupHeader",
"optional": false,
"type": "(detail: TableProps.RowGroupHeaderDetail<T>) => React.ReactNode",
},
],
"type": "object",
},
"name": "rowGrouping",
"optional": true,
"type": "TableProps.RowGrouping<T>",
},
{
"defaultValue": "[]",
"description": "List of selected items.",
Expand Down Expand Up @@ -44364,6 +44420,20 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from
],
},
},
{
"description": "Returns all group header rows rendered when the \`rowGrouping\` property is used.",
"name": "findRowGroupHeaders",
"parameters": [],
"returnType": {
"isNullable": false,
"name": "Array",
"typeArguments": [
{
"name": "ElementWrapper<HTMLElement>",
},
],
},
},
{
"name": "findRows",
"parameters": [],
Expand Down Expand Up @@ -53779,6 +53849,20 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from
"name": "ElementWrapper",
},
},
{
"description": "Returns all group header rows rendered when the \`rowGrouping\` property is used.",
"name": "findRowGroupHeaders",
"parameters": [],
"returnType": {
"isNullable": false,
"name": "MultiElementWrapper",
"typeArguments": [
{
"name": "ElementWrapper",
},
],
},
},
{
"name": "findRows",
"parameters": [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,7 @@ exports[`test-utils selectors 1`] = `
"awsui_body-cell-editor_c6tup",
"awsui_body-cell_c6tup",
"awsui_empty_wih1l",
"awsui_group-header-row_wih1l",
"awsui_header-cell-ascending_1spae",
"awsui_header-cell-descending_1spae",
"awsui_header-controls_wih1l",
Expand Down
100 changes: 100 additions & 0 deletions src/table/__tests__/row-grouping.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { render } from '@testing-library/react';

import Table, { TableProps } from '../../../lib/components/table';
import createWrapper from '../../../lib/components/test-utils/dom';

interface Item {
id: number;
name: string;
group: string;
}

const columns: TableProps.ColumnDefinition<Item>[] = [
{ header: 'id', cell: item => item.id, isRowHeader: true },
{ header: 'name', cell: item => item.name },
];

const items: Item[] = [
{ id: 1, name: 'Apples', group: 'Fruit' },
{ id: 2, name: 'Oranges', group: 'Fruit' },
{ id: 3, name: 'Carrots', group: 'Vegetable' },
{ id: 4, name: 'Potatoes', group: 'Vegetable' },
{ id: 5, name: 'Bread', group: 'Bakery' },
];

const rowGrouping: TableProps.RowGrouping<Item> = {
getGroupId: item => item.group,
renderGroupHeader: ({ groupId, items }) => `${groupId} (${items.length})`,
};

function renderTable(props?: Partial<TableProps<Item>>) {
const { container } = render(<Table items={items} columnDefinitions={columns} trackBy="id" {...props} />);
return createWrapper(container).findTable()!;
}

describe('Table row grouping', () => {
test('does not render group header rows when rowGrouping is not set', () => {
const wrapper = renderTable();
expect(wrapper.findRowGroupHeaders()).toHaveLength(0);
expect(wrapper.findRows()).toHaveLength(5);
});

test('renders one group header per distinct group in order', () => {
const wrapper = renderTable({ rowGrouping });
const headers = wrapper.findRowGroupHeaders();
expect(headers).toHaveLength(3);
expect(headers.map(h => h.getElement().textContent)).toEqual(['Fruit (2)', 'Vegetable (2)', 'Bakery (1)']);
});

test('findRows excludes group header rows', () => {
const wrapper = renderTable({ rowGrouping });
expect(wrapper.findRows()).toHaveLength(5);
});

test('group header cell spans all columns (including selection column)', () => {
const wrapper = renderTable({ rowGrouping, selectionType: 'multi' });
const cell = wrapper.findRowGroupHeaders()[0].find('td')!.getElement();
// 2 data columns + 1 selection column = 3
expect(cell).toHaveAttribute('colspan', '3');
});

test('group header cell colspan matches column count without selection', () => {
const wrapper = renderTable({ rowGrouping });
const cell = wrapper.findRowGroupHeaders()[0].find('td')!.getElement();
expect(cell).toHaveAttribute('colspan', '2');
});

test('group header rows are not selectable (single spanning cell, no controls)', () => {
const wrapper = renderTable({ rowGrouping, selectionType: 'multi' });
const header = wrapper.findRowGroupHeaders()[0];
expect(header.findAll('td')).toHaveLength(1);
expect(header.findAll('input')).toHaveLength(0);
});

test('exposes the group id via data attribute', () => {
const wrapper = renderTable({ rowGrouping });
expect(wrapper.findRowGroupHeaders()[0].getElement()).toHaveAttribute('data-group-id', 'Fruit');
expect(wrapper.findRowGroupHeaders()[1].getElement()).toHaveAttribute('data-group-id', 'Vegetable');
});

test('creates separate groups for non-consecutive items with the same id', () => {
const wrapper = renderTable({
items: [
{ id: 1, name: 'Apples', group: 'Fruit' },
{ id: 2, name: 'Carrots', group: 'Vegetable' },
{ id: 3, name: 'Oranges', group: 'Fruit' },
],
rowGrouping,
});
const headers = wrapper.findRowGroupHeaders();
expect(headers.map(h => h.getElement().getAttribute('data-group-id'))).toEqual(['Fruit', 'Vegetable', 'Fruit']);
});

test('findRowGroupHeaders returns an empty array when no grouping is configured', () => {
const wrapper = renderTable();
expect(wrapper.findRowGroupHeaders()).toEqual([]);
});
});
1 change: 1 addition & 0 deletions src/table/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const Table = React.forwardRef(
},
metadata: {
expandableRows: !!props.expandableRows,
rowGrouping: !!props.rowGrouping,
progressiveLoading: !!props.getLoadingStatus,
hasSkeleton: !!props.skeleton,
skeletonTotalRows: props.skeleton?.totalRows ?? null,
Expand Down
27 changes: 27 additions & 0 deletions src/table/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,23 @@ export interface TableProps<T = any> extends BaseComponentProps {
*/
expandableRows?: TableProps.ExpandableRows<T>;

/**
* Visually groups table rows under non-selectable group header rows. Use it to organize the items
* into sections without changing the flat data structure of `items`.
*
* The configuration contains:
* * `getGroupId` ((Item) => string) - Returns the group identifier for the given item. Consecutive
* items that share the same identifier form a group. A group header row is rendered above the first
* item of every group.
* * `renderGroupHeader` ((TableProps.RowGroupHeaderDetail<Item>) => ReactNode) - Renders the content
* of a group header row. It is called once per group with the group identifier and the list of items
* belonging to the group.
*
* The group header row spans all columns and is not selectable, editable, or expandable.
* The feature is opt-in and does not change the behavior of tables that do not define it.
*/
rowGrouping?: TableProps.RowGrouping<T>;

/**
* A function that specifies the current status of loading more items. It is called once for the entire
* table with `item=null` and then for each expanded item. The function result is one of the four possible states:
Expand Down Expand Up @@ -670,6 +687,16 @@ export namespace TableProps {
totalSelectedItemsCount?: number;
}

export interface RowGrouping<T> {
getGroupId: (item: T) => string;
renderGroupHeader: (detail: RowGroupHeaderDetail<T>) => React.ReactNode;
}

export interface RowGroupHeaderDetail<T> {
groupId: string;
items: ReadonlyArray<T>;
}

export type OnExpandableItemToggle<T> = NonCancelableEventHandler<ExpandableItemToggleDetail<T>>;

export interface ExpandableItemToggleDetail<T> {
Expand Down
Loading
Loading