From f936b92157de76e5ab940a0a4f32f29aba2cf239 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:07:53 +0000 Subject: [PATCH 1/3] fix(console): mark the fields a refused form submit names A refused submit on the console form page (public /f/:slug and the internal /forms/:name) now marks each field it names: aria-invalid on the control and the reason under it, for the client required check, a faulted visibleWhen (pointed at the banner that names it) and the server's field-level refusals, read with extractFieldErrors from @object-ui/react. The banner and the toast are unchanged. Claude-Session: https://claude.ai/code/session_01JQwxbX1Zhx8ST6DDF1AGvD Co-authored-by: Claude --- .../FormPage.refusalMarks-27417.test.tsx | 314 ++++++++++++++++++ ui/apps/console/src/components/FormPage.tsx | 178 ++++++++-- 2 files changed, 468 insertions(+), 24 deletions(-) create mode 100644 ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx diff --git a/ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx b/ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx new file mode 100644 index 00000000000..1cd8a316d59 --- /dev/null +++ b/ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx @@ -0,0 +1,314 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack-ai/objectstack#27417 — a refused submit marks the fields it names. + * + * Every refusal on this page used to end in one string, on the banner and the + * outcome toast, and nothing on the inputs: the submitter read "Required: Name, + * Email" or a server sentence and had to find the rows themselves. Each refused + * row now carries `aria-invalid` and a reason under its control, which the + * control names in `aria-describedby`. + * + * The server half reads the refusal with the record dialog's reader of the + * server's field paths, `extractFieldErrors` (`@object-ui/react`); this page + * only attaches the response body to the error it throws. So the fixtures below + * are the envelopes the server actually answers: the flat `400 + * VALIDATION_FAILED` with `fields[]` the public submit route maps a validation + * error to, the same refusal in the runtime dispatcher's wrapped envelope, and + * the flat `409 UNIQUE_VIOLATION` naming its one `field`. + * + * The banner and the toast are unchanged: a refusal that names no field marks + * no row and still says what it said before. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { toast } from 'sonner'; +import { createI18n } from '@object-ui/i18n'; +import { FormPage } from './FormPage'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const FIELDS = { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + company: { type: 'text', label: 'Company' }, +}; + +function publicPayload(sectionFields: unknown[], fields: Record = FIELDS) { + return { + slug: 'contact-us', + object: 'showcase_inquiry', + label: 'Contact us', + form: { type: 'simple', sections: [{ fields: sectionFields }] }, + objectSchema: { name: 'showcase_inquiry', fields }, + }; +} + +let posts = 0; + +/** Answers the resolver, and every submit with `refusal` (status + JSON body). */ +function stubPublic( + sectionFields: unknown[], + refusal: { status: number; body: unknown }, + fields?: Record, +) { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method === 'POST') { + posts += 1; + return { + ok: false, + status: refusal.status, + statusText: 'Refused', + text: async () => JSON.stringify(refusal.body), + json: async () => refusal.body, + } as unknown as Response; + } + if (!String(url).includes('/forms/')) throw new Error(`unstubbed fetch: ${url}`); + const payload = publicPayload(sectionFields, fields); + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => payload, + text: async () => JSON.stringify(payload), + } as unknown as Response; + }), + ); + return render( + + + } /> + + , + ); +} + +/** The internal route, refusing its `POST /data/:object` with `refusal`. */ +function stubInternal(refusal: { status: number; body: unknown }) { + const view = { + name: 'showcase_inquiry.contact', + object: 'showcase_inquiry', + viewKind: 'form', + label: 'Contact', + config: { type: 'simple', sections: [{ fields: ['name', 'email', 'company'] }] }, + }; + const object = { name: 'showcase_inquiry', label: 'Inquiry', fields: FIELDS }; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + const answer = (ok: boolean, status: number, body: unknown) => + ({ + ok, + status, + statusText: ok ? 'OK' : 'Refused', + json: async () => body, + text: async () => JSON.stringify(body), + }) as unknown as Response; + if (init?.method === 'POST') { + posts += 1; + return answer(false, refusal.status, refusal.body); + } + if (String(url).includes('/meta/view/')) return answer(true, 200, view); + if (String(url).includes('/meta/object/')) return answer(true, 200, object); + throw new Error(`unstubbed fetch: ${url}`); + }), + ); + return render( + + + } /> + + , + ); +} + +/** The text a control names in `aria-describedby`, or null when it names none. */ +function describedText(control: HTMLElement): string | null { + const id = control.getAttribute('aria-describedby'); + if (!id) return null; + return id + .split(/\s+/) + .map((ref) => document.getElementById(ref)?.textContent ?? '') + .join(' ') + .trim(); +} + +async function fillAndSubmit(fill: Record) { + await screen.findByLabelText(/^Name/); + for (const [label, value] of Object.entries(fill)) { + await userEvent.type(screen.getByLabelText(label), value); + } + await userEvent.click(screen.getByRole('button', { name: /Submit/ })); +} + +const VALIDATION_FAILED_FIELDS = [ + { field: 'name', code: 'max_length', message: 'Name must be at most 3 characters' }, + { field: 'email', code: 'invalid_format', message: 'Email is not a valid email address' }, +]; + +beforeEach(() => { + posts = 0; + vi.mocked(toast.error).mockClear(); + // The page's chrome reads the `en` pack through react-i18next's global, as + // the sibling FormPage suites set it up. + createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('#27417 — a server refusal naming fields marks each of them', () => { + it('public route: the flat 400 VALIDATION_FAILED marks both named fields and no other', async () => { + stubPublic(['name', 'email', 'company'], { + status: 400, + body: { error: 'Validation failed', code: 'VALIDATION_FAILED', fields: VALIDATION_FAILED_FIELDS }, + }); + await fillAndSubmit({ Name: 'Ada Lovelace', Email: 'ada' }); + + const name = screen.getByLabelText('Name'); + const email = screen.getByLabelText('Email'); + await waitFor(() => expect(name).toHaveAttribute('aria-invalid', 'true')); + expect(email).toHaveAttribute('aria-invalid', 'true'); + expect(describedText(name)).toContain('Name must be at most 3 characters'); + expect(describedText(email)).toContain('Email is not a valid email address'); + // The row the refusal does not name stays unmarked. + expect(screen.getByLabelText('Company')).not.toHaveAttribute('aria-invalid'); + // The banner and the toast are unchanged. + expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1); + expect(String(vi.mocked(toast.error).mock.calls[0][0])).toContain('Submit failed (400)'); + }); + + it('internal route: the wrapped dispatcher envelope marks both named fields', async () => { + stubInternal({ + status: 400, + body: { + success: false, + error: { + code: 'VALIDATION_FAILED', + message: 'Validation failed', + httpStatus: 400, + details: { fields: VALIDATION_FAILED_FIELDS }, + }, + }, + }); + await fillAndSubmit({ Name: 'Ada Lovelace', Email: 'ada' }); + + const name = screen.getByLabelText('Name'); + await waitFor(() => expect(name).toHaveAttribute('aria-invalid', 'true')); + expect(screen.getByLabelText('Email')).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByLabelText('Company')).not.toHaveAttribute('aria-invalid'); + expect(describedText(name)).toContain('Name must be at most 3 characters'); + }); + + it('marks the one field a 409 UNIQUE_VIOLATION names, with the server sentence', async () => { + stubPublic(['name', 'email', 'company'], { + status: 409, + body: { + error: 'An inquiry with this email already exists.', + code: 'UNIQUE_VIOLATION', + field: 'email', + object: 'showcase_inquiry', + }, + }); + await fillAndSubmit({ Name: 'Ada', Email: 'ada@example.com' }); + + const email = screen.getByLabelText('Email'); + await waitFor(() => expect(email).toHaveAttribute('aria-invalid', 'true')); + expect(describedText(email)).toContain('An inquiry with this email already exists.'); + expect(screen.getByLabelText('Name')).not.toHaveAttribute('aria-invalid'); + }); + + it('an edit to a marked row clears its mark and leaves the others', async () => { + stubPublic(['name', 'email', 'company'], { + status: 400, + body: { error: 'Validation failed', code: 'VALIDATION_FAILED', fields: VALIDATION_FAILED_FIELDS }, + }); + await fillAndSubmit({ Name: 'Ada Lovelace', Email: 'ada' }); + const name = screen.getByLabelText('Name'); + await waitFor(() => expect(name).toHaveAttribute('aria-invalid', 'true')); + + await userEvent.type(name, '!'); + expect(name).not.toHaveAttribute('aria-invalid'); + expect(screen.queryByText('Name must be at most 3 characters')).toBeNull(); + expect(screen.getByLabelText('Email')).toHaveAttribute('aria-invalid', 'true'); + }); +}); + +describe('#27417 — a refusal naming no field marks none', () => { + it('a 400 with no field list leaves every row unmarked and keeps the banner', async () => { + stubPublic(['name', 'email', 'company'], { + status: 400, + body: { error: 'Invalid inquiry status transition.', code: 'VALIDATION_ERROR' }, + }); + await fillAndSubmit({ Name: 'Ada', Email: 'ada@example.com' }); + + expect(await screen.findByText(/Invalid inquiry status transition\./)).toBeInTheDocument(); + for (const label of ['Name', 'Email', 'Company']) { + expect(screen.getByLabelText(label)).not.toHaveAttribute('aria-invalid'); + expect(screen.getByLabelText(label)).not.toHaveAttribute('aria-describedby'); + } + }); + + it('a field the form does not draw marks nothing', async () => { + stubPublic(['name', 'email'], { + status: 400, + body: { + error: 'Validation failed', + code: 'VALIDATION_FAILED', + fields: [{ field: 'company', message: 'Company is required' }], + }, + }); + await fillAndSubmit({ Name: 'Ada', Email: 'ada@example.com' }); + + await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1)); + expect(screen.getByLabelText('Name')).not.toHaveAttribute('aria-invalid'); + expect(screen.getByLabelText('Email')).not.toHaveAttribute('aria-invalid'); + expect(screen.queryByText('Company is required')).toBeNull(); + }); +}); + +describe('#27417 — the client required refusal marks the missing fields', () => { + it('marks each required row left empty, writes nothing, and leaves the filled one', async () => { + stubPublic( + [{ field: 'name', required: true }, { field: 'email', required: true }, 'company'], + { status: 500, body: { error: 'must not be reached' } }, + ); + await fillAndSubmit({ Company: 'Analytical Engines' }); + + expect(await screen.findByText('Required: Name, Email')).toBeInTheDocument(); + expect(posts).toBe(0); + const name = screen.getByLabelText(/^Name/); + const email = screen.getByLabelText(/^Email/); + expect(name).toHaveAttribute('aria-invalid', 'true'); + expect(email).toHaveAttribute('aria-invalid', 'true'); + expect(describedText(name)).toBe('Name is required'); + expect(describedText(email)).toBe('Email is required'); + expect(screen.getByLabelText('Company')).not.toHaveAttribute('aria-invalid'); + }); +}); + +describe('#27417 — a faulted visibleWhen marks its row and points it at the banner', () => { + it('marks the faulted row, names the banner, and repeats no text under it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + stubPublic( + ['name', 'email', 'company'], + { status: 500, body: { error: 'must not be reached' } }, + { ...FIELDS, company: { type: 'text', label: 'Company', visibleWhen: 'record.name ==' } }, + ); + await fillAndSubmit({ Name: 'Ada', Email: 'ada@example.com' }); + + const banner = await screen.findByText(/visibleWhen rule of Company could not be evaluated/); + expect(posts).toBe(0); + const company = screen.getByLabelText('Company'); + expect(company).toHaveAttribute('aria-invalid', 'true'); + expect(company.getAttribute('aria-describedby')).toBe(banner.id); + expect(screen.getByLabelText('Name')).not.toHaveAttribute('aria-invalid'); + warn.mockRestore(); + }); +}); diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index ffa956c8fa9..ac937e40607 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -149,7 +149,12 @@ import { resolveFormWidgetType, } from '@object-ui/fields'; import { Tabs, TabsContent, TabsList, TabsTrigger, cn } from '@object-ui/components'; -import { SchemaRendererContext, usePredicateScope, type ApiFetch } from '@object-ui/react'; +import { + SchemaRendererContext, + extractFieldErrors, + usePredicateScope, + type ApiFetch, +} from '@object-ui/react'; import { pickLocalized, useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n'; import type { FormFieldSpec, FormSectionSpec, FormViewSpec } from '@object-ui/app-shell'; import type { ObjectFormSection } from '@object-ui/types'; @@ -1814,7 +1819,7 @@ async function submitPublic(slug: string, data: Record): Promis }); if (!res.ok) { const body = await res.text().catch(() => ''); - throw new Error(`Submit failed (${res.status}): ${body || res.statusText}`); + throw refusedWriteError(`Submit failed (${res.status}): ${body || res.statusText}`, body); } return res.json().catch(() => ({})); } @@ -1854,13 +1859,44 @@ async function submitInternal( ); if (!res.ok) { const body = await res.text().catch(() => ''); - throw new Error( + throw refusedWriteError( `${recordId ? 'Update' : 'Create'} failed (${res.status}): ${body || res.statusText}`, + body, ); } return res.json().catch(() => ({})); } +/** + * The error a refused submit throws (objectstack-ai/objectstack#27417): the + * message this page has always shown, plus the response body attached the way + * `@objectstack/client` attaches it to its own errors — the semantic `code`, + * and `details` from the wrapped envelope's `error.details`, else the whole + * flat body. + * + * That is what lets the record dialog's reader of the server's field paths, + * `extractFieldErrors` (`@object-ui/react`), read this page's refusals exactly + * as it reads the client's: the flat `400 VALIDATION_FAILED` with `fields[]` + * the submit routes answer, and the `409 UNIQUE_VIOLATION` naming its one + * `field`. ⛔ No field path is read here; a body that is not JSON attaches + * nothing, and the refusal marks no field. + */ +function refusedWriteError(message: string, body: string): Error { + const error = new Error(message) as Error & { code?: string; details?: unknown }; + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return error; + } + if (!parsed || typeof parsed !== 'object') return error; + const envelope = parsed as { code?: unknown; details?: unknown; error?: { code?: unknown; details?: unknown } }; + const code = [envelope.code, envelope.error?.code].find((c) => typeof c === 'string' && c); + if (typeof code === 'string') error.code = code; + error.details = envelope.details ?? envelope.error?.details ?? envelope; + return error; +} + // ─── Field renderers ───────────────────────────────────────────────── /** The DOM id of a row's control — the host owns it (objectui#3962). */ @@ -1873,6 +1909,14 @@ function labelIdOf(fieldName: string): string { return `f_${fieldName}-label`; } +/** + * The id of the message a refused submit puts under a row's control, which the + * control names in `aria-describedby` (objectstack-ai/objectstack#27417). + */ +function errorIdOf(fieldName: string): string { + return `f_${fieldName}-error`; +} + /** * The attributes a row's visible `