Skip to content

Commit 9f13ee8

Browse files
fix(ui): list each failed validation rule as its own line in the form banner and toast (#28151)
domain:ui#1 · os-tesla · session_01L9qibzC6RxMfB59EsgAoNN <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao1RUqxpYW3tyxDQLmPkuSfY)_ Fixes #28053 Clause-②: yes (widening: @object-ui/react gains the public export unmarkedRefusalLines; no accept set moves) Metadata-protocol: none ## 维护者速读 - 改了什么:一次保存同时违反两条及以上校验规则时,表单横幅和 toast 改为每条规则一行;规则点名了表单上画出的字段时,消息留在该字段下,横幅只列其余的。 - 为什么改:以前横幅显示服务端用 `; ` 拼起来的一句话,用户读成一句长句,往往只改第一处;HotCRM 为此把两条预测规则改成互斥来绕开。 - 风险与回滚:只改控制台的展示,服务端拒绝信封不变(实测本就逐条规则带 `fields[]`);单条规则的拒绝在表单渲染器上不变;回滚即 revert 本 PR。 - 先看:`ui/packages/react/src/utils/error-message.ts` 的 `unmarkedRefusalLines`、`form.tsx` 提交 catch 里的 `shownInline`、`FormPage.tsx` 提交 catch。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/components/src/renderers/form/__tests__/form-refusal-lines-28053.test.tsx`,预期 3 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes The server already says each failed rule separately: `ValidationError` keeps one `fields[]` entry per rule, located on the field the rule names, else on `_record`; only its top-level message is the `; `-join. The console threw the entries away whenever one of them had no drawn input and showed the joined text instead. Now: - **`@object-ui/react`** adds `unmarkedRefusalLines(err, isShownInline)`: one line per `fields[]` entry the caller could not show under a visible input, in server order. It answers `null` (the caller keeps the envelope's message) when the refusal has no entry list, or when an entry it would have to say carries no human-readable `message`; it answers `[]` when every entry is inline. It reads the same three shapes `extractFieldErrors` reads (the adapter's `validationErrors`, the client's `details.fields`, a bare `fields`), now through one private `fieldEntries` reader. - **Form renderer** (`ui/packages/components/src/renderers/form/form.tsx`): the submit catch records which rejected fields it wrote under a DRAWN input (`fieldWrapper`), and the banner and toast say only the other entries, one list item each. One remaining entry stays a plain string, so a single-rule refusal reads exactly as before. The permission, delegated-scope and author-marked `userMessage` paths keep their precedence. - **FormPage** (`ui/apps/console/src/components/FormPage.tsx`): the same rule over the rows it marks. Its banner and toast used to show `Submit failed (400): ` plus the raw response body for every server refusal; a refusal with entries no row carries now shows those entries' messages instead. A refusal whose every entry is marked on a row, or that has no entry list, still shows what it showed before. Both surfaces draw several lines as a `ul` of `li`, in the banner and in the toast. No server, spec or envelope change. ## Tests - `ui/packages/react/src/utils/error-message.unmarked-refusal-lines.test.ts` (new, 8 cases): the measured two-rule body; located entry left out; all inline gives `[]`; two `_record` rules give two lines; adapter, client and hand-rolled shapes agree; an entry with no field is said; an entry with no message gives `null`; no entry list gives `null`. - `ui/packages/components/src/renderers/form/__tests__/form-refusal-lines-28053.test.tsx` (new): two record-level rules give two banner items and two toast items and no `; `; one located plus one record-level rule gives the inline mark and one banner line; a single record-level rule is unchanged (the toast gets the plain string). - `form-server-field-errors.test.tsx`: the case for a rejected field the form does not render now asserts the toast says only that field's reason (it asserted a substring of the joined text). - `FormPage.refusalMarks-27417.test.tsx`: two `#28053` cases (two `_record` rules; one row-marked plus one record-level rule), and the undrawn-field case now asserts the banner says the reason and no row carries it (it asserted the reason appeared nowhere, which was the defect). **Reverse verification.** With both submit catches restored to the base (`git restore --source=6410029d` of `form.tsx` and `FormPage.tsx`, helper kept), 6 of the 22 cases in the three consumer files went red (the four new consumer cases and the two updated ones); the single-rule case stayed green, as it should. Restored with `git checkout HEAD --`, proven by `git diff HEAD` empty and both blob hashes equal to `HEAD`'s. ## Acceptance notes - **Premise measured on the wire, not only the evaluator.** On `origin/main` `6410029d`, `POST /api/v1/data/:object` through `RestServer` over a real `ObjectQL` and an SQLite `SqlDriver` (a throwaway probe in `packages/rest`, not committed), with three `cross_field` rules: - one rule naming `best` plus one naming no field answered `400` with `error` = the `; `-join and `fields` = `[{field: best, code: rule_violation, message: …}, {field: _record, code: rule_violation, message: …}]`; - two rules naming no field answered two `_record` entries, each with its own message. So this is not a Seam; the ui lane fixes it. - **FormPage single-entry change is deliberate.** A refusal with exactly one entry no row carries used to show `Submit failed (400): ` plus the raw JSON body; it now shows that entry's message. Same rule as the multi-entry case. - An entry with no human-readable `message` falls back to the envelope's top-level text for the whole banner rather than putting an untranslated code on a line by itself. - Not changed here: FormPage's banner and toast for a server refusal with no `fields[]` list, or whose every entry is marked on a row, still show `Submit failed (STATUS): ` plus the raw response body. Noted, not filed. - Out of bounds and untouched: `ui/packages/app-shell/src/console/AppContent.tsx`, `providers/writeWarningToast.ts`, `views/RecordFormPage.tsx` (held by #27999). ## Verification - Head `f4877f89` adds only the changeset's `Clause-②` line over `767d45bb`; the Check Changeset scripts (`check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, each `--base origin/main`) re-ran there, exit 0. - Local subset on head `767d45bb`: the dispatch-derived gate list, every line run with stdin from /dev/null, exit codes on disk, reconciled with `dispatch-gates.mjs --ran`. <details><summary>Derived gates (local)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --dir ui run check:new-line-citations :: exit 0 dispatch-gates --ran: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN (exit 0) pnpm --filter @object-ui/react type-check :: exit 0 pnpm --filter @object-ui/components type-check :: exit 0 pnpm --filter @object-ui/console type-check :: exit 0 ``` </details> <details><summary>Targeted tests</summary> ```text pnpm --dir ui exec vitest run FILES (head 767d45b) packages/react/src/utils/error-message.unmarked-refusal-lines.test.ts packages/react/src/utils/error-message.test.ts packages/react/src/utils/error-message.normalisation-boundary.test.ts packages/components/src/renderers/form/__tests__/form-refusal-lines-28053.test.tsx packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx apps/console/src/components/FormPage.refusalMarks-27417.test.tsx Test Files 6 passed (6) Tests 67 passed (67) os-verify-lock: VERDICT command-exit 0 Reverse verification (both submit catches at base 6410029): Tests 6 failed | 16 passed (22), VERDICT command-exit 1; restored, git diff HEAD empty. ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibwcER4bPAfAhbS8zYY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d70e94a commit 9f13ee8

9 files changed

Lines changed: 437 additions & 28 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/react': patch
3+
'@object-ui/components': patch
4+
'@object-ui/console': patch
5+
---
6+
7+
Clause-②: yes (widening: @object-ui/react gains the public export unmarkedRefusalLines; no accept set moves)
8+
Metadata-protocol: none
9+
10+
A save refused by two or more validation rules now lists each rule's message on its own line in the form's banner and toast, instead of the server's `; `-joined summary. A rule that names a field the form draws keeps its message under that input, and the banner says only the rest. The refusal envelope is unchanged: the console reads the one `fields[]` entry per rule it already carried. `@object-ui/react` adds `unmarkedRefusalLines(err, isShownInline)`, which answers those lines (or `null` when the refusal has no entry list, so the caller keeps the envelope's message).

