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..b6990806cac
--- /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-②: 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).
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..210c204e589 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,69 @@ 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 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 ?? '');
+}
+
+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/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 (
+
+ {refusal.map((line, i) => - {line}
)}
+
+ );
+}
+
// ─── 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/__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/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
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();
+ });
+});