From cf9a965e96e6d7e64112dd1429488c663203b1a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:10:19 +0000 Subject: [PATCH 1/6] fix(components): a refused submit focuses the first invalid field instead of toasting the field list The invalid-submit toast 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 row. Drop it; keep reveal + focus on the first invalid field (activating its tab first). A server rejection naming a field with no drawn input (a predicate-hidden tab or section) now falls through to the form-level banner, which the toast used to cover. Claude-Session: https://claude.ai/code/session_01CzUs1oNtFWhWUXT7rmoGy2 Co-authored-by: Claude --- .../fieldtab-visiblewhen-6237.test.tsx | 37 ++++++++ .../__tests__/form-invalid-scroll.test.tsx | 27 +++--- .../form-invalid-toast-joiner.test.tsx | 24 +++-- .../form-server-field-errors.test.tsx | 13 ++- .../components/src/renderers/form/form.tsx | 91 +++++++++++-------- 5 files changed, 133 insertions(+), 59 deletions(-) diff --git a/ui/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx b/ui/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx index 12ec3fb18e0..ee725eac242 100644 --- a/ui/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx +++ b/ui/packages/components/src/renderers/form/__tests__/fieldtab-visiblewhen-6237.test.tsx @@ -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'; @@ -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( diff --git a/ui/packages/components/src/renderers/form/__tests__/form-invalid-scroll.test.tsx b/ui/packages/components/src/renderers/form/__tests__/form-invalid-scroll.test.tsx index a2a389187f9..29543054d3a 100644 --- a/ui/packages/components/src/renderers/form/__tests__/form-invalid-scroll.test.tsx +++ b/ui/packages/components/src/renderers/form/__tests__/form-invalid-scroll.test.tsx @@ -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'; @@ -58,7 +63,7 @@ function renderForm(fields: any[], defaultValues?: Record) { 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 }, @@ -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 () => { @@ -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(); }); diff --git a/ui/packages/components/src/renderers/form/__tests__/form-invalid-toast-joiner.test.tsx b/ui/packages/components/src/renderers/form/__tests__/form-invalid-toast-joiner.test.tsx index 14dcd8f5b97..7be6e840689 100644 --- a/ui/packages/components/src/renderers/form/__tests__/form-invalid-toast-joiner.test.tsx +++ b/ui/packages/components/src/renderers/form/__tests__/form-invalid-toast-joiner.test.tsx @@ -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 @@ -42,6 +47,8 @@ let toastErrorSpy: ReturnType; 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 = () => {}; } @@ -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) { @@ -84,7 +94,7 @@ async function toastTextAfterSubmit(language: string): Promise { 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'); @@ -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('无法提交'); }); }); diff --git a/ui/packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx b/ui/packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx index 9b8a93cf043..d8adaca3733 100644 --- a/ui/packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx +++ b/ui/packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx @@ -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 () => { diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index 6eb65585ed5..c9e451d839f 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -2843,13 +2843,16 @@ ComponentRegistry.register('form', * carries `data-field` (FormItem), so this reaches custom widgets that RHF's * own focus cannot (#2793). */ - const revealField = (name: string) => { + const fieldWrapper = (name: string): HTMLElement | null => { const root: ParentNode = formRef.current ?? document; const escaped = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(name) : name.replace(/["\\]/g, '\\$&'); - const target = root.querySelector(`[data-field="${escaped}"]`); + return root.querySelector(`[data-field="${escaped}"]`); + }; + const revealField = (name: string) => { + const target = fieldWrapper(name); if (target) { target.scrollIntoView?.({ behavior: 'smooth', block: 'center' }); const focusable = target.querySelector( @@ -2860,54 +2863,64 @@ ComponentRegistry.register('form', }; /** - * Tell the user WHICH fields are wrong, wherever the verdict came from. + * Point the user at the fields that are wrong, wherever the verdict came + * from: reveal and focus the FIRST of them (in declared/visual order, not + * the caller's key order), activating its tab first when it sits on a + * background one. + * + * No toast. Every field this reaches carries its own message under the + * input, so a toast naming the same fields only repeated them — truncated + * after three names, and drawn over the record dialog's title row, which it + * hid. Moving focus to the first offender is what makes a refused submit + * visible on a long form; the inline messages say the rest. * - * Toast naming the offending fields, then reveal the FIRST of them (in - * declared/visual order, not the caller's key order) — activating its tab - * first when it sits on a background one. + * Returns the rejected names that have NO drawn input — a field on a + * predicate-hidden tab (#6237) or section, or hidden by its own rule. Only + * the SERVER can reject those (their client rules are skipped with them), + * and nothing inline can carry the message, so the caller must say it at + * form level instead. * * Shared by the client-side invalid handler and the SERVER-rejection path: * the two failures are the same event as far as the person filling the form * is concerned; only the referee differs. */ - const announceFieldErrors = (names: string[]) => { - if (names.length === 0) return; + const announceFieldErrors = (names: string[]): string[] => { + if (names.length === 0) return []; setRejectedFieldNames(names); - const labels = names.map((n) => fieldLabelByName[n] || n); - const MAX = 3; - const fieldsText = - labels.slice(0, MAX).join(t('validation.formInvalidJoiner')) + - (labels.length > MAX ? '…' : ''); - toast.error(t('validation.formInvalid', { fields: fieldsText }), { id: outcomeToastId }); - const errored = new Set(names); - const firstName = - (fields as FormFieldConfig[]) + const ordered = [ + ...(fields as FormFieldConfig[]) .map((f) => f?.name) - .find((n): n is string => Boolean(n) && errored.has(n as string)) ?? names[0]; + .filter((n): n is string => Boolean(n) && errored.has(n as string)), + ...names.filter((n) => !(fields as FormFieldConfig[]).some((f) => f?.name === n)), + ]; + // A field is drawn when its wrapper is in the DOM. A background tab's + // panel stays mounted (CSS-hidden), so its fields count as drawn and are + // reached below by activating the tab; a predicate-hidden tab's panel is + // not rendered at all, so its fields do not. + const undrawn = ordered.filter((n) => !fieldWrapper(n)); + const firstName = ordered.find((n) => !undrawn.includes(n)); + if (!firstName) return undrawn; // In a tabbed layout the offending field may sit on a tab the user is not - // looking at — naming it is useless if they can't see it. Activate its tab - // first, then reveal it once that panel has actually been painted (it is - // display:none until then, so scroll/focus would no-op). - // - // Unless the tab is predicate-HIDDEN (#6237): only the SERVER can reject - // a field there (its client rules are skipped with it), and there is no - // panel to activate or control to reveal — the toast above already names - // the field. Recording the activation as a pick anyway would yank the - // view to that tab whenever its predicate later re-admits it. + // looking at — the message under it is useless if they can't see it. + // Activate its tab first, then reveal it once that panel has actually + // been painted (it is display:none until then, so scroll/focus would + // no-op). A predicate-HIDDEN tab is never activated: recording that as a + // pick would yank the view to it whenever its predicate later re-admits + // it, and a drawn field it also claims is rendered in a visible panel. const tabKey = tabKeyByFieldName.get(firstName); - if (tabKey && hiddenFieldTabKeys.has(tabKey)) return; - if (tabKey && tabKey !== activeFieldTab) { + if (tabKey && !hiddenFieldTabKeys.has(tabKey) && tabKey !== activeFieldTab) { setPickedFieldTab(tabKey); if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => revealField(firstName)); } else { setTimeout(() => revealField(firstName), 0); } - return; + return undrawn; } revealField(firstName); + return undrawn; }; // Handle form submission — react-hook-form's validation, then the write. @@ -3008,11 +3021,14 @@ ComponentRegistry.register('form', // this form does not render, fall through: the top-level message // concatenates every field's reason, so the part the user cannot see // inline is still said out loud instead of silently dropped. + // The same holds for a rendered-schema field with no DRAWN input + // (a predicate-hidden tab or section): `announceFieldErrors` names + // those, and any one of them sends the failure down the same path. if (known.length > 0 && known.length === fieldErrors.length) { - announceFieldErrors(known.map((fe) => fe.field)); + const undrawn = announceFieldErrors(known.map((fe) => fe.field)); // A fully-attributed rejection is not a form-level failure — the // banner would just repeat what is now written under each input. - return; + if (undrawn.length === 0) return; } } // A rejected write used to be rendered verbatim, which put raw server @@ -3055,8 +3071,10 @@ ComponentRegistry.register('form', // Client-side (react-hook-form) validation blocked the submit. The // per-field errors render inline, but in long forms the offending field // is often scrolled out of view — the user clicks 创建 and sees nothing - // happen. Surface a toast naming the fields so the feedback is visible - // regardless of scroll position (mirrors the server-error toast above). + // happen. Move focus to the first offender so the feedback is visible + // regardless of scroll position. Every name here has a drawn input: + // react-hook-form validates only mounted controls, and an undrawn field + // is unmounted, so the returned list is always empty on this path. announceFieldErrors(Object.keys(validationErrors || {})); }); @@ -3458,8 +3476,9 @@ ComponentRegistry.register('form', // parents still read two ways (objectui#4026). The separator is a // property of the locale, so all of them now read // `validation.formInvalidJoiner`: the key objectstack#5407 added for the - // invalid-submit toast a few hundred lines up, which is the same class - // of truncated-name list, rather than a second gate-only twin that would + // invalid-submit field list (that toast is gone; the `visibleWhen` + // refusal a few hundred lines up still joins with it), which is the same + // class of name list, rather than a second gate-only twin that would // reintroduce the divergence. const gatedHint = optionGroupGated ? t('fields.options.selectFirst', { From ab645af6f7c55afdb68520683f55755213684113 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:18:48 +0000 Subject: [PATCH 2/6] fix(components): a client-side refusal retires the previous outcome banner and toast Claude-Session: https://claude.ai/code/session_01CzUs1oNtFWhWUXT7rmoGy2 Co-authored-by: Claude --- .../form-server-field-errors.test.tsx | 28 +++++++++++++++++++ .../visiblewhen-clear-notice-8070.test.tsx | 18 +++++++----- .../components/src/renderers/form/form.tsx | 8 ++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/ui/packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx b/ui/packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx index d8adaca3733..db8fdca41f1 100644 --- a/ui/packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx +++ b/ui/packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx @@ -153,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', diff --git a/ui/packages/components/src/renderers/form/__tests__/visiblewhen-clear-notice-8070.test.tsx b/ui/packages/components/src/renderers/form/__tests__/visiblewhen-clear-notice-8070.test.tsx index ea2f2d71613..e8518dcb672 100644 --- a/ui/packages/components/src/renderers/form/__tests__/visiblewhen-clear-notice-8070.test.tsx +++ b/ui/packages/components/src/renderers/form/__tests__/visiblewhen-clear-notice-8070.test.tsx @@ -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 @@ -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)], @@ -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)); diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index c9e451d839f..4c8551d8930 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -3075,6 +3075,14 @@ ComponentRegistry.register('form', // regardless of scroll position. Every name here has a drawn input: // react-hook-form validates only mounted controls, and an undrawn field // is unmounted, so the returned list is always empty on this path. + // + // This attempt's outcome is those inline messages, so whatever outcome + // the form showed before it — the previous attempt's banner and its toast + // twin, or a clear-on-hide notice (objectui#8070) — retires here, as it + // does at the start of an attempt that clears validation. The refusal + // toast used to do that by superseding the same id. + setSubmitError(null); + toast.dismiss(outcomeToastId); announceFieldErrors(Object.keys(validationErrors || {})); }); From 60aa2b49a9c072610cfa25957ee74cef1c48d095 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:27:13 +0000 Subject: [PATCH 3/6] chore: changeset and sdui manifest record for the form refusal change Claude-Session: https://claude.ai/code/session_01CzUs1oNtFWhWUXT7rmoGy2 Co-authored-by: Claude --- .changeset/25874-form-refusal-no-field-toast.md | 13 +++++++++++++ scripts/sdui-manifest.record.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .changeset/25874-form-refusal-no-field-toast.md diff --git a/.changeset/25874-form-refusal-no-field-toast.md b/.changeset/25874-form-refusal-no-field-toast.md new file mode 100644 index 00000000000..ccc684d92f8 --- /dev/null +++ b/.changeset/25874-form-refusal-no-field-toast.md @@ -0,0 +1,13 @@ +--- +'@object-ui/components': 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. diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 5d728022f56..ae9176b4274 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "7734fe6bc3763fe8318db624b9e0dd4c48b2dc7b15bf2a3fb1f78e46bb8d959e" + "registrySourcesSha256": "82ef0997c76d847286a27141753bcbde5b9904cc3f9306b65b2d8145918d2e5d" } } From cb3fe1a02f5944429ec9eb95710b849fc32cd907 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:58:35 +0000 Subject: [PATCH 4/6] chore: re-record the sdui manifest hash after merging main Claude-Session: https://claude.ai/code/session_01CzUs1oNtFWhWUXT7rmoGy2 Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index f65f470a705..0ec69dc13de 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063" + "registrySourcesSha256": "a71feb98497ec8c6958f52d5e1d6d85b5f64cfd30efed87e1f4a80678dc82909" } } From 32a5792b3db788ffcfbcb847fdec4210908e0fbf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:21:16 +0000 Subject: [PATCH 5/6] fix(components,i18n): drop the now-unread validation.formInvalid key and bring two comments in line Claude-Session: https://claude.ai/code/session_01CzUs1oNtFWhWUXT7rmoGy2 Co-authored-by: Claude --- .../25874-form-refusal-no-field-toast.md | 2 ++ .../components/src/renderers/form/form.tsx | 18 ++++++++++-------- ui/packages/i18n/src/locales/ar.ts | 1 - ui/packages/i18n/src/locales/de.ts | 1 - ui/packages/i18n/src/locales/en.ts | 8 +++++--- ui/packages/i18n/src/locales/es.ts | 1 - ui/packages/i18n/src/locales/fr.ts | 1 - ui/packages/i18n/src/locales/ja.ts | 1 - ui/packages/i18n/src/locales/ko.ts | 1 - ui/packages/i18n/src/locales/pt.ts | 1 - ui/packages/i18n/src/locales/ru.ts | 1 - ui/packages/i18n/src/locales/zh.ts | 1 - 12 files changed, 17 insertions(+), 20 deletions(-) diff --git a/.changeset/25874-form-refusal-no-field-toast.md b/.changeset/25874-form-refusal-no-field-toast.md index ccc684d92f8..d1ad37599cc 100644 --- a/.changeset/25874-form-refusal-no-field-toast.md +++ b/.changeset/25874-form-refusal-no-field-toast.md @@ -1,5 +1,6 @@ --- '@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 @@ -11,3 +12,4 @@ Metadata-protocol: none - **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. diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index 4c8551d8930..fa032bb62d8 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -472,12 +472,12 @@ const useSafeFormTranslation = createSafeTranslation( 'validation.pattern': '{{field}} format is invalid', 'validation.email': 'Please enter a valid email address', 'validation.url': 'Please enter a valid URL', - 'validation.formInvalid': 'Please check the highlighted fields: {{fields}}', // objectstack#5407 — the joiner between the field names is a LOCALE // property, not a code constant. It used to be a hardcoded `、` (U+3001), // which is right for zh/ja and wrong everywhere else — including `en`, - // where the toast read "Subject、Account、Status". A locale that ships no - // entry falls back through i18next to `en`'s ", ". + // where the invalid-submit toast (since removed, objectstack#25874) read + // "Subject、Account、Status". A locale that ships no entry falls back + // through i18next to `en`'s ", ". 'validation.formInvalidJoiner': ', ', 'errors.forbidden': 'Access denied.', 'form.noPermissionToSave': "You don't have permission to save this record.", @@ -2484,11 +2484,13 @@ ComponentRegistry.register('form', // ruling on objectui#8070 (letter A) ports the naming half of the // objectui#6499 ruling to this surface: the user is told WHICH fields were // cleared and WHY. So a pass that empties anything raises one notice naming - // the cleared fields by the label the form draws (the source - // `validation.formInvalid` names them from), joined by the locale's own - // `validation.formInvalidJoiner`, under `outcomeToastId`. Sharing that id - // is what makes it retire like the form's other outcome messages: a later - // submit refusal replaces it, and the next accepted attempt dismisses it. + // the cleared fields by the label the form draws (`fieldLabelByName`), + // joined by the locale's own `validation.formInvalidJoiner`, under + // `outcomeToastId`. Sharing that id is what makes it retire like the form's + // other outcome messages: every later submit attempt takes it down. One + // that clears validation and a client-side refusal (whose report is the + // inline field messages, objectstack#25874) both dismiss the id, and the + // faulted-`visibleWhen` refusal dismisses it before raising its own. // // Only a field that held something is named. An empty list is rewritten // as itself, and `false` on a two-state control is that control's EMPTY diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 891cfe2b3cf..9c63bb30b8e 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -172,7 +172,6 @@ const ar = { email: "يرجى إدخال بريد إلكتروني صالح", url: "يرجى إدخال رابط صالح", pattern: "صيغة {{field}} غير صالحة", - formInvalid: "يرجى التحقق من الحقول المميزة: {{fields}}", formInvalidJoiner: "، ", unique: "{{field}} يجب أن يكون فريداً", type: "{{field}} يجب أن يكون {{type}} صالحاً", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 45df300eb6b..c7fc9c90d9c 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -158,7 +158,6 @@ const de = { email: "Bitte geben Sie eine gültige E-Mail-Adresse ein", url: "Bitte geben Sie eine gültige URL ein", pattern: "{{field}} hat ein ungültiges Format", - formInvalid: "Bitte überprüfen Sie die markierten Felder: {{fields}}", formInvalidJoiner: ", ", unique: "{{field}} muss eindeutig sein", type: "{{field}} muss ein gültiger {{type}} sein", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 912c9ea46ae..c5726213ca6 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -202,12 +202,14 @@ const en = { email: 'Please enter a valid email address', url: 'Please enter a valid URL', pattern: '{{field}} format is invalid', - formInvalid: 'Please check the highlighted fields: {{fields}}', - // Separator between the field names interpolated into `formInvalid`. + // Separator between the field labels a form names in one sentence: the + // record form's clear-on-hide notice (`form.clearedOnHide`) and + // faulted-`visibleWhen` refusal (`form.visibleWhenFaulted`), and the + // option-group "select the parent first" hint (`fields.options.selectFirst`). // Per-locale because list punctuation is a locale property, not a code // constant: CJK enumerates with U+3001, Latin scripts with a comma+space, // Arabic with U+060C. Hardcoding one of them in the renderer put the CJK - // comma into the English toast (objectstack#5407). + // comma into the English invalid-submit toast (objectstack#5407). formInvalidJoiner: ', ', unique: '{{field}} must be unique', type: '{{field}} must be a valid {{type}}', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 49755a9440b..8eb29b4ede8 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -165,7 +165,6 @@ const es = { email: "Por favor, introduzca un correo electrónico válido", url: "Por favor, introduzca una URL válida", pattern: "El formato de {{field}} no es válido", - formInvalid: "Por favor, revise los campos resaltados: {{fields}}", formInvalidJoiner: ", ", unique: "El campo {{field}} debe ser único", type: "{{field}} debe ser un {{type}} válido", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 977e180ee73..63fd179c81b 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -160,7 +160,6 @@ const fr = { email: "Veuillez saisir une adresse e-mail valide", url: "Veuillez saisir une URL valide", pattern: "Le format de {{field}} est invalide", - formInvalid: "Veuillez vérifier les champs en surbrillance : {{fields}}", formInvalidJoiner: ", ", unique: "{{field}} doit être unique", type: "{{field}} doit être un {{type}} valide", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 567ea51bf82..f6883afaab8 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -158,7 +158,6 @@ const ja = { email: "有効なメールアドレスを入力してください", url: "有効なURLを入力してください", pattern: "{{field}}の形式が正しくありません", - formInvalid: "ハイライトされた項目を確認してください: {{fields}}", formInvalidJoiner: "、", unique: "{{field}}は一意である必要があります", type: "{{field}}は有効な{{type}}である必要があります", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 72a5459b2e7..dc7d252544a 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -158,7 +158,6 @@ const ko = { email: "유효한 이메일 주소를 입력해주세요", url: "유효한 URL을 입력해주세요", pattern: "{{field}} 형식이 올바르지 않습니다", - formInvalid: "표시된 필드를 확인해 주세요: {{fields}}", formInvalidJoiner: ", ", unique: "{{field}}은(는) 고유해야 합니다", type: "{{field}}은(는) 유효한 {{type}}이어야 합니다", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 02b58db8da4..c549cb60d26 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -160,7 +160,6 @@ const pt = { email: "Por favor, insira um endereço de e-mail válido", url: "Por favor, insira uma URL válida", pattern: "O formato de {{field}} é inválido", - formInvalid: "Verifique os campos destacados: {{fields}}", formInvalidJoiner: ", ", unique: "{{field}} deve ser único", type: "{{field}} deve ser um {{type}} válido", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 747023fad25..7c810acfd27 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -165,7 +165,6 @@ const ru = { email: "Пожалуйста, введите корректный адрес электронной почты", url: "Пожалуйста, введите корректный URL", pattern: "Неверный формат поля {{field}}", - formInvalid: "Проверьте выделенные поля: {{fields}}", formInvalidJoiner: ", ", unique: "{{field}} должно быть уникальным", type: "{{field}} должно быть допустимым {{type}}", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index aee0eb1ff9d..068cccba26f 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -163,7 +163,6 @@ const zh = { email: '请输入有效的邮箱地址', url: '请输入有效的URL', pattern: '{{field}}格式不正确', - formInvalid: '请检查表单中标记的字段:{{fields}}', formInvalidJoiner: '、', unique: '{{field}}必须唯一', type: '{{field}}必须是有效的{{type}}', From 024bef86193273ea191485f17e8f4f86840675af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:28:47 +0000 Subject: [PATCH 6/6] chore: re-record the sdui manifest hash Claude-Session: https://claude.ai/code/session_01CzUs1oNtFWhWUXT7rmoGy2 Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 0ec69dc13de..c4de1db3cd0 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "a71feb98497ec8c6958f52d5e1d6d85b5f64cfd30efed87e1f4a80678dc82909" + "registrySourcesSha256": "6ae9bbeaed45f5221fd9f6163c3a6b66744a4aae8b74878d242fb334d42b7f0f" } }