Skip to content
15 changes: 15 additions & 0 deletions .changeset/25874-form-refusal-no-field-toast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/components': patch
'@object-ui/i18n': patch
---

fix(components): a refused submit moves focus to the first invalid field and no longer toasts the field list over the dialog's title

Clause-②: no
Metadata-protocol: none

- **Before.** A submit refused by field validation, on the client or by the server's `fields[]`, raised an error toast naming the fields ("Please check the highlighted fields: First name, Last name, Company…") on top of the message already written under each input. The toast cut its list after three names, and in a record create / edit dialog it was drawn over the title row.
- **Now.** The form raises no toast for a refusal that every field carries inline. It scrolls to the first invalid field in form order and focuses it, and first activates that field's tab when it is on a background tab. The message under each input is the whole report.
- **A field with nothing drawn to carry it.** When the server rejects a field that has no drawn input, for example one on a tab or section a `visibleWhen` predicate hides, the form shows the form-level banner and its toast with the server's sentence, the same path a rejection naming an unknown field already takes. The hidden tab is not activated.
- **Earlier outcomes retire.** A refusal on the client now takes down the previous attempt's banner and toast, and a clear-on-hide notice, as an attempt that passes validation already did. Before, the refusal toast replaced them under the form's outcome id.
- **`validation.formInvalid` removed.** The toast's sentence ("Please check the highlighted fields: {{fields}}") had no other reader, so it leaves the form's default map and all ten `@object-ui/i18n` locale packs. `validation.formInvalidJoiner` stays: the clear-on-hide notice, the faulted-`visibleWhen` refusal and the option-group hint still join field labels with it. A host bundle that overrides `validation.formInvalid` can drop the entry; nothing reads it.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.record.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@
"ui/packages/types"
],
"registrySources": 922,
"registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063"
"registrySourcesSha256": "6ae9bbeaed45f5221fd9f6163c3a6b66744a4aae8b74878d242fb334d42b7f0f"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { PredicateScopeProvider } from '@object-ui/react';
import { toast } from '../../../ui/sonner';
// Module-scope import (not `beforeAll`) — objectui#3010/#3021.
import '../../../renderers';

Expand Down Expand Up @@ -208,6 +209,42 @@ describe('#6237 — a tab predicate gates the whole tab', () => {
expect(document.body.textContent).not.toMatch(/required/i);
});

it('a SERVER rejection of a hidden tab\'s field is said at form level — no input can carry it (objectstack#25874)', async () => {
// The invalid-submit toast that used to name every rejected field is gone:
// a drawn field carries its own message. A field on a hidden tab has no
// drawn input, so its rejection must not be taken over as "marked inline"
// — the form-level banner says the server's sentence instead.
const toastError = vi.spyOn(toast, 'error').mockImplementation(() => 'id' as never);
const message = 'Salary is required';
const fields = [{ field: 'salary', code: 'required', message }];
const onSubmit = vi.fn().mockRejectedValue(
Object.assign(new Error(message), {
code: 'VALIDATION_FAILED',
httpStatus: 400,
details: { error: message, code: 'VALIDATION_FAILED', fields, object: 'crm_employee' },
}),
);
renderForm(
{
fields: FIELDS(),
fieldTabs: TABS(GATE),
showSubmit: true,
submitLabel: 'Save',
onSubmit,
},
DENIED,
);
expect(member(/salary/i)).toBeNull();
fireEvent.click(submitBtn());

await waitFor(() => expect(screen.getByText(message)).toBeTruthy());
expect(toastError).toHaveBeenCalledTimes(1);
expect(String(toastError.mock.calls[0][0])).toBe(message);
// The hidden tab is never activated to "reveal" a field it cannot draw.
expect(trigger()).toBeNull();
expect(screen.getByRole('tab', { name: /basics/i })).toHaveAttribute('data-state', 'active');
});