‎ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* no row and still says what it said before.
2222
*/
2323

24+
import type React from 'react';
2425
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2526
import { render, screen, waitFor } from '@testing-library/react';
2627
import userEvent from '@testing-library/user-event';
@@ -270,7 +271,69 @@ describe('#27417 — a refusal naming no field marks none', () => {
270271
await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1));
271272
expect(screen.getByLabelText('Name')).not.toHaveAttribute('aria-invalid', 'true');
272273
expect(screen.getByLabelText('Email')).not.toHaveAttribute('aria-invalid', 'true');
273-
expect(screen.queryByText('Company is required')).toBeNull();
274+
// No row carries the reason; the banner says it (#28053).
275+
expect(document.querySelectorAll('[id$="-error"]')).toHaveLength(0);
276+
expect(vi.mocked(toast.error).mock.calls[0][0]).toBe('Company is required');
277+
});
278+
});
279+
280+
/** The banner's list items, as text. */
281+
function bannerItems(): string[] {
282+
return Array.from(document.querySelectorAll('[id^="form-error:"] li')).map((li) => li.textContent ?? '');
283+
}
284+
285+
/** The last toast's content, drawn, as its list items. */
286+
function toastItems(): string[] {
287+
const calls = vi.mocked(toast.error).mock.calls;
288+
const content = calls[calls.length - 1][0] as React.ReactNode;
289+
const { container } = render(<>{content}</>);
290+
return Array.from(container.querySelectorAll('li')).map((li) => li.textContent ?? '');
291+
}
292+
293+
describe('#28053 — one entry per failed validation rule', () => {
294+
const AMOUNT = 'Amount must be positive.';
295+
const TERMS = 'Accept the terms before sending.';
296+
297+
it('two record-level rules: two banner lines and two toast lines, no joined summary', async () => {
298+
stubPublic(['name', 'email', 'company'], {
299+
status: 400,
300+
body: {
301+
error: `${AMOUNT}; ${TERMS}`,
302+
code: 'VALIDATION_FAILED',
303+
fields: [
304+
{ field: '_record', code: 'rule_violation', message: AMOUNT },
305+
{ field: '_record', code: 'rule_violation', message: TERMS },
306+
],
307+
},
308+
});
309+
await fillAndSubmit({ Name: 'Ada', Email: 'ada@example.com' });
310+
311+
await waitFor(() => expect(bannerItems()).toEqual([AMOUNT, TERMS]));
312+
expect(toastItems()).toEqual([AMOUNT, TERMS]);
313+
expect(screen.queryByText(/Submit failed/)).toBeNull();
314+
expect(document.querySelectorAll('[id$="-error"]')).toHaveLength(0);
315+
});
316+
317+
it('one located + one record-level rule: the row carries its reason, the banner says the other', async () => {
318+
stubPublic(['name', 'email', 'company'], {
319+
status: 400,
320+
body: {
321+
error: `Email is not a valid email address; ${AMOUNT}`,
322+
code: 'VALIDATION_FAILED',
323+
fields: [
324+
{ field: 'email', code: 'rule_violation', message: 'Email is not a valid email address' },
325+
{ field: '_record', code: 'rule_violation', message: AMOUNT },
326+
],
327+
},
328+
});
329+
await fillAndSubmit({ Name: 'Ada', Email: 'ada' });
330+
331+
const email = screen.getByLabelText('Email');
332+
await waitFor(() => expect(email).toHaveAttribute('aria-invalid', 'true'));
333+
expect(describedText(email)).toContain('Email is not a valid email address');
334+
expect(vi.mocked(toast.error).mock.calls[0][0]).toBe(AMOUNT);
335+
expect(await screen.findByText(AMOUNT)).toBeInTheDocument();
336+
expect(screen.queryByText(/Submit failed/)).toBeNull();
274337
});
275338
});
276339

