Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .changeset/27247-object-form-show-reset-button.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

function renderForm(schema: Rec, after?: React.ReactNode, domProps: Rec = {}) {
const Form = ComponentRegistry.get('form')!;
return render(
<>
<Form
schema={{
type: 'form',
fields: [{ name: 'title', label: 'Title', type: 'input' }],
defaultValues: { title: 'Opening' },
...schema,
}}
{...domProps}
/>
{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 },
<button type="reset" form="f-27247">Outside</button>,
{ id: 'f-27247' },
);
fireEvent.change(titleInput(), { target: { value: 'Edited' } });
fireEvent.click(screen.getByRole('button', { name: 'Outside' }));
await waitFor(() => expect(titleInput().value).toBe('Opening'));
});
});
34 changes: 34 additions & 0 deletions ui/packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
// <form>'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<HTMLFormElement>) => {
event?.preventDefault();
form.reset();
setSubmitError(null);
setRejectedFieldNames([]);
};

// Handle cancel
const handleCancel = () => {
form.reset();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -3993,6 +4014,7 @@ ComponentRegistry.register('form',
<form
ref={formRef}
onSubmit={handleSubmit}
onReset={handleReset}
className={className}
{...formProps}
// Apply designer props
Expand Down Expand Up @@ -4176,6 +4198,16 @@ ComponentRegistry.register('form',
{cancelLabel ?? t('common.cancel')}
</Button>
)}
{showReset && (
<Button
type="reset"
variant="outline"
disabled={isSubmitting || disabled}
className="w-full sm:w-auto"
>
{resetLabel ?? t('common.reset')}
</Button>
)}
{showSubmit && (
<Button
type="submit"
Expand Down Expand Up @@ -4209,6 +4241,8 @@ ComponentRegistry.register('form',
{ name: 'submitLabel', type: 'string' },
{ name: 'cancelLabel', type: 'string' },
{ name: 'showCancel', type: 'boolean' },
{ name: 'showReset', type: 'boolean', description: 'Draw a Reset button that restores the opening values without saving' },
{ name: 'resetLabel', type: 'string' },
{
name: 'layout',
type: 'enum',
Expand Down
21 changes: 20 additions & 1 deletion ui/packages/plugin-form/src/DrawerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ const useDiscardTranslation = createSafeTranslation(
'form.create': 'Create',
'form.update': 'Update',
'common.cancel': 'Cancel',
// The footer's declared Reset button (objectstack#27247).
'common.reset': 'Reset',
'form.errorLoading': 'Error loading form',
'publicForm.loading': 'Loading form…',
},
Expand Down Expand Up @@ -185,6 +187,8 @@ export interface DrawerFormSchema {
submitText?: string;
showCancel?: boolean;
cancelText?: string;
/** The Reset button in the footer: restores the opening values, no save (objectstack#27247). */
showReset?: boolean;
initialValues?: Record<string, any>;
initialData?: Record<string, any>;
readOnly?: boolean;
Expand Down Expand Up @@ -739,6 +743,7 @@ const DrawerFormBody: React.FC<DrawerFormProps> = ({
});
const showSubmit = schema.showSubmit !== false && schema.mode !== 'view' && !closedForRecord(closedAffordance);
const showCancel = schema.showCancel !== false;
const showReset = schema.showReset === true && schema.mode !== 'view' && !closedForRecord(closedAffordance);
const submitLabel = schema.submitText || (schema.mode === 'create' ? t('form.create') : t('form.update'));
const cancelLabel = schema.cancelText || t('common.cancel');

Expand Down Expand Up @@ -1014,7 +1019,7 @@ const DrawerFormBody: React.FC<DrawerFormProps> = ({
{/* Sticky footer — own action buttons. Cancel calls the discard guard
directly (no form.reset), so unsaved input survives "Keep editing".
Suppressed for the master-detail path, which owns its own action bar. */}
{!error && !loading && !(subforms?.length && schema.mode !== 'view') && (showSubmit || showCancel) && (
{!error && !loading && !(subforms?.length && schema.mode !== 'view') && (showSubmit || showCancel || showReset) && (
<div className="shrink-0 border-t px-4 py-3 bg-background" data-testid="drawer-form-footer">
{/* The REASON the Save below is disabled (objectui#10166). */}
<UploadInFlightNotice gate={uploadGate} />
Expand All @@ -1030,6 +1035,20 @@ const DrawerFormBody: React.FC<DrawerFormProps> = ({
{cancelLabel}
</Button>
)}
{/* The declared Reset button (objectstack#27247). It lives out
here, outside the <form>, so it names the form by `id` and
the form's own `reset` handler restores the opening values. */}
{showReset && (
<Button
type="reset"
form={formId}
variant="outline"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{t('common.reset')}
</Button>
)}
{showSubmit && (
<Button
type="submit"
Expand Down
21 changes: 20 additions & 1 deletion ui/packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ const useDiscardTranslation = createSafeTranslation(
'form.create': 'Create',
'form.update': 'Update',
'common.cancel': 'Cancel',
// The footer's declared Reset button (objectstack#27247).
'common.reset': 'Reset',
'form.errorLoading': 'Error loading form',
},
'form.discardTitle',
Expand Down Expand Up @@ -222,6 +224,8 @@ export interface ModalFormSchema {
submitText?: string;
showCancel?: boolean;
cancelText?: string;
/** The Reset button in the footer: restores the opening values, no save (objectstack#27247). */
showReset?: boolean;
initialValues?: Record<string, any>;
initialData?: Record<string, any>;
readOnly?: boolean;
Expand Down Expand Up @@ -867,6 +871,7 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
});
const showSubmit = schema.showSubmit !== false && schema.mode !== 'view' && !closedForRecord(closedAffordance);
const showCancel = schema.showCancel !== false;
const showReset = schema.showReset === true && schema.mode !== 'view' && !closedForRecord(closedAffordance);
const submitLabel = schema.submitText || (schema.mode === 'create' ? t('form.create') : t('form.update'));
const cancelLabel = schema.cancelText || t('common.cancel');

Expand Down Expand Up @@ -1219,7 +1224,7 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
);
}

const hasFooter = !loading && !error && (showSubmit || showCancel);
const hasFooter = !loading && !error && (showSubmit || showCancel || showReset);

return (
<Dialog
Expand Down Expand Up @@ -1282,6 +1287,20 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
{cancelLabel}
</Button>
)}
{/* The declared Reset button (objectstack#27247). It lives out
here, outside the <form>, so it names the form by `id` and
the form's own `reset` handler restores the opening values. */}
{showReset && (
<Button
type="reset"
form={formId}
variant="outline"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{t('common.reset')}
</Button>
)}
{showSubmit && (
<Button
type="submit"
Expand Down
Loading
Loading