Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/28053-one-line-per-failed-rule.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});

Expand Down
37 changes: 29 additions & 8 deletions ui/apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -162,6 +162,7 @@ import {
import {
SchemaRendererContext,
extractFieldErrors,
unmarkedRefusalLines,
usePredicateScope,
type ApiFetch,
} from '@object-ui/react';
Expand Down Expand Up @@ -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 (
<ul className="list-disc space-y-1 pl-4">
{refusal.map((line, i) => <li key={i}>{line}</li>)}
</ul>
);
}

// ─── Field renderers ─────────────────────────────────────────────────

/** The DOM id of a row's control — the host owns it (objectui#3962). */
Expand Down Expand Up @@ -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<string | null>(null);
/** One string, or one line per reason a server refusal marked on no row. */
const [error, setError] = useState<string | string[] | null>(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
Expand Down Expand Up @@ -2856,16 +2871,22 @@ 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.
const drawn = sections.flatMap((s) => s.fields);
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')]),
));
Expand Down Expand Up @@ -2921,7 +2942,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
return (
<div className="mx-auto max-w-2xl p-6">
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
{error}
{refusalContent(error)}
</div>
</div>
);
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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)}
</div>
)}
<div className="flex flex-wrap items-center justify-end gap-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

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<unknown>) {
const Form = ComponentRegistry.get('form')!;
return render(
<Form
schema={{
type: 'form',
mode: 'create',
showSubmit: true,
showCancel: false,
submitLabel: 'Create',
fields: [
{ name: 'amount', label: 'Amount', type: 'input' },
{ name: 'best', label: 'Best case', type: 'input' },
],
onSubmit,
}}
/>,
);
}

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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 () => {
Expand Down
Loading
Loading