it('control: the SAME required field on a VISIBLE (even inactive) tab still blocks the submit', async () => {
const onSubmit = vi.fn();
renderForm(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@

/**
* #2793: submitting a form with a missing required field must give visible
* feedback — a toast naming the fields (already shipped in #2329) AND scroll
* the first errored field into view + focus it. Before this, react-hook-form's
* native focus-on-error silently no-opped for custom widgets (lookup / select /
* master-detail), so on a long form the user clicked 创建 and saw nothing.
* feedback — scroll the first errored field into view + focus it. Before this,
* react-hook-form's native focus-on-error silently no-opped for custom widgets
* (lookup / select / master-detail), so on a long form the user clicked 创建
* and saw nothing.
*
* And NO toast (objectstack#25874). The toast that used to name the fields
* (#2329) repeated what each field already says under its input, truncated its
* own list after three names, and was drawn over the record dialog's title. The
* inline messages carry the detail; focus carries the "something is wrong".
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
Expand Down Expand Up @@ -58,7 +63,7 @@ function renderForm(fields: any[], defaultValues?: Record<string, unknown>) {
const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i }));

describe('form renderer — invalid-submit feedback (#2793)', () => {
it('toasts and scrolls+focuses the first errored field on a missing required field', async () => {
it('scrolls+focuses the first errored field on a missing required field, with no toast', async () => {
const { container } = renderForm([
{ name: 'title', label: 'Title', type: 'input', required: true },
{ name: 'project', label: 'Project', type: 'input', required: true },
Expand All @@ -70,14 +75,15 @@ describe('form renderer — invalid-submit feedback (#2793)', () => {

submit();

await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
// Names the missing field(s).
expect(String(toastErrorSpy.mock.calls[0][0])).toMatch(/Title/);
// First errored field (visual order) scrolled into view + focused.
expect(scrollSpy).toHaveBeenCalled();
await waitFor(() => expect(scrollSpy).toHaveBeenCalled());
await waitFor(() =>
expect(titleWrap.contains(document.activeElement)).toBe(true),
);
// The per-field message is the only error text: it is under the input…
expect(titleWrap.textContent).toMatch(/required/i);
// …and no toast repeats it over the dialog.
expect(toastErrorSpy).not.toHaveBeenCalled();
});

it('scrolls the first errored field even when an earlier field is valid', async () => {
Expand All @@ -98,9 +104,8 @@ describe('form renderer — invalid-submit feedback (#2793)', () => {

submit();

await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
// Only `project` is invalid → it, not the valid `title`, is scrolled to.
expect(projectScroll).toHaveBeenCalled();
await waitFor(() => expect(projectScroll).toHaveBeenCalled());
expect(titleScroll).not.toHaveBeenCalled();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@
*/

/**
* The invalid-submit toast joins the field names with a PER-LOCALE separator —
* A refused submit joins the field names with a PER-LOCALE separator —
* objectstack#5407.
*
* The list was first built for the invalid-submit toast, which is gone
* (objectstack#25874: each field's own message carries the detail). The form's
* remaining field-name list is the faulted-`visibleWhen` refusal, which reads
* the same `validation.formInvalidJoiner` entry, so the pin moved there.
*
* `announceFieldErrors` used to build the list with a hardcoded `、` (U+3001).
* That is the CJK enumeration comma: correct for zh/ja by accident, wrong in
* every Latin locale, and most visibly wrong in English, where the toast read
Expand Down Expand Up @@ -42,6 +47,8 @@ let toastErrorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
toastErrorSpy = vi.spyOn(toast, 'error').mockImplementation(() => 'id' as any);
// The faulted predicates are reported once each; not what this file asserts.
vi.spyOn(console, 'warn').mockImplementation(() => {});
if (!(Element.prototype as any).scrollIntoView) {
(Element.prototype as any).scrollIntoView = () => {};
}
Expand All @@ -52,11 +59,14 @@ afterEach(() => {
vi.restoreAllMocks();
});

/** Three required fields, so the joiner appears twice and truncation does not. */
/** A predicate the engine cannot parse: a fault whatever the record holds. */
const BROKEN = 'record.stage ==';

/** Three faulted fields, so the joiner appears twice. */
const fields = [
{ name: 'subject', label: 'Subject', type: 'input', required: true },
{ name: 'account', label: 'Account', type: 'input', required: true },
{ name: 'status', label: 'Status', type: 'input', required: true },
{ name: 'subject', label: 'Subject', type: 'input', visibleWhen: BROKEN },
{ name: 'account', label: 'Account', type: 'input', visibleWhen: BROKEN },
{ name: 'status', label: 'Status', type: 'input', visibleWhen: BROKEN },
];

function renderFormIn(language: string) {
Expand Down Expand Up @@ -84,7 +94,7 @@ async function toastTextAfterSubmit(language: string): Promise<string> {
return String(toastErrorSpy.mock.calls[0][0]);
}

describe('form renderer — invalid-submit toast list joiner (objectstack#5407)', () => {
describe('form renderer — refused-submit field list joiner (objectstack#5407)', () => {
it('joins with a comma+space under an en session, never the CJK comma', async () => {
const text = await toastTextAfterSubmit('en');

Expand All @@ -100,6 +110,6 @@ describe('form renderer — invalid-submit toast list joiner (objectstack#5407)'
expect(text).toContain('Subject、Account、Status');
// zh's sentence, to prove the joiner is being read from the zh pack and not
// from an `en` fallback that happens to carry the same punctuation.
expect(text).toContain('请检查表单中标记的字段');
expect(text).toContain('无法提交');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,20 @@ describe('form renderer — server-rejected fields', () => {
await waitFor(() => expect(stageWrap.contains(document.activeElement)).toBe(true));
});

it('names the rejected fields in the toast', async () => {
it('raises no toast when every rejected field carries its own message (objectstack#25874)', async () => {
const onSubmit = vi.fn().mockRejectedValue(
validationFailure([{ field: 'title', message: 'Title is required' }]),
);
renderForm(FIELDS, onSubmit);
const { container } = renderForm(FIELDS, onSubmit);
const titleWrap = container.querySelector('[data-field="title"]') as HTMLElement;

submit();

await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
// The field LABEL, not its machine name.
expect(String(toastErrorSpy.mock.calls[0][0])).toMatch(/Title/);
// The reason is written under the input it belongs to…
await waitFor(() => expect(titleWrap.textContent).toMatch(/Title is required/));
// …so a toast naming the same field, drawn over the dialog's title, would
// only repeat it.
expect(toastErrorSpy).not.toHaveBeenCalled();
});

it('marks every rejected field, not just the first', async () => {
Expand Down Expand Up @@ -150,6 +153,34 @@ describe('form renderer — server-rejected fields', () => {
expect(String(toastErrorSpy.mock.calls[0][0])).toMatch(/Owner is required/);
});

it('a later client-side refusal retires the previous refusal`s banner and toast (objectstack#25874)', async () => {
// The client refusal raises no toast of its own any more, so nothing would
// supersede the earlier server refusal under the shared outcome id: the
// attempt has to take the stale banner and toast down itself.
const dismissSpy = vi.spyOn(toast, 'dismiss').mockImplementation(() => 'id' as any);
const forbidden = Object.assign(new Error('insufficient privileges'), {
code: 'FORBIDDEN',
httpStatus: 403,
details: { error: 'insufficient privileges', code: 'FORBIDDEN' },
});
const onSubmit = vi.fn().mockRejectedValue(forbidden);
vi.spyOn(console, 'warn').mockImplementation(() => {});
renderForm([{ name: 'title', label: 'Title', type: 'input', required: true }], onSubmit);
const input = screen.getByLabelText(/title/i);

fireEvent.change(input, { target: { value: 'Filled' } });
submit();
await waitFor(() => expect(screen.getByText(/permission to save/i)).toBeTruthy());

dismissSpy.mockClear();
fireEvent.change(input, { target: { value: '' } });
submit();

await waitFor(() => expect(screen.queryByText(/permission to save/i)).toBeNull());
expect(dismissSpy).toHaveBeenCalled();
expect(onSubmit).toHaveBeenCalledTimes(1);
});

it('leaves a non-field failure on the generic path', async () => {
const forbidden = Object.assign(new Error('insufficient privileges'), {
code: 'FORBIDDEN',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
* the ruling's default — one form-level notice under the form's existing
* outcome-toast id, naming every cleared column at once, so it retires like the
* form's other outcome messages: a later submit outcome supersedes it, and the
* dismissal at the next attempt takes it down.
* dismissal at the next attempt takes it down. A client-side refusal raises no
* toast of its own (objectstack#25874 — each field's message carries it), so
* that attempt dismisses the id instead of superseding it.
*
* Only a field that HELD something is named: an already-empty value, an empty
* list and an unchecked two-state control (whose empty state is `false`) are
Expand Down Expand Up @@ -199,7 +201,7 @@ describe('#8070 — a visibleWhen clear is named to the user', () => {
expect(notices()[1]).not.toContain('Contact');
});

it('a later submit refusal supersedes the notice, and the next accepted attempt retires it — one id throughout', async () => {
it('a later submit attempt retires the notice, refused or accepted — one id throughout', async () => {
const onSubmit = vi.fn();
renderForm({
fields: [{ name: 'subject', label: 'Subject', type: 'input', required: true }, ...FIELDS.slice(0, 2)],
Expand All @@ -211,13 +213,15 @@ describe('#8070 — a visibleWhen clear is named to the user', () => {
await waitFor(() => expect(warningSpy).toHaveBeenCalledTimes(1));
const noticeId = (warningSpy.mock.calls[0][1] as { id: string }).id;

// Refused on the client: the refusal is published under the SAME id, which
// sonner treats as an update of the notice rather than a second toast.
// Refused on the client: the refusal is the inline message under Subject,
// and the attempt dismisses the notice's id rather than leaving it up.
save();
await waitFor(() => expect(errorSpy).toHaveBeenCalledTimes(1));
expect(errorSpy.mock.calls[0][1]).toMatchObject({ id: noticeId });
await waitFor(() => expect(dismissSpy).toHaveBeenCalledWith(noticeId));
expect(errorSpy).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();

// Accepted on the client: the attempt starts by dismissing that id.
// Accepted on the client: the attempt starts by dismissing that id too.
dismissSpy.mockClear();
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Kickoff' } });
save();
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
Expand Down
Loading
Loading