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/inline-edit-select.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 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<ItemType> = {
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<ItemType> = 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<ItemType>[] = [
{
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<string>) {
const value = currentValue ?? item.status;
return (
<Select
autoFocus={true}
expandToViewport={true}
selectedOption={statusOptions.find(option => option.value === value) ?? null}
options={statusOptions}
// Submit the chosen value straight away, without waiting for a setValue state update.
onChange={({ detail }) => submitValue(detail.selectedOption.value)}
/>
);
},
},
},
{
id: 'size',
header: 'Size (classic submit button)',
minWidth: 220,
cell: item => labelFor(sizeOptions, item.size),
editConfig: {
ariaLabel: 'Size',
editIconAriaLabel: 'editable',
editingCell(item, { currentValue, setValue }: TableProps.CellContext<string>) {
const value = currentValue ?? item.size;
return (
<Select
autoFocus={true}
expandToViewport={true}
selectedOption={sizeOptions.find(option => 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 (
<ScreenshotArea>
<Box padding="l">
<SpaceBetween size="l">
<Header variant="h1">Table inline-edit with a Select editor</Header>
<Box>
The <b>Status</b> column submits the chosen value immediately from the Select&apos;s change handler using{' '}
<code>submitValue(detail.selectedOption.value)</code>. The <b>Size</b> column uses the classic{' '}
<code>setValue</code> flow where the edit is confirmed with the submit button.
</Box>
<Table
ariaLabels={ariaLabels}
columnDefinitions={columns}
items={items}
submitEdit={handleSubmit}
variant="container"
/>
</SpaceBetween>
</Box>
</ScreenshotArea>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 \`<form>\` 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.
Expand Down
37 changes: 37 additions & 0 deletions src/table/__tests__/inline-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TableProps.CellContext<string>['submitValue'] | null>();
renderComponent(<TestComponent submitValueRef={submitValueRef} />);

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<TableProps.CellContext<string>['submitValue'] | null>();
renderComponent(<TestComponent submitValueRef={submitValueRef} />);

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(<TestComponent disableNativeForm={true} />);
expect(wrapper.find('form')).toBe(null);
Expand Down
20 changes: 16 additions & 4 deletions src/table/body-cell/inline-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ export function InlineEditor<ItemType>({
onEditEnd({ cancelled, refocusCell: refocusCell });
}

async function handleSubmit() {
if (currentEditValue === undefined) {
async function submitEditValue(valueToSubmit: Optional<any>) {
if (valueToSubmit === undefined) {
finishEdit();
return;
}

setCurrentEditLoading(true);
try {
await submitEdit(item, column, currentEditValue);
await submitEdit(item, column, valueToSubmit);
setCurrentEditLoading(false);
finishEdit();
} catch {
Expand All @@ -69,6 +69,18 @@ export function InlineEditor<ItemType>({
}
}

// 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<any>?]) {
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
Expand Down Expand Up @@ -110,7 +122,7 @@ export function InlineEditor<ItemType>({
const cellContext = {
currentValue: currentEditValue,
setValue: setCurrentEditValue,
submitValue: handleSubmit,
submitValue,
};

const FormElement = disableNativeForm ? 'div' : 'form';
Expand Down
18 changes: 16 additions & 2 deletions src/table/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ export interface TableProps<T = any> 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 `<form>` 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.
Expand Down Expand Up @@ -476,7 +478,19 @@ export namespace TableProps {
export interface CellContext<V> {
currentValue: Optional<V>;
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<T, V = any> {
Expand Down
Loading