From bc6b46ed582be146f11bda02f6267777c7f7226b Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 13:45:09 +0000 Subject: [PATCH] feat: support a Select editor for table inline-edit cells via submitValue Table inline editing already lets editConfig.editingCell render any control, including a Select/Autosuggest/Multiselect. However, cellContext.submitValue could only submit the value held in React state, so a dropdown editor that wanted to submit as soon as an option is chosen had to call setValue() and then submitValue() in the same change handler and would submit a stale value (the setValue state update has not been applied yet). submitValue now accepts an optional explicit value: submitValue(value). When a value is passed it is submitted directly, bypassing the pending setValue update, enabling a submit-on-selection experience for dropdown editors: onChange={({ detail }) => submitValue(detail.selectedOption.value)}. Calling submitValue() with no arguments keeps the existing behaviour of submitting currentValue, so the change is opt-in and fully backward compatible. - Widen TableProps.CellContext.submitValue to (value?: V) => void and document it - Update the editingCell docs for submit-on-selection usage - Add a dev page (pages/table/inline-edit-select.page.tsx) showing a Select that submits on selection alongside the classic submit-button flow - Add unit tests for explicit-value submission and explicit-undefined no-op - Refresh the table documenter snapshot --- pages/table/inline-edit-select.page.tsx | 141 ++++++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 4 +- src/table/__tests__/inline-editor.test.tsx | 37 +++++ src/table/body-cell/inline-editor.tsx | 20 ++- src/table/interfaces.tsx | 18 ++- 5 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 pages/table/inline-edit-select.page.tsx diff --git a/pages/table/inline-edit-select.page.tsx b/pages/table/inline-edit-select.page.tsx new file mode 100644 index 0000000000..9be3531b25 --- /dev/null +++ b/pages/table/inline-edit-select.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 from '~components/box'; +import Header from '~components/header'; +import Select, { SelectProps } from '~components/select'; +import SpaceBetween from '~components/space-between'; +import Table, { TableProps } from '~components/table'; + +import ScreenshotArea from '../utils/screenshot-area'; + +interface ItemType { + id: string; + name: string; + // Value edited via a Select that submits on selection. + status: string; + // Value edited via a Select that uses the classic setValue + submit-button flow. + size: string; +} + +const statusOptions: SelectProps.Option[] = [ + { label: 'Active', value: 'active' }, + { label: 'Deactivated', value: 'deactivated' }, + { label: 'Suspended', value: 'suspended' }, +]; + +const sizeOptions: SelectProps.Option[] = [ + { label: 'Small', value: 'small' }, + { label: 'Medium', value: 'medium' }, + { label: 'Large', value: 'large' }, +]; + +const labelFor = (options: SelectProps.Option[], value: string) => + options.find(option => option.value === value)?.label ?? value; + +const initialItems: ItemType[] = [ + { id: '1', name: 'Instance alpha', status: 'active', size: 'small' }, + { id: '2', name: 'Instance beta', status: 'deactivated', size: 'medium' }, + { id: '3', name: 'Instance gamma', status: 'suspended', size: 'large' }, + { id: '4', name: 'Instance delta', status: 'active', size: 'small' }, +]; + +const ariaLabels: TableProps.AriaLabels = { + tableLabel: 'Inline editable select cells', + activateEditLabel: (column, item) => `Edit ${item.name} ${column.header}`, + cancelEditLabel: column => `Cancel editing ${column.header}`, + submitEditLabel: column => `Submit edit ${column.header}`, + submittingEditText: () => 'Submitting edit', + successfulEditLabel: () => 'Edit successful', +}; + +export default function InlineEditSelectPage() { + const [items, setItems] = useState(initialItems); + + const handleSubmit: TableProps.SubmitEditFunction = async (currentItem, column, newValue) => { + // Emulate a small async round-trip like a real server call. + await new Promise(resolve => setTimeout(resolve, 300)); + const field = column.id as 'status' | 'size'; + setItems(prev => prev.map(item => (item.id === currentItem.id ? { ...item, [field]: newValue as string } : item))); + }; + + const columns: TableProps.ColumnDefinition[] = [ + { + id: 'name', + header: 'Name', + cell: item => item.name, + isRowHeader: true, + }, + { + id: 'status', + header: 'Status (submit on selection)', + minWidth: 220, + cell: item => labelFor(statusOptions, item.status), + editConfig: { + ariaLabel: 'Status', + editIconAriaLabel: 'editable', + // No native form is needed: the Select submits the chosen value immediately. + disableNativeForm: true, + editingCell(item, { currentValue, submitValue }: TableProps.CellContext) { + const value = currentValue ?? item.status; + return ( + option.value === value) ?? null} + options={sizeOptions} + // Classic flow: track the value; the user confirms with the submit button. + onChange={({ detail }) => setValue(detail.selectedOption.value ?? item.size)} + /> + ); + }, + }, + }, + ]; + + return ( + + + +
Table inline-edit with a Select editor
+ + The Status column submits the chosen value immediately from the Select's change handler using{' '} + submitValue(detail.selectedOption.value). The Size column uses the classic{' '} + setValue flow where the edit is confirmed with the submit button. + + + + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..0d691edbc4 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -28511,7 +28511,9 @@ To target individual cells use \`columnDefinitions.verticalAlign\`, that takes p The \`cellContext\` object contains the following properties: * \`cellContext.currentValue\` - State to keep track of a value in input fields while editing. * \`cellContext.setValue\` - Function to update \`currentValue\`. This should be called when the value in input field changes. - * \`cellContext.submitValue\` - Function to submit the \`currentValue\`. + * \`cellContext.submitValue\` - Function to submit the edit. Call with no arguments to submit + \`currentValue\` (text-style editors), or pass a value explicitly to submit it immediately, + e.g. from a \`Select\`/\`Autosuggest\`/\`Multiselect\` change handler (submit on selection). * \`editConfig.disableNativeForm\` (boolean) - Disables the use of a \`\` element to capture submissions inside the inline editor. If enabled, ensure that any text inputs in the editing cell submit the cell value when the Enter key is pressed, using \`cellContext.submitValue\`. * \`isRowHeader\` (boolean) - Specifies that cells in this column should be used as row headers. diff --git a/src/table/__tests__/inline-editor.test.tsx b/src/table/__tests__/inline-editor.test.tsx index 8913ed58c6..d954a47899 100644 --- a/src/table/__tests__/inline-editor.test.tsx +++ b/src/table/__tests__/inline-editor.test.tsx @@ -135,6 +135,43 @@ describe('InlineEditor', () => { }); }); + it('should submit an explicitly provided value without waiting for setValue (submit on selection)', async () => { + // Models a Select/dropdown editor that submits the chosen value straight from its change handler, + // before the asynchronous setValue update is applied. setValue is intentionally never called here, + // so currentEditValue stays undefined - yet the explicit value must still be submitted. + thereBeErrors = false; + const submitValueRef = React.createRef['submitValue'] | null>(); + renderComponent(); + + act(() => { + submitValueRef.current!('explicit value'); + }); + + await waitFor(() => { + expect(handleSubmitEdit).toHaveBeenCalled(); + expect(handleSubmitEdit.mock.lastCall!.length).toBe(3); + expect(handleSubmitEdit.mock.lastCall![2]).toBe('explicit value'); + expect(handleEditEnd).toHaveBeenCalled(); + }); + }); + + it('should not submit when submitValue is called with an explicit undefined value', async () => { + // Calling submitValue(undefined) explicitly is treated the same as "no change" and ends the edit + // without invoking the submit callback. + thereBeErrors = false; + const submitValueRef = React.createRef['submitValue'] | null>(); + renderComponent(); + + act(() => { + submitValueRef.current!(undefined); + }); + + await waitFor(() => { + expect(handleEditEnd).toHaveBeenCalled(); + }); + expect(handleSubmitEdit).not.toHaveBeenCalled(); + }); + it('should not render a form element if disableNativeForm is set', () => { const { wrapper } = renderComponent(); expect(wrapper.find('form')).toBe(null); diff --git a/src/table/body-cell/inline-editor.tsx b/src/table/body-cell/inline-editor.tsx index a728dcc28d..801ab3e762 100644 --- a/src/table/body-cell/inline-editor.tsx +++ b/src/table/body-cell/inline-editor.tsx @@ -52,15 +52,15 @@ export function InlineEditor({ onEditEnd({ cancelled, refocusCell: refocusCell }); } - async function handleSubmit() { - if (currentEditValue === undefined) { + async function submitEditValue(valueToSubmit: Optional) { + if (valueToSubmit === undefined) { finishEdit(); return; } setCurrentEditLoading(true); try { - await submitEdit(item, column, currentEditValue); + await submitEdit(item, column, valueToSubmit); setCurrentEditLoading(false); finishEdit(); } catch { @@ -69,6 +69,18 @@ export function InlineEditor({ } } + // Submits the value currently tracked in state. Used by the native form submit and the submit button. + function handleSubmit() { + submitEditValue(currentEditValue); + } + + // Exposed to `editingCell` via `cellContext.submitValue`. When called with an explicit value, that value + // is submitted directly, bypassing the asynchronous `setValue` state update. This lets discrete-choice + // editors (Select, Autosuggest, Multiselect) submit the chosen value immediately from their change handler. + function submitValue(...args: [Optional?]) { + submitEditValue(args.length === 0 ? currentEditValue : args[0]); + } + function onFormSubmit(evt: React.FormEvent) { evt.preventDefault(); // Prevents the form from navigating away evt.stopPropagation(); // Prevents any outer form elements from submitting @@ -110,7 +122,7 @@ export function InlineEditor({ const cellContext = { currentValue: currentEditValue, setValue: setCurrentEditValue, - submitValue: handleSubmit, + submitValue, }; const FormElement = disableNativeForm ? 'div' : 'form'; diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index e00ef8fc17..7d68fb823b 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -127,7 +127,9 @@ export interface TableProps extends BaseComponentProps { * The `cellContext` object contains the following properties: * * `cellContext.currentValue` - State to keep track of a value in input fields while editing. * * `cellContext.setValue` - Function to update `currentValue`. This should be called when the value in input field changes. - * * `cellContext.submitValue` - Function to submit the `currentValue`. + * * `cellContext.submitValue` - Function to submit the edit. Call with no arguments to submit + * `currentValue` (text-style editors), or pass a value explicitly to submit it immediately, + * e.g. from a `Select`/`Autosuggest`/`Multiselect` change handler (submit on selection). * * `editConfig.disableNativeForm` (boolean) - Disables the use of a `` element to capture submissions inside the inline editor. * If enabled, ensure that any text inputs in the editing cell submit the cell value when the Enter key is pressed, using `cellContext.submitValue`. * * `isRowHeader` (boolean) - Specifies that cells in this column should be used as row headers. @@ -476,7 +478,19 @@ export namespace TableProps { export interface CellContext { currentValue: Optional; setValue: (value: V | undefined) => void; - submitValue: () => void; + /** + * Submits the current inline edit. + * + * When called with no arguments, the value tracked by `setValue` is submitted. This is + * suitable for text-based editors where the value is accumulated as the user types. + * + * For editors that produce a value in a single, discrete interaction - such as a `Select`, + * `Autosuggest`, or `Multiselect` used as a dropdown - you can pass the chosen value explicitly + * to submit it immediately from the change handler, without waiting for the `setValue` state + * update to be applied. This enables a "submit on selection" experience, for example: + * `onChange={({ detail }) => submitValue(detail.selectedOption.value)}`. + */ + submitValue: (value?: V) => void; } export interface EditConfig {