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 ( + + ); +} + // ─── 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(); + }); +});