From 684f11fdca4aca1cb69dd7974d32525489a650ee Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 13:58:57 +0000 Subject: [PATCH 1/2] feat: add bulk inline edit to Table (WIP) --- pages/table/bulk-editable.page.tsx | 141 ++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 54 ++++++ src/table/__tests__/use-bulk-editing.test.tsx | 103 +++++++++++ src/table/interfaces.tsx | 53 ++++++ src/table/internal.tsx | 8 +- src/table/use-bulk-editing.ts | 175 ++++++++++++++++++ 6 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 pages/table/bulk-editable.page.tsx create mode 100644 src/table/__tests__/use-bulk-editing.test.tsx create mode 100644 src/table/use-bulk-editing.ts diff --git a/pages/table/bulk-editable.page.tsx b/pages/table/bulk-editable.page.tsx new file mode 100644 index 0000000000..b7a685880b --- /dev/null +++ b/pages/table/bulk-editable.page.tsx @@ -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 = { + 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(''); + + const bulkEditConfig: TableProps.BulkEditConfig = { + 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)[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 with editable columns. + const bulkEditing = useBulkEditing({ + items, + trackBy: 'Id', + columnDefinitions: [], + bulkEdit: bulkEditConfig, + }); + + const editableColumn = ( + id: keyof DistributionInfo & string, + header: string + ): TableProps.ColumnDefinition => ({ + 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 ( + bulkEditing.setCellValue(rowId, id, event.detail.value)} + /> + ); + }, + }); + + const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'Id', header: 'Distribution ID', width: 180, cell: item => item.Id }, + editableColumn('DomainName', 'Domain name'), + editableColumn('Origin', 'Origin'), + editableColumn('Status', 'Status'), + ]; + + return ( + + +
bulkEditing.startBulkEdit()}> + {bulkEditConfig.activateBulkEditLabel} + + ) : ( + + + + + ) + } + > + Bulk inline edit (WIP) +
+ + {lastCommit && ( + + {lastCommit} + + )} + +
+ + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..d9bcce6db8 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -28442,6 +28442,60 @@ in tables with data grouping. "optional": true, "type": "TableProps.AriaLabels", }, + { + "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) => Promise | 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", + "properties": [ + { + "name": "activateBulkEditLabel", + "optional": true, + "type": "string", + }, + { + "name": "cancelBulkEditLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(detail: TableProps.BulkEditSubmitDetail) => void | Promise", + "parameters": [ + { + "name": "detail", + "type": "TableProps.BulkEditSubmitDetail", + }, + ], + "returnType": "void | Promise", + "type": "function", + }, + "name": "onSubmit", + "optional": false, + "type": "(detail: TableProps.BulkEditSubmitDetail) => void | Promise", + }, + { + "name": "submitBulkEditLabel", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "bulkEdit", + "optional": true, + "type": "TableProps.BulkEditConfig", + }, { "defaultValue": "'middle'", "description": "Determines the alignment of the content inside table cells. diff --git a/src/table/__tests__/use-bulk-editing.test.tsx b/src/table/__tests__/use-bulk-editing.test.tsx new file mode 100644 index 0000000000..a71f391d2b --- /dev/null +++ b/src/table/__tests__/use-bulk-editing.test.tsx @@ -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[] = [ + { 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) { + return renderHook(() => useBulkEditing({ 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({ items, columnDefinitions, bulkEdit: { onSubmit: () => {} } }) + ); + expect(result.current.getRowId(items[0], 0)).toBe('0'); + expect(result.current.getRowId(items[1], 1)).toBe('1'); + }); +}); diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index e00ef8fc17..484e0adb31 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -397,6 +397,22 @@ export interface TableProps 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) => Promise | 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; + /** * 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. @@ -642,6 +658,43 @@ export namespace TableProps { newValue: ValueType ) => Promise | void; + /** + * **(WIP - AWSUI-56121)** A single pending cell change collected during bulk inline editing. + */ + export interface BulkEditChange { + /** The item (row) whose cell was edited. */ + item: ItemType; + /** The column definition of the edited cell. */ + column: ColumnDefinition; + /** 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 { + /** All cells that were changed while bulk-edit mode was active. */ + changes: ReadonlyArray>; + } + + /** + * **(WIP - AWSUI-56121)** Configuration for the opt-in bulk inline editing mode. + */ + export interface BulkEditConfig { + /** + * 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) => Promise | 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; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index d58a59f1f9..5a10b2da16 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -63,6 +63,7 @@ import { } from './table-role'; import Thead, { TheadProps } from './thead'; import ToolsHeader from './tools-header'; +import { useBulkEditing } from './use-bulk-editing'; import { useCellEditing } from './use-cell-editing'; import { ColumnWidthDefinition, ColumnWidthsProvider, DEFAULT_COLUMN_WIDTH } from './use-column-widths'; import { usePreventStickyClickScroll } from './use-prevent-sticky-click-scroll'; @@ -133,6 +134,7 @@ const InternalTable = React.forwardRef( stripedRows, contentDensity, submitEdit, + bulkEdit, onEditCancel, resizableColumns, onColumnWidthsChange, @@ -196,6 +198,9 @@ const InternalTable = React.forwardRef( const stickyHeaderRef = React.useRef(null); const scrollbarRef = React.useRef(null); const { cancelEdit, ...cellEditing } = useCellEditing({ onCancel: onEditCancel, onSubmit: submitEdit }); + // WIP (AWSUI-56121): opt-in bulk inline editing controller. When `bulkEdit` is not provided + // the controller is disabled (`isActive` is always false) and has no effect on rendering. + const bulkEditing = useBulkEditing({ items: allItems, trackBy, columnDefinitions, bulkEdit }); const paginationRef = useRef({}); const filterRef = useRef({}); const preferencesRef = useRef({}); @@ -710,7 +715,8 @@ const InternalTable = React.forwardRef( const cellId = { row: rowId, col: colId }; const isEditing = cellEditing.checkEditing(cellId); const successfulEdit = cellEditing.checkLastSuccessfulEdit(cellId); - const isEditable = !!column.editConfig && !cellEditing.isLoading; + const isEditable = + !!column.editConfig && (bulkEditing.isActive || !cellEditing.isLoading); const cellExpandableProps = isExpandable && colIndex === 0 ? rowExpandableProps : undefined; const counter = column.counter?.({ diff --git a/src/table/use-bulk-editing.ts b/src/table/use-bulk-editing.ts new file mode 100644 index 0000000000..084dd88017 --- /dev/null +++ b/src/table/use-bulk-editing.ts @@ -0,0 +1,175 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useMemo, useState } from 'react'; + +import { TableProps } from './interfaces'; +import { getTrackableValue } from './utils'; + +/** + * WIP (AWSUI-56121): Bulk inline editing for Table. + * + * This hook is a v0 building block that layers a "bulk edit mode" on top of the + * existing single-cell inline-edit (`editConfig`) machinery. Instead of editing + * one cell at a time and committing immediately, the consumer can enter a mode + * where several cells become editable at once, accumulate pending ("dirty") + * changes, and commit them all together via a single callback. + * + * The hook is intentionally UI-agnostic: it only owns the mode + dirty-cell + * state. Rendering of the editable cells is expected to reuse the existing + * inline-editor pieces (`editConfig.editingCell`, `CellContext`). + */ + +/** A composite key identifying a single cell by its (tracked) row id and column id. */ +function toCellKey(rowId: string, columnId: string): string { + // Row/column ids can contain arbitrary characters; the separator is escaped to + // keep the composite key collision-free. + return `${rowId.replace(/\|/g, '||')}|:|${columnId.replace(/\|/g, '||')}`; +} + +interface UseBulkEditingProps { + items: ReadonlyArray; + trackBy?: TableProps.TrackBy; + columnDefinitions: ReadonlyArray>; + bulkEdit?: TableProps.BulkEditConfig; +} + +export function useBulkEditing({ items, trackBy, columnDefinitions, bulkEdit }: UseBulkEditingProps) { + const [isActive, setIsActive] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + // Map of cellKey -> pending value. Only cells the user actually touched are stored. + const [dirtyCells, setDirtyCells] = useState>(() => new Map()); + + const enabled = !!bulkEdit; + + const getRowId = useCallback( + (item: T, index: number): string => { + // Without `trackBy` there is no stable identity, so fall back to the row index. + if (!trackBy) { + return String(index); + } + const tracked = getTrackableValue(trackBy, item); + return tracked !== undefined && tracked !== null ? String(tracked) : String(index); + }, + [trackBy] + ); + + const startBulkEdit = useCallback(() => { + if (!enabled) { + return; + } + setDirtyCells(new Map()); + setIsActive(true); + }, [enabled]); + + const discardBulkEdit = useCallback(() => { + setDirtyCells(new Map()); + setIsActive(false); + }, []); + + const setCellValue = useCallback((rowId: string, columnId: string, value: unknown) => { + setDirtyCells(prev => { + const next = new Map(prev); + next.set(toCellKey(rowId, columnId), value); + return next; + }); + }, []); + + const clearCellValue = useCallback((rowId: string, columnId: string) => { + setDirtyCells(prev => { + if (!prev.has(toCellKey(rowId, columnId))) { + return prev; + } + const next = new Map(prev); + next.delete(toCellKey(rowId, columnId)); + return next; + }); + }, []); + + const getCellValue = useCallback( + (rowId: string, columnId: string): { isDirty: boolean; value: unknown } => { + const key = toCellKey(rowId, columnId); + return { isDirty: dirtyCells.has(key), value: dirtyCells.get(key) }; + }, + [dirtyCells] + ); + + const dirtyCellCount = dirtyCells.size; + const hasChanges = dirtyCellCount > 0; + + /** + * Reconstructs the list of pending edits into a consumer-friendly shape, + * resolving row ids and column ids back to the original item / column objects. + */ + const collectChanges = useCallback((): Array> => { + if (dirtyCells.size === 0) { + return []; + } + const itemById = new Map(); + items.forEach((item, index) => itemById.set(getRowId(item, index), item)); + const columnById = new Map>(); + columnDefinitions.forEach((column, index) => columnById.set(column.id ?? String(index), column)); + + const changes: Array> = []; + dirtyCells.forEach((value, key) => { + const [rawRow, rawColumn] = key.split('|:|'); + const rowId = rawRow.replace(/\|\|/g, '|'); + const columnId = rawColumn.replace(/\|\|/g, '|'); + const item = itemById.get(rowId); + const column = columnById.get(columnId); + if (item !== undefined && column !== undefined) { + changes.push({ item, column, newValue: value }); + } + }); + return changes; + }, [dirtyCells, items, columnDefinitions, getRowId]); + + const submitBulkEdit = useCallback(async () => { + if (!bulkEdit?.onSubmit) { + discardBulkEdit(); + return; + } + const changes = collectChanges(); + setIsSubmitting(true); + try { + await bulkEdit.onSubmit({ changes }); + setDirtyCells(new Map()); + setIsActive(false); + } finally { + setIsSubmitting(false); + } + }, [bulkEdit, collectChanges, discardBulkEdit]); + + return useMemo( + () => ({ + enabled, + isActive: enabled && isActive, + isSubmitting, + hasChanges, + dirtyCellCount, + getRowId, + startBulkEdit, + discardBulkEdit, + submitBulkEdit, + setCellValue, + clearCellValue, + getCellValue, + collectChanges, + }), + [ + enabled, + isActive, + isSubmitting, + hasChanges, + dirtyCellCount, + getRowId, + startBulkEdit, + discardBulkEdit, + submitBulkEdit, + setCellValue, + clearCellValue, + getCellValue, + collectChanges, + ] + ); +} From 9673e3e4b707341a1e696eaf5a98a681e2879c4f Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 16:21:36 +0000 Subject: [PATCH 2/2] fix: prevent React key warning on bulk inline edit dev page The bulk-editable dev page rendered {lastCommit && } where lastCommit is a string. When empty, this evaluates to '' (an empty-string text child) rather than false, which SpaceBetween flattens into a keyless child and renders inside a mapped list. React then logs a SEVERE 'unique key' console warning, which the accessibility test harness treats as a failure. Use a ternary so the falsy branch is null and no keyless child is produced. --- pages/table/bulk-editable.page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pages/table/bulk-editable.page.tsx b/pages/table/bulk-editable.page.tsx index b7a685880b..5ef3bbbfd6 100644 --- a/pages/table/bulk-editable.page.tsx +++ b/pages/table/bulk-editable.page.tsx @@ -122,11 +122,11 @@ export default function BulkEditablePage() { Bulk inline edit (WIP) - {lastCommit && ( + {lastCommit ? ( {lastCommit} - )} + ) : null}