From 88e008eb623ed4fe0559c2bf9a23afdb46a23eb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:52:55 +0000 Subject: [PATCH 1/4] fix(ui): list each unmarked validation-rule refusal as its own banner line A refused write keeps one fields[] entry per failed rule; only its top-level message joins them with '; '. The form renderer and FormPage now mark what they can inline and say the rest one line per entry, falling back to the envelope's message when there is no entry list. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwcER4bPAfAhbS8zYY --- ui/apps/console/src/components/FormPage.tsx | 37 ++++++++--- .../components/src/renderers/form/form.tsx | 41 +++++++++--- ui/packages/react/src/index.ts | 2 +- ui/packages/react/src/utils/error-message.ts | 64 +++++++++++++++++-- 4 files changed, 120 insertions(+), 24 deletions(-) diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index b27577ca4a2..52a32cbcf08 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -116,7 +116,7 @@ * the host half (ids, label association, required, uploads). */ -import { Suspense, useContext, useEffect, useId, useMemo, useState, type FormEvent } from 'react'; +import { Suspense, useContext, useEffect, useId, useMemo, useState, type FormEvent, type ReactNode } from 'react'; import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { @@ -162,6 +162,7 @@ import { import { SchemaRendererContext, extractFieldErrors, + unmarkedRefusalLines, usePredicateScope, type ApiFetch, } from '@object-ui/react'; @@ -1948,6 +1949,19 @@ function refusedWriteError(message: string, body: string): Error { return error; } +/** + * A submit refusal as the banner and the toast draw it: one string as itself, + * several reasons as one list item each. + */ +function refusalContent(refusal: string | string[]): ReactNode { + if (typeof refusal === 'string') return refusal; + return ( + + ); +} + // ─── Field renderers ───────────────────────────────────────────────── /** 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) { const channel = mode === 'internal' ? hostApiFetch : undefined; const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + /** One string, or one line per reason a server refusal marked on no row. */ + const [error, setError] = useState(null); /** * The load answered "no such form" ({@link FormNotFoundError}). Its own flag * rather than a string in `error`, because the page draws a localized state @@ -2856,9 +2871,6 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { break; } } catch (err: any) { - const msg = err?.message ?? String(err); - setError(msg); - toast.error(msg, { id: outcomeToastId }); // The fields the server's refusal names, read by the record dialog's // reader (see `refusedWriteError`), marked where this form draws them. A // field the form does not draw marks nothing; the banner still says it. @@ -2866,6 +2878,15 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { const named = (extractFieldErrors(err) ?? []).filter((fe) => drawn.some((f) => f.name === fe.field), ); + // A refusal listing one entry per failed rule says each entry no row + // carries on its own line, not the envelope's `; `-joined summary. + const unmarked = unmarkedRefusalLines(err, (field) => named.some((fe) => fe.field === field)); + const msg: string | string[] = + unmarked && unmarked.length > 1 + ? unmarked + : unmarked?.[0] ?? err?.message ?? String(err); + setError(msg); + toast.error(refusalContent(msg), { id: outcomeToastId }); setFieldErrors(Object.fromEntries( named.map((fe) => [fe.field, fe.message || t('form.submitFailed')]), )); @@ -2921,7 +2942,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { return (
- {error} + {refusalContent(error)}
); @@ -3022,7 +3043,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { const refusal = mark === undefined ? undefined : mark === null - ? { message: error ?? '', describedBy: errorBannerId } + ? { message: [error ?? ''].flat().join(' '), describedBy: errorBannerId } : { message: mark, describedBy: errorIdOf(f.name) }; const spanClass = f.colSpan === 2 ? 'sm:col-span-2' @@ -3325,7 +3346,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { id={errorBannerId} className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive" > - {error} + {refusalContent(error)} )}
diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index b065892169c..13342973af4 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -42,11 +42,24 @@ import { FullscreenEditor } from '../../custom/fullscreen-editor'; import { CharacterCount } from '../../custom/character-count'; import { cn } from '../../lib/utils'; import React from 'react'; -import { SchemaRendererContext, usePredicateScope, isPermissionError, extractWriteErrorMessage, extractFieldErrors, declaredUserMessage, delegationScopeMessage } from '@object-ui/react'; +import { SchemaRendererContext, usePredicateScope, isPermissionError, extractWriteErrorMessage, extractFieldErrors, declaredUserMessage, delegationScopeMessage, unmarkedRefusalLines } from '@object-ui/react'; import { createSafeTranslation } from '@object-ui/i18n'; import type { RendererHostProps } from '../../lib/renderer-host-props'; import { currencyCodeFieldNames, useUsedCurrencyCodes, withUsedCurrencyCodes } from './usedCurrencyCodes'; +/** + * A submit refusal as the banner and the toast draw it: one string as itself, + * several reasons as one list item each. + */ +function refusalContent(refusal: string | string[]): React.ReactNode { + if (typeof refusal === 'string') return refusal; + return ( +
    + {refusal.map((line, i) =>
  • {line}
  • )} +
+ ); +} + /** * Whether a `section-divider` row draws anything: a heading, a blurb, or both. * A row with neither (a headingless section's gate row) renders nothing and @@ -1810,7 +1823,8 @@ ComponentRegistry.register('form', const formRef = React.useRef(null); const [isSubmitting, setIsSubmitting] = React.useState(false); - const [submitError, setSubmitError] = React.useState(null); + /** One string, or one line per reason the refusal could not mark inline. */ + const [submitError, setSubmitError] = React.useState(null); /** * The sonner id every OUTCOME toast of THIS form is published under. * @@ -3060,10 +3074,14 @@ ComponentRegistry.register('form', // toast — telling the user something was wrong, but not WHAT, on a // surface that already knows how to mark the input. Mark them. const fieldErrors = extractFieldErrors(error); + // The fields whose reason is now written under a DRAWN input; the + // banner says only the rest. + const shownInline = new Set(); if (fieldErrors) { const known = fieldErrors.filter((fe) => (fields as FormFieldConfig[]).some((f) => f?.name === fe.field), ); + for (const fe of known) if (fieldWrapper(fe.field)) shownInline.add(fe.field); for (const fe of known) { form.setError(fe.field, { type: 'server', @@ -3074,9 +3092,8 @@ ComponentRegistry.register('form', } // Only take over the whole failure when EVERY rejected field has a // visible input to carry it. If the server also rejected something - // 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. + // this form does not render, fall through: the banner says each + // reason the user cannot see inline, so none is 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. @@ -3108,19 +3125,25 @@ ComponentRegistry.register('form', // boundary to fix (objectstack#26059, Q3). It is read before // `isPermissionError`, which answers true for any 403; every other // denial keeps the fixed line (objectstack#24914 B′). - const errorMessage = + // A refusal listing one entry per failed rule is said one line per + // entry left unmarked above, not as the envelope's `; `-joined + // summary, which would also repeat the reasons already under inputs. + const unmarked = unmarkedRefusalLines(error, (field) => shownInline.has(field)); + const errorMessage: string | string[] = declaredUserMessage(error) ?? delegationScopeMessage(error) ?? (isPermissionError(error) ? t('form.noPermissionToSave') - : extractWriteErrorMessage(error) ?? t('form.submitFailed')); + : unmarked && unmarked.length > 1 + ? unmarked + : unmarked?.[0] ?? extractWriteErrorMessage(error) ?? t('form.submitFailed')); if (isPermissionError(error) && typeof console !== 'undefined') { console.warn('[form] write denied:', error); } setSubmitError(errorMessage); // Also surface as a toast so the message is visible even when the // in-form banner has scrolled out of view (long forms in modals/drawers). - toast.error(errorMessage, { id: outcomeToastId }); + toast.error(refusalContent(errorMessage), { id: outcomeToastId }); // Log errors for debugging (dev environment only) // process may not be defined in all environments @@ -4055,7 +4078,7 @@ ComponentRegistry.register('form', {submitError && ( - {submitError} + {refusalContent(submitError)} )} diff --git a/ui/packages/react/src/index.ts b/ui/packages/react/src/index.ts index 8b30f9abbfd..bad1a9830a6 100644 --- a/ui/packages/react/src/index.ts +++ b/ui/packages/react/src/index.ts @@ -84,7 +84,7 @@ export type { PredicateGateKind } from './utils/visibilityDiagnostic.js'; // Write-error surfacing utilities (shared by drag-write plugins so a failed // PATCH — e.g. an RLS 403 — is never silently swallowed). -export { extractWriteErrorMessage, isPermissionError, extractFieldErrors, classifyLoadError, declaredUserMessage, delegationScopeMessage } from './utils/error-message.js'; +export { extractWriteErrorMessage, isPermissionError, extractFieldErrors, classifyLoadError, declaredUserMessage, delegationScopeMessage, unmarkedRefusalLines } from './utils/error-message.js'; export type { WriteFieldError, LoadErrorKind } from './utils/error-message.js'; // Built-in i18n support diff --git a/ui/packages/react/src/utils/error-message.ts b/ui/packages/react/src/utils/error-message.ts index d3015007304..a38603edbf2 100644 --- a/ui/packages/react/src/utils/error-message.ts +++ b/ui/packages/react/src/utils/error-message.ts @@ -220,12 +220,8 @@ export function extractFieldErrors(err: unknown): WriteFieldError[] | null { const e = err as Record | null | undefined; if (!e || typeof e !== 'object') return null; - const raw: unknown = - (Array.isArray(e.validationErrors) && e.validationErrors) || - (Array.isArray(e.details?.fields) && e.details.fields) || - (Array.isArray(e.fields) && e.fields) || - null; - if (!raw || !Array.isArray(raw)) { + const raw = fieldEntries(e); + if (!raw) { if (!errorCodeIs(e, UNIQUE_VIOLATION) && !errorCodeIs(e.details, UNIQUE_VIOLATION)) return null; const field = firstString(e.details?.field, e.field); return field ? [{ field, message: extractWriteErrorMessage(err) ?? '' }] : null; @@ -246,6 +242,62 @@ export function extractFieldErrors(err: unknown): WriteFieldError[] | null { return out.length > 0 ? out : null; } +/** + * The `fields[]` list of a rejected write, from whichever side of the adapter + * boundary it arrives on — the three shapes {@link extractFieldErrors} reads. + */ +function fieldEntries(e: Record): unknown[] | null { + return ( + (Array.isArray(e.validationErrors) && e.validationErrors) || + (Array.isArray(e.details?.fields) && e.details.fields) || + (Array.isArray(e.fields) && e.fields) || + null + ); +} + +/** + * The form-level lines of a rejected write: one per `fields[]` entry the + * caller could NOT show under an input, in the order the server sent them. + * + * The server keeps one entry per failed rule — located on the field the rule + * names, else on `_record` — and only its top-level `message` joins them with + * `; `. A banner that shows that joined text reads two broken rules as one + * run-on sentence, and repeats whatever is already written under an input. So + * a caller marks what it can (`extractFieldErrors` → its inputs) and says the + * rest here, one line per entry. + * + * `isShownInline` answers, per entry's field, whether the caller drew that + * entry's message under a visible input. An entry with no usable field is + * never shown inline. + * + * Answers `null` — the caller keeps the envelope's top-level message — when + * the refusal carries no entry list, or when an entry it would have to say + * carries no human-readable `message`: the server builds the top-level text + * for such an entry, and an untranslated enum on its own line reads as a bug. + * Answers `[]` when every entry is shown inline. + */ +export function unmarkedRefusalLines( + err: unknown, + isShownInline: (field: string) => boolean, +): string[] | null { + const e = err as Record | null | undefined; + if (!e || typeof e !== 'object') return null; + const raw = fieldEntries(e); + if (!raw) return null; + + const lines: string[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== 'object') continue; + const rec = entry as Record; + const field = firstString(rec.field, rec.name, rec.path); + if (field && isShownInline(field)) continue; + const message = firstString(rec.message, rec.error); + if (!message) return null; + lines.push(message); + } + return lines; +} + /** * True when a failed write was rejected for authorization reasons (HTTP 403 / * `PERMISSION_DENIED` / `FORBIDDEN` / a row-level-security denial). Lets a From bf9f9fdd9004cdb24120919271274ea436107eed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:56:42 +0000 Subject: [PATCH 2/4] test(ui): pin one banner line per unmarked validation-rule refusal Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwcER4bPAfAhbS8zYY --- .changeset/28053-one-line-per-failed-rule.md | 10 ++ .../FormPage.refusalMarks-27417.test.tsx | 64 ++++++++- .../form-refusal-lines-28053.test.tsx | 136 ++++++++++++++++++ .../form-server-field-errors.test.tsx | 7 +- ...ror-message.unmarked-refusal-lines.test.ts | 103 +++++++++++++ 5 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 .changeset/28053-one-line-per-failed-rule.md create mode 100644 ui/packages/components/src/renderers/form/__tests__/form-refusal-lines-28053.test.tsx create mode 100644 ui/packages/react/src/utils/error-message.unmarked-refusal-lines.test.ts diff --git a/.changeset/28053-one-line-per-failed-rule.md b/.changeset/28053-one-line-per-failed-rule.md new file mode 100644 index 00000000000..9da87c99402 --- /dev/null +++ b/.changeset/28053-one-line-per-failed-rule.md @@ -0,0 +1,10 @@ +--- +'@object-ui/react': patch +'@object-ui/components': patch +'@object-ui/console': patch +--- + +Clause-②: no +Metadata-protocol: none + +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). diff --git a/ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx b/ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx index 1cda6f8c015..49c69aa1afe 100644 --- a/ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx +++ b/ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx @@ -21,6 +21,7 @@ * no row and still says what it said before. */ +import type React from 'react'; import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -270,7 +271,68 @@ describe('#27417 — a refusal naming no field marks none', () => { await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1)); expect(screen.getByLabelText('Name')).not.toHaveAttribute('aria-invalid', 'true'); expect(screen.getByLabelText('Email')).not.toHaveAttribute('aria-invalid', 'true'); - expect(screen.queryByText('Company is required')).toBeNull(); + // No row carries the reason; the banner says it (#28053). + expect(document.querySelectorAll('[id$="-error"]')).toHaveLength(0); + expect(vi.mocked(toast.error).mock.calls[0][0]).toBe('Company is required'); + }); +}); + +/** The banner's list items, as text. */ +function bannerItems(): string[] { + return Array.from(document.querySelectorAll('[id^="form-error:"] li')).map((li) => li.textContent ?? ''); +} + +/** The last toast's content, drawn, as its list items. */ +function toastItems(): string[] { + const content = vi.mocked(toast.error).mock.calls.at(-1)![0] as React.ReactNode; + const { container } = render(<>{content}); + return Array.from(container.querySelectorAll('li')).map((li) => li.textContent ?? ''); +} + +describe('#28053 — one entry per failed validation rule', () => { + const AMOUNT = 'Amount must be positive.'; + const TERMS = 'Accept the terms before sending.'; + + it('two record-level rules: two banner lines and two toast lines, no joined summary', async () => { + stubPublic(['name', 'email', 'company'], { + status: 400, + body: { + error: `${AMOUNT}; ${TERMS}`, + code: 'VALIDATION_FAILED', + fields: [ + { field: '_record', code: 'rule_violation', message: AMOUNT }, + { field: '_record', code: 'rule_violation', message: TERMS }, + ], + }, + }); + await fillAndSubmit({ Name: 'Ada', Email: 'ada@example.com' }); + + await waitFor(() => expect(bannerItems()).toEqual([AMOUNT, TERMS])); + expect(toastItems()).toEqual([AMOUNT, TERMS]); + expect(screen.queryByText(/Submit failed/)).toBeNull(); + expect(document.querySelectorAll('[id$="-error"]')).toHaveLength(0); + }); + + it('one located + one record-level rule: the row carries its reason, the banner says the other', async () => { + stubPublic(['name', 'email', 'company'], { + status: 400, + body: { + error: `Email is not a valid email address; ${AMOUNT}`, + code: 'VALIDATION_FAILED', + fields: [ + { field: 'email', code: 'rule_violation', message: 'Email is not a valid email address' }, + { field: '_record', code: 'rule_violation', message: AMOUNT }, + ], + }, + }); + await fillAndSubmit({ Name: 'Ada', Email: 'ada' }); + + const email = screen.getByLabelText('Email'); + await waitFor(() => expect(email).toHaveAttribute('aria-invalid', 'true')); + expect(describedText(email)).toContain('Email is not a valid email address'); + expect(vi.mocked(toast.error).mock.calls[0][0]).toBe(AMOUNT); + expect(await screen.findByText(AMOUNT)).toBeInTheDocument(); + expect(screen.queryByText(/Submit failed/)).toBeNull(); }); }); diff --git a/ui/packages/components/src/renderers/form/__tests__/form-refusal-lines-28053.test.tsx b/ui/packages/components/src/renderers/form/__tests__/form-refusal-lines-28053.test.tsx new file mode 100644 index 00000000000..2ee22702d8e --- /dev/null +++ b/ui/packages/components/src/renderers/form/__tests__/form-refusal-lines-28053.test.tsx @@ -0,0 +1,136 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Two failed validation rules on one save are said as two entries + * (objectstack-ai/objectstack#28053). + * + * The server keeps one `fields[]` entry per failed rule — on the field the rule + * names, else on `_record` — and joins only the top-level message with `; `. + * The banner and the toast used to show that joined text, so two broken rules + * read as one run-on sentence. Each rule now gets its own line, except the ones + * already written under an input. + */ + +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup, within } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { toast } from '../../../ui/sonner'; +// Registers the renderers at module scope, NOT inside a `beforeAll` — see +// object-ui/no-dynamic-import-in-test-hook. +import '../../../renderers'; + +let toastErrorSpy: ReturnType; + +beforeEach(() => { + toastErrorSpy = vi.spyOn(toast, 'error').mockImplementation(() => 'id' as any); + if (!(Element.prototype as any).scrollIntoView) { + (Element.prototype as any).scrollIntoView = () => {}; + } +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +/** The body `POST /data/:object` answers, as `@objectstack/client` decorates it. */ +function ruleRefusal(fields: Array<{ field: string; message: string }>) { + const message = fields.map((f) => f.message).join('; '); + const wire = fields.map((f) => ({ ...f, code: 'rule_violation' })); + return Object.assign(new Error(message), { + code: 'VALIDATION_FAILED', + httpStatus: 400, + details: { error: message, code: 'VALIDATION_FAILED', fields: wire, object: 'forecast' }, + }); +} + +function renderForm(onSubmit: (data: any) => Promise) { + const Form = ComponentRegistry.get('form')!; + return render( +
, + ); +} + +const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i })); + +/** The banner's text lines: its list items, or its one string. */ +function bannerLines(container: HTMLElement): string[] { + const banner = container.querySelector('[role="alert"]') as HTMLElement; + const items = within(banner).queryAllByRole('listitem'); + return items.length > 0 ? items.map((li) => li.textContent ?? '') : [banner.textContent ?? '']; +} + +/** The toast's content, drawn, as text lines. */ +function toastLines(): string[] { + const content = toastErrorSpy.mock.calls.at(-1)![0] as React.ReactNode; + if (typeof content === 'string') return [content]; + const { container } = render(<>{content}); + return Array.from(container.querySelectorAll('li')).map((li) => li.textContent ?? ''); +} + +const AMOUNT = 'Amount must be positive.'; +const NAME = 'Name is required for a forecast.'; +const BEST = 'Best case must be at least the amount.'; + +describe('form renderer — one entry per failed validation rule (#28053)', () => { + it('two record-level rules: two banner lines and two toast lines, no joined summary', async () => { + const onSubmit = vi.fn().mockRejectedValue( + ruleRefusal([{ field: '_record', message: AMOUNT }, { field: '_record', message: NAME }]), + ); + const { container } = renderForm(onSubmit); + + submit(); + + await waitFor(() => expect(container.querySelector('[role="alert"]')).toBeTruthy()); + expect(bannerLines(container)).toEqual([AMOUNT, NAME]); + expect(toastLines()).toEqual([AMOUNT, NAME]); + expect(container.textContent).not.toContain('; '); + }); + + it('one located + one record-level rule: the inline mark, and one banner line for the other', async () => { + const onSubmit = vi.fn().mockRejectedValue( + ruleRefusal([{ field: 'best', message: BEST }, { field: '_record', message: AMOUNT }]), + ); + const { container } = renderForm(onSubmit); + const bestWrap = container.querySelector('[data-field="best"]') as HTMLElement; + + submit(); + + await waitFor(() => expect(bestWrap.textContent).toContain(BEST)); + expect(bestWrap.querySelector('[aria-invalid="true"]')).toBeTruthy(); + expect(bannerLines(container)).toEqual([AMOUNT]); + expect(toastLines()).toEqual([AMOUNT]); + }); + + it('a single record-level rule is unchanged: its message, once', async () => { + const onSubmit = vi.fn().mockRejectedValue(ruleRefusal([{ field: '_record', message: AMOUNT }])); + const { container } = renderForm(onSubmit); + + submit(); + + await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled()); + expect(toastErrorSpy.mock.calls[0][0]).toBe(AMOUNT); + expect(bannerLines(container)).toEqual([AMOUNT]); + expect(container.querySelector('[aria-invalid="true"]')).toBeNull(); + }); +}); 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 fbfe07fb243..50d463af996 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 @@ -135,10 +135,11 @@ describe('form renderer — server-rejected fields', () => { expect(screen.getByText('Stage is not a valid option')).toBeTruthy(); }); - it('falls back to the generic banner when a rejected field has no input to carry it', async () => { + it('says in the banner the rejected field that has no input to carry it', async () => { // A field the form does not render cannot be marked inline. Taking over the // failure anyway would silently drop the part the user cannot see, so the - // top-level message — which names every field — is shown instead. + // banner says it — and only it: the other reason is under its input + // (objectstack-ai/objectstack#28053). const onSubmit = vi.fn().mockRejectedValue( validationFailure([ { field: 'title', message: 'Title is required' }, @@ -150,7 +151,7 @@ describe('form renderer — server-rejected fields', () => { submit(); await waitFor(() => expect(toastErrorSpy).toHaveBeenCalled()); - expect(String(toastErrorSpy.mock.calls[0][0])).toMatch(/Owner is required/); + expect(toastErrorSpy.mock.calls[0][0]).toBe('Owner is required'); }); it('a later client-side refusal retires the previous refusal`s banner and toast (objectstack#25874)', async () => { diff --git a/ui/packages/react/src/utils/error-message.unmarked-refusal-lines.test.ts b/ui/packages/react/src/utils/error-message.unmarked-refusal-lines.test.ts new file mode 100644 index 00000000000..c23166017fe --- /dev/null +++ b/ui/packages/react/src/utils/error-message.unmarked-refusal-lines.test.ts @@ -0,0 +1,103 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `unmarkedRefusalLines` — one form-level line per refused entry no input + * carries (objectstack-ai/objectstack#28053). + * + * The fixture is the body `POST /api/v1/data/:object` answers when two + * validation rules fail on one save, as measured through the REST route over a + * real engine: one `fields[]` entry per rule, the located one on the field the + * rule names, the other on `_record`, and only the top-level `error` joined + * with `; `. + */ + +import { describe, it, expect } from 'vitest'; + +import { unmarkedRefusalLines } from './error-message'; + +const twoRules = { + error: 'Best case must be at least the amount.; Amount must be positive.', + code: 'VALIDATION_FAILED', + fields: [ + { field: 'best', code: 'rule_violation', message: 'Best case must be at least the amount.' }, + { field: '_record', code: 'rule_violation', message: 'Amount must be positive.' }, + ], + object: 'forecast', +}; + +/** The raw `@objectstack/client` error: the whole body lands on `details`. */ +const clientError = (body: Record) => + Object.assign(new Error(String(body.error)), { code: body.code, httpStatus: 400, details: body }); + +const none = () => false; + +describe('unmarkedRefusalLines', () => { + it('says each rule on its own line when no input carries any of them', () => { + expect(unmarkedRefusalLines(clientError(twoRules), none)).toEqual([ + 'Best case must be at least the amount.', + 'Amount must be positive.', + ]); + }); + + it('leaves out the entry an input already carries', () => { + expect(unmarkedRefusalLines(clientError(twoRules), (f) => f === 'best')).toEqual([ + 'Amount must be positive.', + ]); + }); + + it('answers an empty list when every entry is carried inline', () => { + expect(unmarkedRefusalLines(clientError(twoRules), () => true)).toEqual([]); + }); + + it('says two record-level rules as two lines, never the joined summary', () => { + const body = { + ...twoRules, + error: 'Amount must be positive.; Name is required for a forecast.', + fields: [ + { field: '_record', code: 'rule_violation', message: 'Amount must be positive.' }, + { field: '_record', code: 'rule_violation', message: 'Name is required for a forecast.' }, + ], + }; + expect(unmarkedRefusalLines(clientError(body), none)).toEqual([ + 'Amount must be positive.', + 'Name is required for a forecast.', + ]); + }); + + it('reads the adapter ValidationError and a hand-rolled error alike', () => { + const adapter = Object.assign(new Error(twoRules.error), { + validationErrors: twoRules.fields.map(({ field, message }) => ({ field, message })), + }); + const handRolled = { message: twoRules.error, fields: twoRules.fields }; + expect(unmarkedRefusalLines(adapter, none)).toEqual(unmarkedRefusalLines(clientError(twoRules), none)); + expect(unmarkedRefusalLines(handRolled, none)).toEqual(unmarkedRefusalLines(clientError(twoRules), none)); + }); + + it('says an entry that names no field, since no input can carry it', () => { + const body = { ...twoRules, fields: [{ code: 'rule_violation', message: 'Amount must be positive.' }] }; + expect(unmarkedRefusalLines(clientError(body), () => true)).toEqual(['Amount must be positive.']); + }); + + it('answers null when an entry it would say has no readable message', () => { + // The server builds the top-level text for such an entry; an enum on a + // line of its own reads as a bug, so the caller keeps the envelope's text. + const body = { + ...twoRules, + fields: [twoRules.fields[1], { field: '_record', code: 'rule_violation' }], + }; + expect(unmarkedRefusalLines(clientError(body), none)).toBeNull(); + }); + + it('answers null when the refusal carries no entry list', () => { + expect(unmarkedRefusalLines(new Error('insufficient privileges'), none)).toBeNull(); + expect(unmarkedRefusalLines(clientError({ error: 'gone', code: 'UNIQUE_VIOLATION', field: 'sku' }), none)).toBeNull(); + expect(unmarkedRefusalLines(null, none)).toBeNull(); + expect(unmarkedRefusalLines('Validation failed', none)).toBeNull(); + }); +}); From 767d45bbe856fbe081b8b742a4f5b009f91b5c48 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:36:58 +0000 Subject: [PATCH 3/4] test(ui): read the last toast call by index, which the console's lib target types Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwcER4bPAfAhbS8zYY --- .../src/components/FormPage.refusalMarks-27417.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx b/ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx index 49c69aa1afe..210c204e589 100644 --- a/ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx +++ b/ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx @@ -284,7 +284,8 @@ function bannerItems(): string[] { /** The last toast's content, drawn, as its list items. */ function toastItems(): string[] { - const content = vi.mocked(toast.error).mock.calls.at(-1)![0] as React.ReactNode; + const calls = vi.mocked(toast.error).mock.calls; + const content = calls[calls.length - 1][0] as React.ReactNode; const { container } = render(<>{content}); return Array.from(container.querySelectorAll('li')).map((li) => li.textContent ?? ''); } From f4877f89a276058b9bcbcfcd8b5e3711bd880330 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:21:13 +0000 Subject: [PATCH 4/4] chore(changeset): declare the new public export as a widening Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibwcER4bPAfAhbS8zYY --- .changeset/28053-one-line-per-failed-rule.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/28053-one-line-per-failed-rule.md b/.changeset/28053-one-line-per-failed-rule.md index 9da87c99402..b6990806cac 100644 --- a/.changeset/28053-one-line-per-failed-rule.md +++ b/.changeset/28053-one-line-per-failed-rule.md @@ -4,7 +4,7 @@ '@object-ui/console': patch --- -Clause-②: no +Clause-②: yes (widening: @object-ui/react gains the public export unmarkedRefusalLines; no accept set moves) Metadata-protocol: none 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).