diff --git a/.changeset/27247-object-form-show-reset-button.md b/.changeset/27247-object-form-show-reset-button.md new file mode 100644 index 00000000000..8dc84203fdc --- /dev/null +++ b/.changeset/27247-object-form-show-reset-button.md @@ -0,0 +1,17 @@ +--- +'@object-ui/plugin-form': patch +'@object-ui/components': patch +'@object-ui/types': patch +--- + +fix(plugin-form): an ObjectForm block's `showReset` draws the Reset button it declares, instead of clearing the form after every save + +Clause-②: yes (widening) + +Metadata-protocol: none (the `object-form` block's `showReset` key and its spec text, "Show the reset button", are unchanged; the renderer now does what that text says. The two new keys are on `@object-ui/types`' `FormSchema`, the renderer-internal `form` node that `@objectstack/spec` does not declare) + +`showReset` on the `object-form` block is declared "Show the reset button", but the renderer passed it to the form's clear-after-save flag. So `showReset: true` drew no button and emptied the form after each successful save, and `showReset: false` did nothing. + +- **FROM → TO.** `showReset: true` now draws a Reset button beside Cancel and Save. Pressing it puts the form back to the values it opened with (the record being edited, or the create defaults) and saves nothing. A save no longer clears the form because of `showReset`. A form that should clear after a save declares `submitBehavior: { kind: 'continue' }`, or `resetOnSuccess: true` on a create form. +- **Where the button is drawn.** The simple form (flat or sectioned), the tabbed form, and the drawer and modal footers. The wizard and the master-detail form have their own action bars and draw no Reset button yet. +- **`@object-ui/types`.** `FormSchema` (the `form` node) gains two optional keys: `showReset`, which draws the button in the form's action bar, and `resetLabel`, its label (default: the session language's "Reset"). The `form` node also restores its opening values on a `reset` event, so a footer outside the form can reach it with a `type="reset"` button that names the form's `id`. diff --git a/ui/packages/components/src/renderers/form/__tests__/form-reset-button-27247.test.tsx b/ui/packages/components/src/renderers/form/__tests__/form-reset-button-27247.test.tsx new file mode 100644 index 00000000000..1baa8a14e8d --- /dev/null +++ b/ui/packages/components/src/renderers/form/__tests__/form-reset-button-27247.test.tsx @@ -0,0 +1,69 @@ +/** + * The `form` renderer's Reset button (objectstack#27247): `showReset` draws it + * in the action bar, `resetLabel` names it, and pressing it — or any reset + * button naming the form by `id` from outside it — restores the values the + * form opened with, without a submit. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +// Module-scope import (not `beforeAll`) — objectui#3010/#3021. +import '../../../renderers'; + +type Rec = Record; + +function renderForm(schema: Rec, after?: React.ReactNode, domProps: Rec = {}) { + const Form = ComponentRegistry.get('form')!; + return render( + <> +
+ {after} + , + ); +} + +const titleInput = () => screen.getByLabelText('Title') as HTMLInputElement; + +describe('form renderer — showReset (objectstack#27247)', () => { + afterEach(cleanup); + + it('draws no Reset button unless showReset is set', () => { + const { container } = renderForm({}); + expect(container.querySelector('button[type="reset"]')).toBeNull(); + }); + + it('restores the opening values and submits nothing', async () => { + const onSubmit = vi.fn(); + renderForm({ showReset: true, resetLabel: 'Start over', onSubmit }); + fireEvent.change(titleInput(), { target: { value: 'Edited' } }); + expect(titleInput().value).toBe('Edited'); + + fireEvent.click(screen.getByRole('button', { name: 'Start over' })); + + await waitFor(() => expect(titleInput().value).toBe('Opening')); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('a reset button outside the form reaches it through the form id', async () => { + // The `id` arrives the way `SchemaRenderer` delivers a node's DOM props — + // beside `schema` — which is how the drawer and modal forms get theirs. + renderForm( + { showActions: false }, + , + { id: 'f-27247' }, + ); + fireEvent.change(titleInput(), { target: { value: 'Edited' } }); + fireEvent.click(screen.getByRole('button', { name: 'Outside' })); + await waitFor(() => expect(titleInput().value).toBe('Opening')); + }); +}); diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index 12d5c6b5a24..1c55bf70f41 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -439,6 +439,9 @@ const useSafeFormTranslation = createSafeTranslation( // so a form with no I18nProvider renders what it always did. 'common.submit': 'Submit', 'common.cancel': 'Cancel', + // objectstack#27247 — the declared Reset button's copy when no + // `resetLabel` is authored; the shared key, for the reason above. + 'common.reset': 'Reset', // objectui#3272 — the fullscreen long-text editor (`mobile_fullscreen`). // A whole dialog, not a word: title, its sr-only description, the footer's // confirm button, and the trigger's accessible name. `Cancel` in the footer @@ -1617,7 +1620,9 @@ ComponentRegistry.register('form', // it". An authored value still wins verbatim, including `''`. submitLabel, cancelLabel, + resetLabel, showCancel = false, + showReset = false, showSubmit = true, layout = 'vertical', columns = 1, @@ -3189,6 +3194,20 @@ ComponentRegistry.register('form', return submitThroughValidation(event); }; + // Handle reset (objectstack#27247). The form goes back to the values it + // opened with — react-hook-form's defaults, which the `defaultValues` + // reset above keeps current — and nothing is saved. Wired to the + // 's own `reset` event, not only to the bar's button, so a footer + // outside the form (drawer, modal) reaches it with a `type="reset"` + // button naming the form's `id`; `preventDefault` keeps the browser from + // also restoring the DOM inputs behind react-hook-form's back. + const handleReset = (event?: React.FormEvent) => { + event?.preventDefault(); + form.reset(); + setSubmitError(null); + setRejectedFieldNames([]); + }; + // Handle cancel const handleCancel = () => { form.reset(); @@ -3941,8 +3960,10 @@ ComponentRegistry.register('form', // Extract schema props that should not be spread to DOM (handled separately by schema destructuring above) submitLabel: _submitLabel, cancelLabel: _cancelLabel, + resetLabel: _resetLabel, showSubmit: _showSubmit, showCancel: _showCancel, + showReset: _showReset, resetOnSubmit: _resetOnSubmit, defaultValues: _defaultValues, inputType: _inputType, @@ -3993,6 +4014,7 @@ ComponentRegistry.register('form', )} + {showReset && ( + + )} {showSubmit && ( )} + {/* The declared Reset button (objectstack#27247). It lives out + here, outside the , so it names the form by `id` and + the form's own `reset` handler restores the opening values. */} + {showReset && ( + + )} {showSubmit && ( )} + {/* The declared Reset button (objectstack#27247). It lives out + here, outside the , so it names the form by `id` and + the form's own `reset` handler restores the opening values. */} + {showReset && ( + + )} {showSubmit && (