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
141 changes: 141 additions & 0 deletions pages/table/bulk-editable.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';

import { Box, Button, Header, Input, SpaceBetween, StatusIndicator } from '~components';
import Table, { TableProps } from '~components/table';
// WIP (AWSUI-56121): the bulk-editing controller is not yet public API. This dev page
// imports it directly to demonstrate the intended UX until it is wired into the Table
// render path (see follow-ups in the PR description).
import { useBulkEditing } from '~components/table/use-bulk-editing';

import { DistributionInfo, initialItems } from './editable-data';

const ariaLabels: TableProps.AriaLabels<DistributionInfo> = {
tableLabel: 'Distributions',
activateEditLabel: (column, item) => `Edit ${item.Id} ${column.header}`,
cancelEditLabel: column => `Cancel editing ${column.header}`,
submitEditLabel: column => `Submit edit ${column.header}`,
submittingEditText: () => 'Loading edit response',
successfulEditLabel: () => 'Edit successful',
};

export default function BulkEditablePage() {
const [items, setItems] = useState(initialItems);
const [lastCommit, setLastCommit] = useState<string>('');

const bulkEditConfig: TableProps.BulkEditConfig<DistributionInfo> = {
activateBulkEditLabel: 'Edit all rows',
submitBulkEditLabel: 'Save all changes',
cancelBulkEditLabel: 'Discard all changes',
onSubmit: async ({ changes }) => {
// Simulate a server round-trip.
await new Promise(resolve => setTimeout(resolve, 600));
setItems(prev =>
prev.map(item => {
const itemChanges = changes.filter(change => change.item.Id === item.Id);
if (itemChanges.length === 0) {
return item;
}
const updated = { ...item };
for (const change of itemChanges) {
(updated as Record<string, unknown>)[change.column.id!] = change.newValue;
}
return updated;
})
);
setLastCommit(`Committed ${changes.length} cell change(s) at ${new Date().toLocaleTimeString()}`);
},
};

// WIP: standalone controller wired to a plain <Table> with editable columns.
const bulkEditing = useBulkEditing<DistributionInfo>({
items,
trackBy: 'Id',
columnDefinitions: [],
bulkEdit: bulkEditConfig,
});

const editableColumn = (
id: keyof DistributionInfo & string,
header: string
): TableProps.ColumnDefinition<DistributionInfo> => ({
id,
header,
minWidth: 200,
cell: item => {
const rowId = item.Id;
if (!bulkEditing.isActive) {
return String(item[id] ?? '');
}
const { isDirty, value } = bulkEditing.getCellValue(rowId, id);
const current = isDirty ? String(value ?? '') : String(item[id] ?? '');
return (
<Input
value={current}
ariaLabel={`${header} for ${rowId}`}
onChange={event => bulkEditing.setCellValue(rowId, id, event.detail.value)}
/>
);
},
});

const columnDefinitions: TableProps.ColumnDefinition<DistributionInfo>[] = [
{ id: 'Id', header: 'Distribution ID', width: 180, cell: item => item.Id },
editableColumn('DomainName', 'Domain name'),
editableColumn('Origin', 'Origin'),
editableColumn('Status', 'Status'),
];

return (
<Box margin="s">
<SpaceBetween size="m">
<Header
variant="h1"
actions={
!bulkEditing.isActive ? (
<Button data-testid="start-bulk-edit" onClick={() => bulkEditing.startBulkEdit()}>
{bulkEditConfig.activateBulkEditLabel}
</Button>
) : (
<SpaceBetween size="xs" direction="horizontal">
<Button
data-testid="cancel-bulk-edit"
disabled={bulkEditing.isSubmitting}
onClick={() => bulkEditing.discardBulkEdit()}
>
{bulkEditConfig.cancelBulkEditLabel}
</Button>
<Button
data-testid="submit-bulk-edit"
variant="primary"
loading={bulkEditing.isSubmitting}
disabled={!bulkEditing.hasChanges}
onClick={() => bulkEditing.submitBulkEdit()}
>
{bulkEditConfig.submitBulkEditLabel} ({bulkEditing.dirtyCellCount})
</Button>
</SpaceBetween>
)
}
>
Bulk inline edit (WIP)
</Header>

{lastCommit ? (
<StatusIndicator type="success" data-testid="last-commit">
{lastCommit}
</StatusIndicator>
) : null}

<Table
trackBy="Id"
items={items}
columnDefinitions={columnDefinitions}
ariaLabels={ariaLabels}
bulkEdit={bulkEditConfig}
/>
</SpaceBetween>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -28442,6 +28442,60 @@ in tables with data grouping.
"optional": true,
"type": "TableProps.AriaLabels<T>",
},
{
"description": "**(WIP - AWSUI-56121)** Opt-in configuration that enables bulk inline editing, allowing
multiple editable cells to be edited simultaneously and committed together, rather than
one cell at a time. This is additive to per-column \`editConfig\`: a column must define
\`editConfig\` to participate in bulk editing.

The configuration object consists of:
* \`onSubmit\` ((detail: BulkEditSubmitDetail<T>) => Promise<void> | void) - Called when the user commits
all pending edits. Receives every changed cell as a \`BulkEditChange\`. Return a promise to keep the
table in a submitting state while the request is in progress.
* \`activateBulkEditLabel\` (optional, string) - Label for the control that enters bulk-edit mode.
* \`submitBulkEditLabel\` (optional, string) - Label for the control that commits all pending edits.
* \`cancelBulkEditLabel\` (optional, string) - Label for the control that discards all pending edits.",
"inlineType": {
"name": "TableProps.BulkEditConfig<T, unknown>",
"properties": [
{
"name": "activateBulkEditLabel",
"optional": true,
"type": "string",
},
{
"name": "cancelBulkEditLabel",
"optional": true,
"type": "string",
},
{
"inlineType": {
"name": "(detail: TableProps.BulkEditSubmitDetail<ItemType, ValueType>) => void | Promise<void>",
"parameters": [
{
"name": "detail",
"type": "TableProps.BulkEditSubmitDetail<ItemType, ValueType>",
},
],
"returnType": "void | Promise<void>",
"type": "function",
},
"name": "onSubmit",
"optional": false,
"type": "(detail: TableProps.BulkEditSubmitDetail<ItemType, ValueType>) => void | Promise<void>",
},
{
"name": "submitBulkEditLabel",
"optional": true,
"type": "string",
},
],
"type": "object",
},
"name": "bulkEdit",
"optional": true,
"type": "TableProps.BulkEditConfig<T, unknown>",
},
{
"defaultValue": "'middle'",
"description": "Determines the alignment of the content inside table cells.
Expand Down
103 changes: 103 additions & 0 deletions src/table/__tests__/use-bulk-editing.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { act, renderHook } from '../../__tests__/render-hook';
import { TableProps } from '../interfaces';
import { useBulkEditing } from '../use-bulk-editing';

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

const items: Item[] = [
{ id: 'i-1', name: 'alpha', size: 1 },
{ id: 'i-2', name: 'beta', size: 2 },
];

const columnDefinitions: TableProps.ColumnDefinition<Item>[] = [
{ id: 'name', header: 'Name', cell: item => item.name, editConfig: { editingCell: () => null } },
{ id: 'size', header: 'Size', cell: item => item.size, editConfig: { editingCell: () => null } },
];

function setup(bulkEdit?: TableProps.BulkEditConfig<Item>) {
return renderHook(() => useBulkEditing<Item>({ items, trackBy: 'id', columnDefinitions, bulkEdit }));
}

describe('useBulkEditing (WIP AWSUI-56121)', () => {
test('is disabled and inactive when no bulkEdit config is provided', () => {
const { result } = setup(undefined);
expect(result.current.enabled).toBe(false);
expect(result.current.isActive).toBe(false);

act(() => result.current.startBulkEdit());
// Cannot enter bulk-edit mode when disabled.
expect(result.current.isActive).toBe(false);
});

test('enters and exits bulk-edit mode', () => {
const { result } = setup({ onSubmit: () => {} });
expect(result.current.isActive).toBe(false);

act(() => result.current.startBulkEdit());
expect(result.current.isActive).toBe(true);
expect(result.current.hasChanges).toBe(false);

act(() => result.current.discardBulkEdit());
expect(result.current.isActive).toBe(false);
});

test('tracks dirty cells and clears them', () => {
const { result } = setup({ onSubmit: () => {} });
act(() => result.current.startBulkEdit());

act(() => result.current.setCellValue('i-1', 'name', 'ALPHA'));
act(() => result.current.setCellValue('i-2', 'size', 20));
expect(result.current.dirtyCellCount).toBe(2);
expect(result.current.hasChanges).toBe(true);
expect(result.current.getCellValue('i-1', 'name')).toEqual({ isDirty: true, value: 'ALPHA' });
expect(result.current.getCellValue('i-1', 'size')).toEqual({ isDirty: false, value: undefined });

act(() => result.current.clearCellValue('i-1', 'name'));
expect(result.current.dirtyCellCount).toBe(1);
expect(result.current.getCellValue('i-1', 'name').isDirty).toBe(false);
});

test('collectChanges resolves row ids and column ids back to items/columns', () => {
const { result } = setup({ onSubmit: () => {} });
act(() => result.current.startBulkEdit());
act(() => result.current.setCellValue('i-2', 'name', 'BETA'));

const changes = result.current.collectChanges();
expect(changes).toHaveLength(1);
expect(changes[0].item).toEqual(items[1]);
expect(changes[0].column.id).toBe('name');
expect(changes[0].newValue).toBe('BETA');
});

test('submitBulkEdit calls onSubmit with all changes and exits mode', async () => {
const onSubmit = jest.fn().mockResolvedValue(undefined);
const { result } = setup({ onSubmit });
act(() => result.current.startBulkEdit());
act(() => result.current.setCellValue('i-1', 'name', 'ALPHA'));
act(() => result.current.setCellValue('i-1', 'size', 9));

await act(async () => {
await result.current.submitBulkEdit();
});

expect(onSubmit).toHaveBeenCalledTimes(1);
const detail = onSubmit.mock.calls[0][0];
expect(detail.changes).toHaveLength(2);
expect(result.current.isActive).toBe(false);
expect(result.current.dirtyCellCount).toBe(0);
});

test('falls back to row index when trackBy is not provided', () => {
const { result } = renderHook(() =>
useBulkEditing<Item>({ items, columnDefinitions, bulkEdit: { onSubmit: () => {} } })
);
expect(result.current.getRowId(items[0], 0)).toBe('0');
expect(result.current.getRowId(items[1], 1)).toBe('1');
});
});
53 changes: 53 additions & 0 deletions src/table/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,22 @@ export interface TableProps<T = any> extends BaseComponentProps {
*/
onEditCancel?: CancelableEventHandler;

/**
* **(WIP - AWSUI-56121)** Opt-in configuration that enables bulk inline editing, allowing
* multiple editable cells to be edited simultaneously and committed together, rather than
* one cell at a time. This is additive to per-column `editConfig`: a column must define
* `editConfig` to participate in bulk editing.
*
* The configuration object consists of:
* * `onSubmit` ((detail: BulkEditSubmitDetail<T>) => Promise<void> | void) - Called when the user commits
* all pending edits. Receives every changed cell as a `BulkEditChange`. Return a promise to keep the
* table in a submitting state while the request is in progress.
* * `activateBulkEditLabel` (optional, string) - Label for the control that enters bulk-edit mode.
* * `submitBulkEditLabel` (optional, string) - Label for the control that commits all pending edits.
* * `cancelBulkEditLabel` (optional, string) - Label for the control that discards all pending edits.
*/
bulkEdit?: TableProps.BulkEditConfig<T>;

/**
* Use this property to activate advanced keyboard navigation and focusing behaviors.
* When set to `true`, table cells become navigable with arrow keys, and the entire table has a single tab stop.
Expand Down Expand Up @@ -642,6 +658,43 @@ export namespace TableProps {
newValue: ValueType
) => Promise<void> | void;

/**
* **(WIP - AWSUI-56121)** A single pending cell change collected during bulk inline editing.
*/
export interface BulkEditChange<ItemType, ValueType = unknown> {
/** The item (row) whose cell was edited. */
item: ItemType;
/** The column definition of the edited cell. */
column: ColumnDefinition<ItemType>;
/** The pending value the user entered for the cell. */
newValue: ValueType;
}

/**
* **(WIP - AWSUI-56121)** Detail object passed to `bulkEdit.onSubmit` when the user commits all pending edits.
*/
export interface BulkEditSubmitDetail<ItemType, ValueType = unknown> {
/** All cells that were changed while bulk-edit mode was active. */
changes: ReadonlyArray<BulkEditChange<ItemType, ValueType>>;
}

/**
* **(WIP - AWSUI-56121)** Configuration for the opt-in bulk inline editing mode.
*/
export interface BulkEditConfig<ItemType, ValueType = unknown> {
/**
* Called when the user commits all pending edits. Return a promise to keep the table
* in a submitting state while the request is in progress.
*/
onSubmit: (detail: BulkEditSubmitDetail<ItemType, ValueType>) => Promise<void> | void;
/** Label for the control that enters bulk-edit mode. */
activateBulkEditLabel?: string;
/** Label for the control that commits all pending edits. */
submitBulkEditLabel?: string;
/** Label for the control that discards all pending edits. */
cancelBulkEditLabel?: string;
}

export interface ColumnDisplay {
type?: 'column';
id: string;
Expand Down
Loading
Loading