Skip to content

Commit a29dbe7

Browse files
authored
feat: Allow table inline editing to be rendered without a wrapping <form> (#3563)
1 parent c615666 commit a29dbe7

File tree

4 files changed

+88
-19
lines changed

4 files changed

+88
-19
lines changed

src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17539,6 +17539,9 @@ To target individual cells use \`columnDefinitions.verticalAlign\`, that takes p
1753917539
The \`cellContext\` object contains the following properties:
1754017540
* \`cellContext.currentValue\` - State to keep track of a value in input fields while editing.
1754117541
* \`cellContext.setValue\` - Function to update \`currentValue\`. This should be called when the value in input field changes.
17542+
* \`cellContext.submitValue\` - Function to submit the \`currentValue\`.
17543+
* \`editConfig.disableNativeForm\` (boolean) - Disables the use of a \`<form>\` element to capture submissions inside the inline editor.
17544+
If enabled, ensure that any text inputs in the editing cell submit the cell value when the Enter key is pressed, using \`cellContext.submitValue\`.
1754217545
* \`isRowHeader\` (boolean) - Specifies that cells in this column should be used as row headers.
1754317546
* \`hasDynamicContent\` (boolean) - Specifies that cells in this column may have dynamic content. The contents will then be observed to update calculated column widths.
1754417547
This may have a negative performance impact, so should be used only if necessary. It has no effect if \`resizableColumns\` is set to \`true\`.

src/table/__tests__/inline-editor.test.tsx

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import * as React from 'react';
5-
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
5+
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
66

77
import { InlineEditor } from '../../../lib/components/table/body-cell/inline-editor';
88
import createWrapper from '../../../lib/components/test-utils/dom';
@@ -21,18 +21,28 @@ function renderComponent(jsx: React.ReactElement) {
2121
return { wrapper, rerender, getByTestId, queryByTestId, getByRole };
2222
}
2323

24-
const TestComponent = () => {
24+
const TestComponent = ({
25+
disableNativeForm,
26+
submitValueRef,
27+
}: {
28+
disableNativeForm?: boolean;
29+
submitValueRef?: React.MutableRefObject<TableProps.CellContext<any>['submitValue'] | null>;
30+
}) => {
2531
const column = {
2632
id: 'test',
2733
header: 'test',
2834
editConfig: {
35+
disableNativeForm,
2936
ariaLabel: 'test-input',
3037
errorIconAriaLabel: 'error-icon',
3138
constraintText: 'Requirement',
3239
validation: () => (thereBeErrors ? 'there be errors' : undefined),
33-
editingCell: (item: any, { currentValue, setValue }: TableProps.CellContext<string>) => (
34-
<input value={currentValue ?? item.test} onChange={() => setValue('test')} />
35-
),
40+
editingCell: (item: any, { currentValue, setValue, submitValue }: TableProps.CellContext<string>) => {
41+
if (submitValueRef) {
42+
submitValueRef.current = submitValue;
43+
}
44+
return <input value={currentValue ?? item.test} onChange={() => setValue('test')} />;
45+
},
3646
},
3747
cell: (item: any) => <span>{item.test}</span>,
3848
};
@@ -85,18 +95,44 @@ describe('InlineEditor', () => {
8595
expect(wrapper.find('[aria-label="error-icon"]')?.getElement()).toBeInTheDocument();
8696
});
8797

88-
it('should submit edit', () => {
98+
it.each([false, true])(
99+
'should submit edit when submit button is pressed (disableNativeForm=%s)',
100+
disableNativeForm => {
101+
thereBeErrors = false;
102+
const changeEvent = new Event('change', { bubbles: true });
103+
const { wrapper } = renderComponent(<TestComponent disableNativeForm={disableNativeForm} />);
104+
const input = wrapper.find('input')!.getElement();
105+
106+
fireEvent.click(input);
107+
108+
fireEvent(input, changeEvent);
109+
expect(wrapper.find('[aria-label="error-icon"]')?.getElement()).toBeUndefined();
110+
111+
fireEvent.click(wrapper.getElement().querySelector('[aria-label="save edit"]')!);
112+
waitFor(() => {
113+
expect(handleSubmitEdit).toHaveBeenCalled();
114+
expect(handleSubmitEdit.mock.lastCall!.length).toBe(3);
115+
});
116+
117+
expect(handleEditEnd).toHaveBeenCalled();
118+
}
119+
);
120+
121+
it('should submit edit when submitValue is called', () => {
89122
thereBeErrors = false;
123+
const submitValueRef = React.createRef<TableProps.CellContext<string>['submitValue'] | null>();
90124
const changeEvent = new Event('change', { bubbles: true });
91-
const { wrapper } = renderComponent(<TestComponent />);
125+
const { wrapper } = renderComponent(<TestComponent submitValueRef={submitValueRef} />);
92126
const input = wrapper.find('input')!.getElement();
93127

94128
fireEvent.click(input);
95129

96130
fireEvent(input, changeEvent);
97131
expect(wrapper.find('[aria-label="error-icon"]')?.getElement()).toBeUndefined();
98132

99-
fireEvent.click(wrapper.getElement().querySelector('[aria-label="save edit"]')!);
133+
act(() => {
134+
submitValueRef.current!();
135+
});
100136
waitFor(() => {
101137
expect(handleSubmitEdit).toHaveBeenCalled();
102138
expect(handleSubmitEdit.mock.lastCall!.length).toBe(3);
@@ -105,6 +141,16 @@ describe('InlineEditor', () => {
105141
expect(handleEditEnd).toHaveBeenCalled();
106142
});
107143

144+
it('should not render a form element if disableNativeForm is set', () => {
145+
const { wrapper } = renderComponent(<TestComponent disableNativeForm={true} />);
146+
expect(wrapper.find('form')).toBe(null);
147+
});
148+
149+
it('should not render a submit button if disableNativeForm is set', () => {
150+
const { wrapper } = renderComponent(<TestComponent disableNativeForm={true} />);
151+
expect(wrapper.find('button[type=submit]')).toBe(null);
152+
});
153+
108154
it('should not submit any wrapping forms', () => {
109155
thereBeErrors = false;
110156
const changeEvent = new Event('change', { bubbles: true });

src/table/body-cell/inline-editor.tsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,21 +45,14 @@ export function InlineEditor<ItemType>({
4545

4646
const focusLockRef = useRef<FocusLockRef>(null);
4747

48-
const cellContext = {
49-
currentValue: currentEditValue,
50-
setValue: setCurrentEditValue,
51-
};
52-
5348
function finishEdit({ cancelled = false, refocusCell = true }: Partial<OnEditEndOptions> = {}) {
5449
if (!cancelled) {
5550
setCurrentEditValue(undefined);
5651
}
5752
onEditEnd({ cancelled, refocusCell: refocusCell });
5853
}
5954

60-
async function onSubmitClick(evt: React.FormEvent) {
61-
evt.preventDefault(); // Prevents the form from navigating away
62-
evt.stopPropagation(); // Prevents any outer form elements from submitting
55+
async function handleSubmit() {
6356
if (currentEditValue === undefined) {
6457
finishEdit();
6558
return;
@@ -79,6 +72,12 @@ export function InlineEditor<ItemType>({
7972
}
8073
}
8174

75+
function onFormSubmit(evt: React.FormEvent) {
76+
evt.preventDefault(); // Prevents the form from navigating away
77+
evt.stopPropagation(); // Prevents any outer form elements from submitting
78+
handleSubmit();
79+
}
80+
8281
function onCancel({ reFocusEditedCell = true } = {}) {
8382
if (currentEditLoading) {
8483
return;
@@ -108,8 +107,17 @@ export function InlineEditor<ItemType>({
108107
errorIconAriaLabel,
109108
constraintText,
110109
editingCell,
110+
disableNativeForm,
111111
} = column.editConfig!;
112112

113+
const cellContext = {
114+
currentValue: currentEditValue,
115+
setValue: setCurrentEditValue,
116+
submitValue: handleSubmit,
117+
};
118+
119+
const FormElement = disableNativeForm ? 'div' : 'form';
120+
113121
return (
114122
<FocusLock restoreFocus={true} ref={focusLockRef}>
115123
<div
@@ -118,7 +126,7 @@ export function InlineEditor<ItemType>({
118126
aria-label={ariaLabels?.activateEditLabel?.(column, item)}
119127
onKeyDown={handleEscape}
120128
>
121-
<form onSubmit={onSubmitClick}>
129+
<FormElement onSubmit={disableNativeForm ? undefined : onFormSubmit}>
122130
<FormField
123131
stretch={true}
124132
label={ariaLabel}
@@ -143,7 +151,8 @@ export function InlineEditor<ItemType>({
143151
) : null}
144152
<Button
145153
ariaLabel={ariaLabels?.submitEditLabel?.(column)}
146-
formAction="submit"
154+
formAction={disableNativeForm ? 'none' : 'submit'}
155+
onClick={disableNativeForm ? handleSubmit : undefined}
147156
iconName="check"
148157
variant="inline-icon"
149158
loading={currentEditLoading}
@@ -157,7 +166,7 @@ export function InlineEditor<ItemType>({
157166
</span>
158167
</div>
159168
</FormField>
160-
</form>
169+
</FormElement>
161170
</div>
162171
</FocusLock>
163172
);

src/table/interfaces.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ export interface TableProps<T = any> extends BaseComponentProps {
116116
* The `cellContext` object contains the following properties:
117117
* * `cellContext.currentValue` - State to keep track of a value in input fields while editing.
118118
* * `cellContext.setValue` - Function to update `currentValue`. This should be called when the value in input field changes.
119+
* * `cellContext.submitValue` - Function to submit the `currentValue`.
120+
* * `editConfig.disableNativeForm` (boolean) - Disables the use of a `<form>` element to capture submissions inside the inline editor.
121+
* If enabled, ensure that any text inputs in the editing cell submit the cell value when the Enter key is pressed, using `cellContext.submitValue`.
119122
* * `isRowHeader` (boolean) - Specifies that cells in this column should be used as row headers.
120123
* * `hasDynamicContent` (boolean) - Specifies that cells in this column may have dynamic content. The contents will then be observed to update calculated column widths.
121124
* This may have a negative performance impact, so should be used only if necessary. It has no effect if `resizableColumns` is set to `true`.
@@ -410,6 +413,7 @@ export namespace TableProps {
410413
export interface CellContext<V> {
411414
currentValue: Optional<V>;
412415
setValue: (value: V | undefined) => void;
416+
submitValue: () => void;
413417
}
414418

415419
export interface EditConfig<T, V = any> {
@@ -449,6 +453,13 @@ export namespace TableProps {
449453
* Determines whether inline edit for certain items is disabled, and provides a reason why.
450454
*/
451455
disabledReason?: (item: T) => string | undefined;
456+
457+
/**
458+
* Disables the use of a `<form>` element to capture submissions inside the inline editor.
459+
* If enabled, ensure that any text inputs in the editing cell submit the cell value when
460+
* the Enter key is pressed, using `cellContext.submitValue`.
461+
*/
462+
disableNativeForm?: boolean;
452463
}
453464

454465
export type ColumnDefinition<ItemType> = {

0 commit comments

Comments
 (0)