Skip to content

Commit ddbd08e

Browse files
fix(console): a refused form submit marks the fields it names; markdown placeholder in sans (#27443)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8BctHrSkyXGrwQzN3KmQW7Q)_ Fixes #27417 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Console 表单页(公开 `/f/:slug`、`/forms/:name`、应用内表单)提交被拒时,逐个标出被拒字段(`aria-invalid` + 控件下方原因);markdown/html/richtext 编辑器的占位文字改用无衬线字体,输入的源码仍是等宽。 - 为什么改:HotCRM 18.2.0 浏览器走查(#27355 第 9、10 行):拒绝只给横幅和 toast,不指明哪个输入框;描述框占位文字是等宽字体。 - 风险与回滚:只改界面标记与一个 CSS 类,横幅与 toast 原样保留;回滚即还原本 PR 两个源文件。 - 先看:`ui/apps/console/src/components/FormPage.tsx`(`refusedWriteError` 与 catch 里的 `extractFieldErrors`)、`ui/packages/fields/src/widgets/RichTextField.tsx`、新测试 `FormPage.refusalMarks-27417.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/components/FormPage.refusalMarks-27417.test.tsx`,预期 8 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes - **Row 10, field marks (`@object-ui/console`, `FormPage.tsx`).** Every refusal on the form page used to end in one string on the banner and the outcome toast. Each refused row is now marked: `aria-invalid="true"` on its control and the reason under the control, which the control names in `aria-describedby`. - Client required check: each empty required row, with the record form's `validation.required` message ("Name is required"). - Faulted `visibleWhen`: the row is marked and points at the banner, which already names the field and the rule, so no sentence is repeated under it. - Server refusals: the record dialog's reader of the server's field paths, `extractFieldErrors` from `@object-ui/react`, reads the refusal. No second parser was written. `submitPublic` / `submitInternal` used to throw a bare `Error(text)`, which lost the field paths. They now throw `refusedWriteError`, which keeps the same message and attaches the parsed body the way `@objectstack/client` attaches it (`code`, and `details` = wrapped `error.details`, else the flat body). That covers the flat `400 VALIDATION_FAILED` with `fields[]`, the wrapped dispatcher envelope, and the flat `409 UNIQUE_VIOLATION` naming its `field`. Only the rows the form draws are marked. - A refusal that names no field marks nothing. On a tabbed or wizard view the first marked row's tab or step opens, through one `revealRefused` shared by the client and server paths (the client path's existing behaviour, moved, not changed). Editing a marked row clears its mark. The banner and the toast are unchanged. - **Row 9, placeholder font (`@object-ui/fields`, `RichTextField.tsx`).** The markdown / html / richtext textarea is `font-mono` on every host, and its placeholder inherited that. It now carries `placeholder:font-sans`, so typed source stays mono and the placeholder reads as prose. This widened the claimed file surface; the seat approved it in its thread. - **Row 9, brand colour: no code change.** Measured below: the in-app route already takes the app's colour. The app-less routes know no app, and that half is an open question for the seat. - Changeset: `@object-ui/console` patch, `@object-ui/fields` patch. ## Why HotCRM's browser pass on 18.2.0 (#27355 rows 9 and 10): a refused public-form submit showed a toast and a banner but marked no field, and the form's description placeholder was monospace. ## Tests - New `ui/apps/console/src/components/FormPage.refusalMarks-27417.test.tsx`, 8 tests. A server refusal naming A and B marks both and not the third row, on the public route (flat envelope) and the internal route (wrapped envelope). A 409 UNIQUE_VIOLATION marks its one field. An edit clears one mark and keeps the other. A 400 naming no field marks none. A field the form does not draw marks nothing. The client required refusal marks both empty required rows and writes nothing. A faulted `visibleWhen` row points at the banner. - New `ui/packages/fields/src/widgets/__tests__/RichTextField.placeholderFont-27417.test.tsx`, 3 tests (markdown, html, richtext): the control keeps `font-mono` and carries `placeholder:font-sans`. jsdom computes no placeholder style, so the computed font is the Chromium reading below. - Ablation (one-time, through `scripts/ablation-replace.mjs`, fix committed first): row `mark` forced to `undefined` makes 6 of 8 red. The 2 that stay green are the "marks none" pins, as predicted. Restored blob matches `HEAD`. - Regression at head d32c6b1: every `FormPage*.test` plus the fields RichText, aria-invalid and dom-leak suites, 47 files and 739 tests, all passed. `pnpm --filter @object-ui/console --filter @object-ui/fields run type-check` exit 0. ## Acceptance notes - Seat 2's open PR #27305 also edits `FormPage.tsx`, in its `FORM_SPEC_TYPES` hunk only. That hunk is disjoint from these, and whichever lands second merges main. - Row 9's app-less half is left open. `/forms/:name` renders in the app-independent Home layout, and the anonymous `GET /forms/:slug` payload is `slug, object, label, form, objectSchema`, with no app and no branding. The app metadata read is authenticated, so no server read was invented. It is reported to the seat as an open question with options. - The placeholder takes Tailwind's `font-sans` stack, which is a system sans stack. The console body sets its own system stack, which differs in its first families. Both are sans. ## Verification Real Chromium (`/opt/pw-browsers/chromium`) against the showcase backend (`objectstack dev --fresh`) and the console's Vite dev server. "Before" is `FormPage.tsx` at main and "after" is this branch. | Reading | Before | After | |---|---|---| | `/f/contact-us`, empty submit: Name / Email / Message `aria-invalid` | false / false / false | true / true / true, each described by "X is required"; Company false | | `/f/contact-us`, server 400 `VALIDATION_FAILED` on `email` (real server answer) | no field marked | Email true, described by "Email must be a valid email address"; others false | | Submit button vs `--primary`, `/apps/showcase_app/forms/showcase_inquiry.contact` | rgb(124, 59, 237) = app `#7C3AED` | unchanged | | Submit button vs `--primary`, `/forms/...` and `/f/contact-us` | rgb(0, 123, 255), the default (no app known) | unchanged | | Markdown row placeholder `font-family` | ui-monospace stack | -apple-system / system sans stack; control stays ui-monospace | | Textarea and text rows placeholder | body sans | unchanged | The server-refusal probe set `form.noValidate` before submitting, because the browser's own email check would otherwise stop the submit before the server judged it. The server's answer is its real one. Derived gates at d32c6b1: `node scripts/pm/dispatch-gates.mjs --commands` derived 34 families. All 34 exit 0, including `node scripts/check-sdui-manifest.mjs` and the ui `scripts/` suite (169 files passed). `--ran` reconciliation: 34 run, 0 NOT-MEASURED. `pnpm --dir ui run check:new-line-citations`: 0 new citations. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbX1Zhx8ST6DDF1AGvD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 262ab67 commit ddbd08e

5 files changed

Lines changed: 530 additions & 25 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/console': patch
3+
'@object-ui/fields': patch
4+
---
5+
6+
fix(console): a refused form submit marks the fields it names; the markdown editor's placeholder takes the sans stack
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **`@object-ui/console`**: the form page (`/f/:slug`, `/forms/:name` and `/apps/:appName/forms/:name`) used to answer every refusal with one banner and one toast and nothing on the inputs. Each refused field is now marked: `aria-invalid` on its control, and the reason under the control, which the control names in `aria-describedby`. The client required check marks each empty required field ("Name is required"). The server's field-level refusals (`400 VALIDATION_FAILED` with `fields[]`, flat or in the wrapped envelope, and `409 UNIQUE_VIOLATION` naming its `field`) mark the fields the form draws, read with `extractFieldErrors` from `@object-ui/react`, the reader the record dialog uses. A faulted `visibleWhen` marks its field and points it at the banner, which already names the field and the rule. A refusal that names no field, or only fields the form does not draw, marks nothing. On a tabbed or wizard form view the first marked field's tab or step opens. Editing a marked field clears its mark. The banner and the toast are unchanged.
12+
- **`@object-ui/fields`**: the markdown, html and richtext editor (`RichTextField`) keeps `font-mono` for the source a person types, and its placeholder now uses the sans stack (`placeholder:font-sans`), so a form's "Describe your request" no longer reads as code. This applies on every host.
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack-ai/objectstack#27417 — a refused submit marks the fields it names.
5+
*
6+
* Every refusal on this page used to end in one string, on the banner and the
7+
* outcome toast, and nothing on the inputs: the submitter read "Required: Name,
8+
* Email" or a server sentence and had to find the rows themselves. Each refused
9+
* row now carries `aria-invalid` and a reason under its control, which the
10+
* control names in `aria-describedby`.
11+
*
12+
* The server half reads the refusal with the record dialog's reader of the
13+
* server's field paths, `extractFieldErrors` (`@object-ui/react`); this page
14+
* only attaches the response body to the error it throws. So the fixtures below
15+
* are the envelopes the server actually answers: the flat `400
16+
* VALIDATION_FAILED` with `fields[]` the public submit route maps a validation
17+
* error to, the same refusal in the runtime dispatcher's wrapped envelope, and
18+
* the flat `409 UNIQUE_VIOLATION` naming its one `field`.
19+
*
20+
* The banner and the toast are unchanged: a refusal that names no field marks
21+
* no row and still says what it said before.
22+
*/
23+
24+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
25+
import { render, screen, waitFor } from '@testing-library/react';
26+
import userEvent from '@testing-library/user-event';
27+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
28+
import { toast } from 'sonner';
29+
import { createI18n } from '@object-ui/i18n';
30+
import { FormPage } from './FormPage';
31+
32+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
33+
34+
const FIELDS = {
35+
name: { type: 'text', label: 'Name' },
36+
email: { type: 'text', label: 'Email' },
37+
company: { type: 'text', label: 'Company' },
38+
};
39+
40+
function publicPayload(sectionFields: unknown[], fields: Record<string, unknown> = FIELDS) {
41+
return {
42+
slug: 'contact-us',
43+
object: 'showcase_inquiry',
44+
label: 'Contact us',
45+
form: { type: 'simple', sections: [{ fields: sectionFields }] },
46+
objectSchema: { name: 'showcase_inquiry', fields },
47+
};
48+
}
49+
50+
let posts = 0;
51+
52+
/** Answers the resolver, and every submit with `refusal` (status + JSON body). */
53+
function stubPublic(
54+
sectionFields: unknown[],
55+
refusal: { status: number; body: unknown },
56+
fields?: Record<string, unknown>,
57+
) {
58+
vi.stubGlobal(
59+
'fetch',
60+
vi.fn(async (url: string, init?: RequestInit) => {
61+
if (init?.method === 'POST') {
62+
posts += 1;
63+
return {
64+
ok: false,
65+
status: refusal.status,
66+
statusText: 'Refused',
67+
text: async () => JSON.stringify(refusal.body),
68+
json: async () => refusal.body,
69+
} as unknown as Response;
70+
}
71+
if (!String(url).includes('/forms/')) throw new Error(`unstubbed fetch: ${url}`);
72+
const payload = publicPayload(sectionFields, fields);
73+
return {
74+
ok: true,
75+
status: 200,
76+
statusText: 'OK',
77+
json: async () => payload,
78+
text: async () => JSON.stringify(payload),
79+
} as unknown as Response;
80+
}),
81+
);
82+
return render(
83+
<MemoryRouter initialEntries={['/f/contact-us']}>
84+
<Routes>
85+
<Route path="/f/:slug" element={<FormPage mode="public" />} />
86+
</Routes>
87+
</MemoryRouter>,
88+
);
89+
}
90+
91+
/** The internal route, refusing its `POST /data/:object` with `refusal`. */
92+
function stubInternal(refusal: { status: number; body: unknown }) {
93+
const view = {
94+
name: 'showcase_inquiry.contact',
95+
object: 'showcase_inquiry',
96+
viewKind: 'form',
97+
label: 'Contact',
98+
config: { type: 'simple', sections: [{ fields: ['name', 'email', 'company'] }] },
99+
};
100+
const object = { name: 'showcase_inquiry', label: 'Inquiry', fields: FIELDS };
101+
vi.stubGlobal(
102+
'fetch',
103+
vi.fn(async (url: string, init?: RequestInit) => {
104+
const answer = (ok: boolean, status: number, body: unknown) =>
105+
({
106+
ok,
107+
status,
108+
statusText: ok ? 'OK' : 'Refused',
109+
json: async () => body,
110+
text: async () => JSON.stringify(body),
111+
}) as unknown as Response;
112+
if (init?.method === 'POST') {
113+
posts += 1;
114+
return answer(false, refusal.status, refusal.body);
115+
}
116+
if (String(url).includes('/meta/view/')) return answer(true, 200, view);
117+
if (String(url).includes('/meta/object/')) return answer(true, 200, object);
118+
throw new Error(`unstubbed fetch: ${url}`);
119+
}),
120+
);
121+
return render(
122+
<MemoryRouter initialEntries={['/forms/showcase_inquiry.contact']}>
123+
<Routes>
124+
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
125+
</Routes>
126+
</MemoryRouter>,
127+
);
128+
}
129+
130+
/** The text a control names in `aria-describedby`, or null when it names none. */
131+
function describedText(control: HTMLElement): string | null {
132+
const id = control.getAttribute('aria-describedby');
133+
if (!id) return null;
134+
return id
135+
.split(/\s+/)
136+
.map((ref) => document.getElementById(ref)?.textContent ?? '')
137+
.join(' ')
138+
.trim();
139+
}
140+
141+
async function fillAndSubmit(fill: Record<string, string>) {
142+
await screen.findByLabelText(/^Name/);
143+
for (const [label, value] of Object.entries(fill)) {
144+
await userEvent.type(screen.getByLabelText(label), value);
145+
}
146+
await userEvent.click(screen.getByRole('button', { name: /Submit/ }));
147+
}
148+
149+
const VALIDATION_FAILED_FIELDS = [
150+
{ field: 'name', code: 'max_length', message: 'Name must be at most 3 characters' },
151+
{ field: 'email', code: 'invalid_format', message: 'Email is not a valid email address' },
152+
];
153+
154+
beforeEach(() => {
155+
posts = 0;
156+
vi.mocked(toast.error).mockClear();
157+
// The page's chrome reads the `en` pack through react-i18next's global, as
158+
// the sibling FormPage suites set it up.
159+
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
160+
});
161+
162+
afterEach(() => {
163+
vi.unstubAllGlobals();
164+
});
165+
166+
describe('#27417 — a server refusal naming fields marks each of them', () => {
167+
it('public route: the flat 400 VALIDATION_FAILED marks both named fields and no other', async () => {
168+
stubPublic(['name', 'email', 'company'], {
169+
status: 400,
170+
body: { error: 'Validation failed', code: 'VALIDATION_FAILED', fields: VALIDATION_FAILED_FIELDS },
171+
});
172+
await fillAndSubmit({ Name: 'Ada Lovelace', Email: 'ada' });
173+
174+
const name = screen.getByLabelText('Name');
175+
const email = screen.getByLabelText('Email');
176+
await waitFor(() => expect(name).toHaveAttribute('aria-invalid', 'true'));
177+
expect(email).toHaveAttribute('aria-invalid', 'true');
178+
expect(describedText(name)).toContain('Name must be at most 3 characters');
179+
expect(describedText(email)).toContain('Email is not a valid email address');
180+
// The row the refusal does not name stays unmarked.
181+
expect(screen.getByLabelText('Company')).not.toHaveAttribute('aria-invalid', 'true');
182+
// The banner and the toast are unchanged.
183+
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
184+
expect(String(vi.mocked(toast.error).mock.calls[0][0])).toContain('Submit failed (400)');
185+
});
186+
187+
it('internal route: the wrapped dispatcher envelope marks both named fields', async () => {
188+
stubInternal({
189+
status: 400,
190+
body: {
191+
success: false,
192+
error: {
193+
code: 'VALIDATION_FAILED',
194+
message: 'Validation failed',
195+
httpStatus: 400,
196+
details: { fields: VALIDATION_FAILED_FIELDS },
197+
},
198+
},
199+
});
200+
await fillAndSubmit({ Name: 'Ada Lovelace', Email: 'ada' });
201+
202+
const name = screen.getByLabelText('Name');
203+
await waitFor(() => expect(name).toHaveAttribute('aria-invalid', 'true'));
204+
expect(screen.getByLabelText('Email')).toHaveAttribute('aria-invalid', 'true');
205+
expect(screen.getByLabelText('Company')).not.toHaveAttribute('aria-invalid', 'true');
206+
expect(describedText(name)).toContain('Name must be at most 3 characters');
207+
});
208+
209+
it('marks the one field a 409 UNIQUE_VIOLATION names, with the server sentence', async () => {
210+
stubPublic(['name', 'email', 'company'], {
211+
status: 409,
212+
body: {
213+
error: 'An inquiry with this email already exists.',
214+
code: 'UNIQUE_VIOLATION',
215+
field: 'email',
216+
object: 'showcase_inquiry',
217+
},
218+
});
219+
await fillAndSubmit({ Name: 'Ada', Email: 'ada@example.com' });
220+
221+
const email = screen.getByLabelText('Email');
222+
await waitFor(() => expect(email).toHaveAttribute('aria-invalid', 'true'));
223+
expect(describedText(email)).toContain('An inquiry with this email already exists.');
224+
expect(screen.getByLabelText('Name')).not.toHaveAttribute('aria-invalid', 'true');
225+
});
226+
227+
it('an edit to a marked row clears its mark and leaves the others', async () => {
228+
stubPublic(['name', 'email', 'company'], {
229+
status: 400,
230+
body: { error: 'Validation failed', code: 'VALIDATION_FAILED', fields: VALIDATION_FAILED_FIELDS },
231+
});
232+
await fillAndSubmit({ Name: 'Ada Lovelace', Email: 'ada' });
233+
const name = screen.getByLabelText('Name');
234+
await waitFor(() => expect(name).toHaveAttribute('aria-invalid', 'true'));
235+
236+
await userEvent.type(name, '!');
237+
expect(name).not.toHaveAttribute('aria-invalid', 'true');
238+
expect(screen.queryByText('Name must be at most 3 characters')).toBeNull();
239+
expect(screen.getByLabelText('Email')).toHaveAttribute('aria-invalid', 'true');
240+
});
241+
});
242+
243+
describe('#27417 — a refusal naming no field marks none', () => {
244+
it('a 400 with no field list leaves every row unmarked and keeps the banner', async () => {
245+
stubPublic(['name', 'email', 'company'], {
246+
status: 400,
247+
body: { error: 'Invalid inquiry status transition.', code: 'VALIDATION_ERROR' },
248+
});
249+
await fillAndSubmit({ Name: 'Ada', Email: 'ada@example.com' });
250+
251+
expect(await screen.findByText(/Invalid inquiry status transition\./)).toBeInTheDocument();
252+
for (const label of ['Name', 'Email', 'Company']) {
253+
expect(screen.getByLabelText(label)).not.toHaveAttribute('aria-invalid', 'true');
254+
}
255+
// No row was given a reason to point at.
256+
expect(document.querySelectorAll('[id$="-error"]')).toHaveLength(0);
257+
});
258+
259+
it('a field the form does not draw marks nothing', async () => {
260+
stubPublic(['name', 'email'], {
261+
status: 400,
262+
body: {
263+
error: 'Validation failed',
264+
code: 'VALIDATION_FAILED',
265+
fields: [{ field: 'company', message: 'Company is required' }],
266+
},
267+
});
268+
await fillAndSubmit({ Name: 'Ada', Email: 'ada@example.com' });
269+
270+
await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1));
271+
expect(screen.getByLabelText('Name')).not.toHaveAttribute('aria-invalid', 'true');
272+
expect(screen.getByLabelText('Email')).not.toHaveAttribute('aria-invalid', 'true');
273+
expect(screen.queryByText('Company is required')).toBeNull();
274+
});
275+
});
276+
277+
describe('#27417 — the client required refusal marks the missing fields', () => {
278+
it('marks each required row left empty, writes nothing, and leaves the filled one', async () => {
279+
stubPublic(
280+
[{ field: 'name', required: true }, { field: 'email', required: true }, 'company'],
281+
{ status: 500, body: { error: 'must not be reached' } },
282+
);
283+
await fillAndSubmit({ Company: 'Analytical Engines' });
284+
285+
expect(await screen.findByText('Required: Name, Email')).toBeInTheDocument();
286+
expect(posts).toBe(0);
287+
const name = screen.getByLabelText(/^Name/);
288+
const email = screen.getByLabelText(/^Email/);
289+
expect(name).toHaveAttribute('aria-invalid', 'true');
290+
expect(email).toHaveAttribute('aria-invalid', 'true');
291+
expect(describedText(name)).toBe('Name is required');
292+
expect(describedText(email)).toBe('Email is required');
293+
expect(screen.getByLabelText('Company')).not.toHaveAttribute('aria-invalid', 'true');
294+
});
295+
});
296+
297+
describe('#27417 — a faulted visibleWhen marks its row and points it at the banner', () => {
298+
it('marks the faulted row, names the banner, and repeats no text under it', async () => {
299+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
300+
stubPublic(
301+
['name', 'email', 'company'],
302+
{ status: 500, body: { error: 'must not be reached' } },
303+
{ ...FIELDS, company: { type: 'text', label: 'Company', visibleWhen: 'record.name ==' } },
304+
);
305+
await fillAndSubmit({ Name: 'Ada', Email: 'ada@example.com' });
306+
307+
const banner = await screen.findByText(/visibleWhen rule of Company could not be evaluated/);
308+
expect(posts).toBe(0);
309+
const company = screen.getByLabelText('Company');
310+
expect(company).toHaveAttribute('aria-invalid', 'true');
311+
expect(company.getAttribute('aria-describedby')).toBe(banner.id);
312+
expect(screen.getByLabelText('Name')).not.toHaveAttribute('aria-invalid', 'true');
313+
warn.mockRestore();
314+
});
315+
});

0 commit comments

Comments
 (0)