‎ui/apps/console/src/components/FormPage.tsx‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@
116116
* the host half (ids, label association, required, uploads).
117117
*/
118118

119-
import { Suspense, useContext, useEffect, useId, useMemo, useState, type FormEvent } from 'react';
119+
import { Suspense, useContext, useEffect, useId, useMemo, useState, type FormEvent, type ReactNode } from 'react';
120120
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
121121
import { toast } from 'sonner';
122122
import {
@@ -162,6 +162,7 @@ import {
162162
import {
163163
SchemaRendererContext,
164164
extractFieldErrors,
165+
unmarkedRefusalLines,
165166
usePredicateScope,
166167
type ApiFetch,
167168
} from '@object-ui/react';
@@ -1948,6 +1949,19 @@ function refusedWriteError(message: string, body: string): Error {
19481949
return error;
19491950
}
19501951

1952+
/**
1953+
* A submit refusal as the banner and the toast draw it: one string as itself,
1954+
* several reasons as one list item each.
1955+
*/
1956+
function refusalContent(refusal: string | string[]): ReactNode {
1957+
if (typeof refusal === 'string') return refusal;
1958+
return (
1959+
<ul className="list-disc space-y-1 pl-4">
1960+
{refusal.map((line, i) => <li key={i}>{line}</li>)}
1961+
</ul>
1962+
);
1963+
}
1964+
19511965
// ─── Field renderers ─────────────────────────────────────────────────
19521966

19531967
/** The DOM id of a row's control — the host owns it (objectui#3962). */
@@ -2382,7 +2396,8 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
23822396
const channel = mode === 'internal' ? hostApiFetch : undefined;
23832397

23842398
const [loading, setLoading] = useState(true);
2385-
const [error, setError] = useState<string | null>(null);
2399+
/** One string, or one line per reason a server refusal marked on no row. */
2400+
const [error, setError] = useState<string | string[] | null>(null);
23862401
/**
23872402
* The load answered "no such form" ({@link FormNotFoundError}). Its own flag
23882403
* rather than a string in `error`, because the page draws a localized state
@@ -2856,16 +2871,22 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
28562871
break;
28572872
}
28582873
} catch (err: any) {
2859-
const msg = err?.message ?? String(err);
2860-
setError(msg);
2861-
toast.error(msg, { id: outcomeToastId });
28622874
// The fields the server's refusal names, read by the record dialog's
28632875
// reader (see `refusedWriteError`), marked where this form draws them. A
28642876
// field the form does not draw marks nothing; the banner still says it.
28652877
const drawn = sections.flatMap((s) => s.fields);
28662878
const named = (extractFieldErrors(err) ?? []).filter((fe) =>
28672879
drawn.some((f) => f.name === fe.field),
28682880
);
2881+
// A refusal listing one entry per failed rule says each entry no row
2882+
// carries on its own line, not the envelope's `; `-joined summary.
2883+
const unmarked = unmarkedRefusalLines(err, (field) => named.some((fe) => fe.field === field));
2884+
const msg: string | string[] =
2885+
unmarked && unmarked.length > 1
2886+
? unmarked
2887+
: unmarked?.[0] ?? err?.message ?? String(err);
2888+
setError(msg);
2889+
toast.error(refusalContent(msg), { id: outcomeToastId });
28692890
setFieldErrors(Object.fromEntries(
28702891
named.map((fe) => [fe.field, fe.message || t('form.submitFailed')]),
28712892
));
@@ -2921,7 +2942,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
29212942
return (
29222943
<div className="mx-auto max-w-2xl p-6">
29232944
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
2924-
{error}
2945+
{refusalContent(error)}
29252946
</div>
29262947
</div>
29272948
);
@@ -3022,7 +3043,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
30223043
const refusal = mark === undefined
30233044
? undefined
30243045
: mark === null
3025-
? { message: error ?? '', describedBy: errorBannerId }
3046+
? { message: [error ?? ''].flat().join(' '), describedBy: errorBannerId }
30263047
: { message: mark, describedBy: errorIdOf(f.name) };
30273048
const spanClass =
30283049
f.colSpan === 2 ? 'sm:col-span-2'
@@ -3325,7 +3346,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
33253346
id={errorBannerId}
33263347
className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"
33273348
>
3328-
{error}
3349+
{refusalContent(error)}
33293350
</div>
33303351
)}
33313352
<div className="flex flex-wrap items-center justify-end gap-2">
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Two failed validation rules on one save are said as two entries
11+
* (objectstack-ai/objectstack#28053).
12+
*
13+
* The server keeps one `fields[]` entry per failed rule — on the field the rule
14+
* names, else on `_record` — and joins only the top-level message with `; `.
15+
* The banner and the toast used to show that joined text, so two broken rules
16+
* read as one run-on sentence. Each rule now gets its own line, except the ones
17+
* already written under an input.
18+
*/
19+
20+
import React from 'react';
21+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
22+
import { render, screen, fireEvent, waitFor, cleanup, within } from '@testing-library/react';
23+
import { ComponentRegistry } from '@object-ui/core';
24+
import { toast } from '../../../ui/sonner';
25+
// Registers the renderers at module scope, NOT inside a `beforeAll` — see
26+
// object-ui/no-dynamic-import-in-test-hook.
27+
import '../../../renderers';
28+
29+
let toastErrorSpy: ReturnType<typeof vi.spyOn>;
30+
31+
beforeEach(() => {
32+
toastErrorSpy = vi.spyOn(toast, 'error').mockImplementation(() => 'id' as any);
33+
if (!(Element.prototype as any).scrollIntoView) {
34+
(Element.prototype as any).scrollIntoView = () => {};
35+
}
36+
});
37+
38+
afterEach(() => {
39+
cleanup();
40+
vi.restoreAllMocks();
41+
});
42+
43+
/** The body `POST /data/:object` answers, as `@objectstack/client` decorates it. */
44+
function ruleRefusal(fields: Array<{ field: string; message: string }>) {
45+
const message = fields.map((f) => f.message).join('; ');
46+
const wire = fields.map((f) => ({ ...f, code: 'rule_violation' }));
47+
return Object.assign(new Error(message), {
48+
code: 'VALIDATION_FAILED',
49+
httpStatus: 400,
50+
details: { error: message, code: 'VALIDATION_FAILED', fields: wire, object: 'forecast' },
51+
});
52+
}
53+
54+
function renderForm(onSubmit: (data: any) => Promise<unknown>) {
55+
const Form = ComponentRegistry.get('form')!;
56+
return render(
57+
<Form
58+
schema={{
59+
type: 'form',
60+
mode: 'create',
61+
showSubmit: true,
62+
showCancel: false,
63+
submitLabel: 'Create',
64+
fields: [
65+
{ name: 'amount', label: 'Amount', type: 'input' },
66+
{ name: 'best', label: 'Best case', type: 'input' },
67+
],
68+
onSubmit,
69+
}}
70+
/>,
71+
);
72+
}
73+
74+
const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i }));
75+
76+
/** The banner's text lines: its list items, or its one string. */
77+
function bannerLines(container: HTMLElement): string[] {
78+
const banner = container.querySelector('[role="alert"]') as HTMLElement;
79+
const items = within(banner).queryAllByRole('listitem');
80+
return items.length > 0 ? items.map((li) => li.textContent ?? '') : [banner.textContent ?? ''];
81+
}
82+
83+
/** The toast's content, drawn, as text lines. */
84+
function toastLines(): string[] {
85+
const content = toastErrorSpy.mock.calls.at(-1)![0] as React.ReactNode;
86+
if (typeof content === 'string') return [content];
87+
const { container } = render(<>{content}</>);
88+
return Array.from(container.querySelectorAll('li')).map((li) => li.textContent ?? '');
89+
}
90+
91+
const AMOUNT = 'Amount must be positive.';
92+
const NAME = 'Name is required for a forecast.';
93+
const BEST = 'Best case must be at least the amount.';
94+
95+
describe('form renderer — one entry per failed validation rule (#28053)', () => {
96+
it('two record-level rules: two banner lines and two toast lines, no joined summary', async () => {
97+
const onSubmit = vi.fn().mockRejectedValue(
98+
ruleRefusal([{ field: '_record', message: AMOUNT }, { field: '_record', message: NAME }]),
99+
);
100+
const { container } = renderForm(onSubmit);
101+
102+
submit();
103+
104+
await waitFor(() => expect(container.querySelector('[role="alert"]')).toBeTruthy());
105+
expect(bannerLines(container)).toEqual([AMOUNT, NAME]);
106+
expect(toastLines()).toEqual([AMOUNT, NAME]);
107+
expect(container.textContent).not.toContain('; ');
108+
});
109+
110+
it('one located + one record-level rule: the inline mark, and one banner line for the other', async () => {
111+
const onSubmit = vi.fn().mockRejectedValue(
112+
ruleRefusal([{ field: 'best', message: BEST }, { field: '_record', message: AMOUNT }]),
113+
);
114+
const { container } = renderForm(onSubmit);
115+
const bestWrap = container.querySelector('[data-field="best"]') as HTMLElement;
116+
117+
submit();
118+
119+
await waitFor(() => expect(bestWrap.textContent).toContain(BEST));
120+
expect(bestWrap.querySelector('[aria-invalid="true"]')).toBeTruthy();
121+
expect(bannerLines(container)).toEqual([AMOUNT]);
122+
expect(toastLines()).toEqual([AMOUNT]);
123+
});
124+
125+
it('a single record-level rule is unchanged: its message, once', async () => {
126+
const onSubmit = vi.fn().mockRejectedValue(ruleRefusal([{ field: '_record', message: AMOUNT }]));
127+
const { container } = renderForm(onSubmit);
128+
129+
submit();
130+
131+
await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
132+
expect(toastErrorSpy.mock.calls[0][0]).toBe(AMOUNT);
133+
expect(bannerLines(container)).toEqual([AMOUNT]);
134+
expect(container.querySelector('[aria-invalid="true"]')).toBeNull();
135+
});
136+
});

‎ui/packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,11 @@ describe('form renderer — server-rejected fields', () => {
135135
expect(screen.getByText('Stage is not a valid option')).toBeTruthy();
136136
});
137137

138-
it('falls back to the generic banner when a rejected field has no input to carry it', async () => {
138+
it('says in the banner the rejected field that has no input to carry it', async () => {
139139
// A field the form does not render cannot be marked inline. Taking over the
140140
// failure anyway would silently drop the part the user cannot see, so the
141-
// top-level message — which names every field — is shown instead.
141+
// banner says it — and only it: the other reason is under its input
142+
// (objectstack-ai/objectstack#28053).
142143
const onSubmit = vi.fn().mockRejectedValue(
143144
validationFailure([
144145
{ field: 'title', message: 'Title is required' },
@@ -150,7 +151,7 @@ describe('form renderer — server-rejected fields', () => {
150151
submit();
151152

152153
await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled());
153-
expect(String(toastErrorSpy.mock.calls[0][0])).toMatch(/Owner is required/);
154+
expect(toastErrorSpy.mock.calls[0][0]).toBe('Owner is required');
154155
});
155156

156157
it('a later client-side refusal retires the previous refusal`s banner and toast (objectstack#25874)', async () => {

0 commit comments

Comments
 (0)