Skip to content

Commit 421bb6b

Browse files
fix(app-shell): hold a new item whose name the client refuses; one message per mistake on the Setup metadata edit page (#27938)
domain:ui#3 · os-bill · session_01Utp6CaJoinvGQSbHeDaWBe <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg67ztnCdyEXpdn9q9GnfEMN)_ ## 维护者速读 - 改了什么:Setup 里新建元数据(实测页面是「权限集 › 新建」)时,名称被编辑器自己的校验判为不合法,点保存就不再发请求,只在「名称」输入框下显示那一条错误;名称由标签自动生成、没碰过的,点保存后错误也会显示在输入框下。页面顶部的摘要横幅不再重复表单已经画在字段上的错误;服务端拒绝时如果点名的是表单画得出的字段,也只在那个字段下说一次。 - 为什么改:以前坏名照样发出去,页面先出服务端拒绝("is not a legal metadata item name"),再改一下又叠出摘要横幅("does not match the spec — 1 validation error(s)")和字段下的错误,一个错误三条消息(#27521 第 16 行拆出的卡)。 - 风险与回滚:只改前端。暂缓只在「新建」且只看「名称」字段:新建表单总有名称输入框,作者总能改成两端都接受的名字,所以不会卡死保存;其余实时校验仍是提示性的,照常发送、以服务端为准(编辑已有条目从不因此暂缓)。回滚即 revert 本 PR。 - 先看:`ResourceEditPage.tsx` 的 `doSave`(新增的暂缓分支与 422 分支里的 `drawnInline`)、`SchemaForm.tsx` 的 `useInlineIssueFieldRegister` 与 `revealIssues`、新测试 `ResourceEditPage.heldBadName-27865.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/ResourceEditPage.heldBadName-27865.test.tsx`,预期 1 个文件 4 条全部通过(坏名零请求只剩内联错误、服务端点名 name 只说一次、标签生成的坏名同样暂缓、好名照常保存)。 - 需要你做的:无,席位落地。 Fixes #27865 Clause-②: no Metadata-protocol: none ## What changes **Measured first**, on `main` at `e6553619` against the stock showcase (`objectstack dev --seed-admin --fresh`, admin, Chromium 1440×900), Setup › Permission Sets › New, Name typed as `Bad Name`: - before Save: the summary banner (`metadata-validation-banner`, "does not match the spec — 1 validation error(s)", listing Name) **and** the inline error under Name, for the one mistake; - on Save: one `PUT /api/v1/meta/permission/Bad%20Name` left, answered 400 with "is not a legal metadata item name"; the page drew that refusal and cleared the inline error, so the next edit brought the summary banner and the inline error back beside the still-standing refusal: three messages. **Now**, re-driven on the same stack at this branch: typing `Bad Name` shows only the inline error; Save sends **no** request and the inline error stays the one message; `probe_set` still creates (one `PUT`). 1. **Hold** (`doSave`). On the create door, before any request, the page runs the same live client validator the form already shows (`validateMetadataDraft`, mode `create`, on the same draft; no second rule), awaited so a Save inside the 200 ms debounce cannot slip past it. When it refuses the identity field, nothing is sent: the issues are put back on the form and the Save attempt ends. This is the one place a predicted verdict holds Save, and it is bounded so the dead-bolt the advisory ruling guards against stays out of reach (see Acceptance notes): only `createMode`, only the identity field. 2. **Reveal** (`SchemaForm` `revealIssues`). The create form defers a field's error until the field is touched. A Save attempt ends that deferral, so a Name derived from the Label and never touched (Label `A` gives `a`, under the spec's two-character floor) shows its error inline instead of nowhere. 3. **One message per mistake.** The outermost `SchemaForm` now reports which top-level fields draw their issues inline (a row registers while mounted and not deferred; nested forms do not register; the register is held in state, not memoised, per `ui/AGENTS.md` #10). The page then: - leaves those fields' errors out of the summary banner, but only errors that are also among the issues the form received, so a server-only diagnostic is never dropped; - on a 422 whose issues all sit on fields the form draws, skips the refusal line (`setError`), so the server's refusal is said once, on the field. Only where a client validator exists, because that is where the summary banner reads these issues: if the field later leaves the screen, the banner lists it again. Errors the form cannot draw on a field (root, nested key, a field off screen or in a closed section) stay in the banner, unchanged. The Save button gate, the autosave timer and the ⌘S door are untouched: the hold lives in `doSave`, which all three call. ## Tests - New `ResourceEditPage.heldBadName-27865.test.tsx`: the real page over a real `MetadataClient` whose in-memory transport records every `PUT` and answers like the save door (an ungrammatical name 400 `INVALID_REQUEST` with no issues, read from the spec's own `METADATA_ITEM_NAME_PATTERN`; `taken_set` 422 with an issue at `name`). The live permission registration (`registerBuiltinAnchors`: Label → Name derive, `createLive`) is mounted. (a) bad Name, Save clicked inside the debounce, then again: zero `PUT`, exactly one inline issue whose text appears nowhere else, no summary banner, no server refusal; (b) a Name the client accepts and the server refuses at `name`: one `PUT`, one message, inline; (c) Label `A` derives `a`, untouched: held, zero `PUT`, the error shown inline; (d) control: a good Name saves. - `ResourceEditPage.schemaAdvisory.test.tsx` (the advisory half: a stored item with a key the bundled spec does not declare still saves) and `ResourceEditPage.serverRefusalGate.test.tsx` pass unchanged. <details><summary>Readings</summary> - Affected set, on the head after merging `main` (`ab56afe9`; `PackageFormDialog`, which hosts `SchemaForm`, moved on `main` meanwhile): every test file under `ui/` naming `ResourceEditPage`, `SchemaForm` or `PackageFormDialog`, 187 files: Test Files 187 passed, Tests 1921 passed; lock VERDICT command-exit 0 (6m59s). Before the merge, the same set without `PackageFormDialog` (169 files): 1765 passed. - Ablations (`scripts/ablation-replace.mjs`; app-shell tests import `src`, so no dist leg; each restored blob equal to HEAD, `git diff HEAD` empty): - hold condition disabled: (a) red, 1 failed of 3; - banner filter disabled: (a) and (b) red, 2 failed of 3. A first attempt at this one was a no-op (the marker spelling left the expression's value unchanged, 3 passed); re-run with a marker that removes the filter; - refusal-line skip disabled: (b) red, 1 failed of 3; - reveal disabled: (c) red, 1 failed of 4. - Known-stricter check (asked by the order): for every metadata type with a client create validator (20 types), probe names `ok_name`, `ab`, `a`, `x1`, `crm_lead.pipeline`, `a.b`, `abc_`, `a__b` and a 67-character name, judged by the client create pass against the server's registry schema plus the door's name grammar: no type where the client refuses a Name the server accepts (scratch run, not committed). - `pnpm --filter @object-ui/app-shell run type-check` (both the `src` and the test program), after rebuilding `@object-ui/app-shell^...` on `ab56afe9`: lock VERDICT command-exit 0. - `ui` `check:new-line-citations`: 0 new. - Derived gates (`dispatch-gates --commands`), run on `ab56afe9`: 34 derived, 34 run, 0 NOT-MEASURED, all exit 0 (also all exit 0 on `a18abd01`, before the merge). </details> ## Acceptance notes - **The advisory ruling, named rather than worked around.** `ResourceEditPage` records, and `ResourceEditPage.schemaAdvisory.test.tsx` pins, that the live client Zod pass stays advisory because a client stricter than the server would dead-bolt Save "on a draft the server accepts, with no on-screen editor able to take the key back out". This card asks for a hold on that same verdict, so the hold is bounded to where that failure cannot occur: the create door's identity field, which the create form always offers as an input, so the author can always type a name both ends accept. Every other live issue stays advisory, the Save button gate is unchanged, and an edit is never held. The boundary is written beside the ruling in `ResourceEditPage.tsx` and at the hold itself. The seat kept it (review on `ab56afe9`). The bound is pinned on the create door by case (e) of `heldBadName-27865`: a good Name beside a client-refused non-identity key still sends. Widening the hold to any issue turns (e) red, and only (e). `schemaAdvisory`'s header names the carve-out beside its `serverRefusalGate` paragraph, with no assertion changed (rework head `fc4cb80a`). - A 400 refusal that carries no field path (the door's name grammar, for a name the client accepts) stays one message in the refusal line: it names no field to place it on. No such name exists for a permission set today: the client's `SnakeCaseIdentifierSchema` is at least as strict as the door's grammar. - On the create form, before any Save attempt, a field's error is shown once the field is touched and is then no longer repeated in the banner; an untouched field's error still shows in the banner until then, as before. - Out of this card: the sibling spec card #27864 (Label first + required). --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZcCCHhGAp4cezRhuD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 25b8ef3 commit 421bb6b

5 files changed

Lines changed: 371 additions & 6 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(setup): a new metadata item whose name the editor already refuses is not sent, and one mistake is one message
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** On Setup › Permission Sets › New (and every metadata create page), a Name the editor flagged as invalid was sent on Save anyway. The page then showed the server's refusal ("is not a legal metadata item name"), and after the next edit also the summary banner ("does not match the spec — 1 validation error(s)") and the inline error under Name: three messages for one mistake.
12+
- **Now.** While the editor's own check refuses the Name of a new item, Save sends nothing and the error under Name is the message; a Name filled in from the Label, never touched, shows its error there too once Save is pressed. The summary banner no longer repeats an error the form already draws on its field, and a server refusal that names a field the form draws is said on that field only.
13+
- **Unchanged.** Every other check the editor runs while you type stays advisory: Save still sends, and the server has the last word (an existing item is never held by it). Errors the form cannot draw on a field (the whole item, a nested key, a field off screen) are still listed in the banner.
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27865 — New Permission Set: one mistake is one message.
5+
*
6+
* (a) a Name the client validator refuses holds the save: no `PUT` leaves,
7+
* even on a click inside the live pass's debounce, and the inline error
8+
* on Name is the only message (no summary banner, no server refusal);
9+
* (b) a Name the client accepts but the server refuses, naming `name`, is
10+
* said once: inline on Name, with no banner repeating it;
11+
* (c) a Name derived from the Label, never touched, is held the same way,
12+
* and the Save attempt shows its error inline (the create form's
13+
* show-on-touch deferral ends there);
14+
* (d) a good Name saves (control);
15+
* (e) the hold's BOUND: a good Name beside a client-refused key that is not
16+
* the identity (the server-gained-a-key skew shape the advisory ruling
17+
* is about) still sends — only the Name holds a create.
18+
*
19+
* The REAL page over a REAL `MetadataClient` whose transport is an in-memory
20+
* server that records every `PUT` and answers the way the save door does: an
21+
* ungrammatical name is a 400 `INVALID_REQUEST` with no issues
22+
* (`refuseUngrammaticalMetaItemName`), and `taken_set` stands for a name the
23+
* client cannot judge, refused 422 with an issue at `name`.
24+
*
25+
* That the live Zod pass stays advisory everywhere else is pinned, unchanged,
26+
* in `ResourceEditPage.schemaAdvisory.test.tsx`.
27+
*/
28+
29+
import '@testing-library/jest-dom/vitest';
30+
import * as React from 'react';
31+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
32+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
33+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
34+
import { z } from 'zod';
35+
import { getMetadataCreateSeed, getMetadataTypeSchema } from '@objectstack/spec/kernel';
36+
import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared';
37+
import { MetadataClient } from '@object-ui/data-objectstack';
38+
39+
const TAKEN = 'taken_set';
40+
const TAKEN_MESSAGE = 'A permission set with this name already exists.';
41+
42+
const server = {
43+
puts: [] as Array<{ name: string; body: Record<string, unknown> }>,
44+
};
45+
46+
const json = (status: number, body: unknown) =>
47+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
48+
49+
async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
50+
const method = (init?.method ?? 'GET').toUpperCase();
51+
const url = new URL(String(input), 'http://console.test');
52+
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
53+
if (!meta) return json(200, []);
54+
const seg = meta[1].split('/').map(decodeURIComponent);
55+
if (seg[0] === '_drafts') return json(200, { drafts: [] });
56+
if (seg.length === 1) return json(200, { items: [] });
57+
if (method === 'PUT') {
58+
const name = seg[1]!;
59+
server.puts.push({ name, body: JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown> });
60+
if (!METADATA_ITEM_NAME_PATTERN.test(name)) {
61+
return json(400, {
62+
success: false,
63+
error: { code: 'INVALID_REQUEST', message: `${JSON.stringify(name)} is not a legal metadata item name.` },
64+
});
65+
}
66+
if (name === TAKEN) {
67+
return json(422, {
68+
success: false,
69+
error: { code: 'INVALID_METADATA', message: 'Invalid permission', details: { issues: [{ path: ['name'], message: TAKEN_MESSAGE }] } },
70+
});
71+
}
72+
return json(200, { success: true, version: 'hmac-sha256:v1', seq: 1, state: 'draft', message: `Saved ${seg[0]}/${name}` });
73+
}
74+
return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } });
75+
}
76+
77+
const client = vi.hoisted(() => ({ current: null as unknown }));
78+
/** Extra keys the served create seed carries — set by (e) only. */
79+
const seedExtra = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
80+
81+
vi.mock('./useMetadata', async (importOriginal) => {
82+
const mod = await importOriginal<typeof import('./useMetadata')>();
83+
return {
84+
...mod,
85+
useMetadataClient: () => client.current,
86+
useMetadataTypes: () => ({
87+
loading: false,
88+
entries: [
89+
{
90+
type: 'permission',
91+
label: 'Permission Set',
92+
domain: 'security',
93+
allowEnvironmentOverlay: true,
94+
allowRuntimeCreate: true,
95+
schema: PERMISSION_JSON_SCHEMA,
96+
createSeed: { ...(getMetadataCreateSeed('permission') as Record<string, unknown>), ...seedExtra.current },
97+
},
98+
],
99+
}),
100+
};
101+
});
102+
103+
import { MetadataResourceEditPage } from './ResourceEditPage';
104+
import { registerDefaultMetadataSchemas } from './default-schemas';
105+
import { registerBuiltinAnchors } from './anchors';
106+
107+
const PERMISSION_JSON_SCHEMA = z.toJSONSchema(getMetadataTypeSchema('permission') as z.ZodType, {
108+
io: 'input',
109+
unrepresentable: 'any',
110+
}) as Record<string, unknown>;
111+
112+
client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });
113+
114+
registerDefaultMetadataSchemas();
115+
// The live registration: Label → Name derive, `createLive`.
116+
registerBuiltinAnchors();
117+
118+
beforeEach(() => {
119+
server.puts = [];
120+
seedExtra.current = {};
121+
});
122+
afterEach(() => {
123+
cleanup();
124+
});
125+
126+
const saveButton = () => screen.getByTestId('resource-save');
127+
128+
async function openNewPermissionSet() {
129+
render(
130+
<MemoryRouter initialEntries={['/metadata/permission/new']}>
131+
<Routes>
132+
<Route path="/metadata/permission/new" element={<MetadataResourceEditPage type="permission" name="" createMode />} />
133+
<Route path="/metadata/permission/:name" element={<div data-testid="landed" />} />
134+
</Routes>
135+
</MemoryRouter>,
136+
);
137+
return screen.findByRole('textbox', { name: /^Name/ }, { timeout: 8000 });
138+
}
139+
140+
/** The one message: exactly one inline issue, said nowhere else on the page. */
141+
async function expectOnlyInlineIssue(): Promise<string> {
142+
const inline = await screen.findAllByTestId('schema-form-issue', {}, { timeout: 8000 });
143+
expect(inline).toHaveLength(1);
144+
const text = inline[0]!.textContent ?? '';
145+
expect(text).not.toBe('');
146+
expect(screen.getAllByText(text, { exact: false })).toHaveLength(1);
147+
expect(screen.queryByTestId('metadata-validation-banner')).toBeNull();
148+
return text;
149+
}
150+
151+
describe('New Permission Set: one mistake, one message (objectstack#27865)', () => {
152+
it('(a) a Name the client refuses holds the save — no PUT, only the inline error', async () => {
153+
const nameBox = await openNewPermissionSet();
154+
fireEvent.change(nameBox, { target: { value: 'Bad Name' } });
155+
// Inside the live pass's 200 ms debounce: the hold must not depend on it.
156+
fireEvent.click(saveButton());
157+
await expectOnlyInlineIssue();
158+
await new Promise((r) => setTimeout(r, 300));
159+
expect(server.puts).toEqual([]);
160+
expect(screen.queryByText(/is not a legal metadata item name/)).toBeNull();
161+
expect(screen.queryByTestId('landed')).toBeNull();
162+
// A second click is held the same way.
163+
fireEvent.click(saveButton());
164+
await new Promise((r) => setTimeout(r, 300));
165+
expect(server.puts).toEqual([]);
166+
await expectOnlyInlineIssue();
167+
});
168+
169+
it('(b) a Name the client accepts but the server refuses at `name` is said once, inline', async () => {
170+
const nameBox = await openNewPermissionSet();
171+
fireEvent.change(nameBox, { target: { value: TAKEN } });
172+
fireEvent.blur(nameBox);
173+
fireEvent.click(saveButton());
174+
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 });
175+
const text = await expectOnlyInlineIssue();
176+
expect(text).toContain(TAKEN_MESSAGE);
177+
expect(screen.queryByTestId('landed')).toBeNull();
178+
});
179+
180+
it('(c) a derived, untouched Name the client refuses is held and its error shown inline', async () => {
181+
await openNewPermissionSet();
182+
const labelBox = screen.getByRole('textbox', { name: /^Label/ });
183+
fireEvent.change(labelBox, { target: { value: 'A' } });
184+
// Single-letter slug: refused by the spec's two-character floor.
185+
await waitFor(() => expect(screen.getByRole('textbox', { name: /^Name/ })).toHaveValue('a'));
186+
expect(screen.queryAllByTestId('schema-form-issue')).toHaveLength(0);
187+
fireEvent.click(saveButton());
188+
await expectOnlyInlineIssue();
189+
await new Promise((r) => setTimeout(r, 300));
190+
expect(server.puts).toEqual([]);
191+
});
192+
193+
it('(d) a good Name saves (control)', async () => {
194+
const nameBox = await openNewPermissionSet();
195+
fireEvent.change(nameBox, { target: { value: 'sales_ops' } });
196+
fireEvent.click(saveButton());
197+
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 });
198+
expect(server.puts[0]!.name).toBe('sales_ops');
199+
await screen.findByTestId('landed', {}, { timeout: 8000 });
200+
});
201+
202+
it('(e) a good Name beside a client-refused non-identity key still sends — the hold is the Name only', async () => {
203+
// A key the bundled spec does not declare, served in the create seed: the
204+
// client's strict pass refuses it at the root, the (newer) server takes it.
205+
seedExtra.current = { zzServerOnlyKey: true };
206+
const nameBox = await openNewPermissionSet();
207+
fireEvent.change(nameBox, { target: { value: 'sales_ops' } });
208+
// The client does refuse the draft — surfaced, advisory — and not at Name.
209+
const banner = await screen.findByTestId('metadata-validation-banner', {}, { timeout: 8000 });
210+
expect(banner.textContent).toContain('(root)');
211+
expect(screen.queryAllByTestId('schema-form-issue')).toHaveLength(0);
212+
fireEvent.click(saveButton());
213+
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 });
214+
expect(server.puts[0]!.name).toBe('sales_ops');
215+
expect(server.puts[0]!.body.zzServerOnlyKey).toBe(true);
216+
await screen.findByTestId('landed', {}, { timeout: 8000 });
217+
});
218+
});

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.schemaAdvisory.test.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@
4545
* ⛔ Neither suite may be cited without the other. A carve-out with only its
4646
* blocking half pinned is indistinguishable from having deleted this one.
4747
*
48+
* ## ⭐ The create-name carve-out, objectstack#27865 — also read with this suite
49+
*
50+
* One PREDICTED verdict does hold a save, and it is bounded: on the create
51+
* door only, when the live pass refuses the identity field (the Name),
52+
* `doSave` sends nothing and the error stays on that field. The ruling's
53+
* reason does not reach it: the create form always offers the Name input, so a
54+
* client stricter than the server costs the author a different name, never a
55+
* dead-bolt. Both halves are pinned in
56+
* `ResourceEditPage.heldBadName-27865.test.tsx` — the hold, and its bound: a
57+
* good Name beside a client-refused non-identity key (this suite's skew shape,
58+
* on the create door) still sends. Every case below is unchanged by it: an
59+
* edit is never held, and no other live issue holds a create.
60+
*
4861
* ## The fixture is the real skew shape, not a contrived one
4962
*
5063
* The stored page carries `zzServerOnlyKey` — a key the bundled

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,16 @@ function MetadataResourceEditPageImpl({
629629
// the user types anything reads as broken, not helpful (the save path still
630630
// validates). Flips true on the first real edit.
631631
const [createDirty, setCreateDirty] = React.useState(false);
632+
// objectstack#27865 — one message per mistake. `revealIssues` ends the create
633+
// form's show-on-touch deferral once Save is attempted; `inlineIssueFields`
634+
// is what the form reports it draws inline (a top-level field, mounted, not
635+
// deferred), which the summary banner and the refusal line leave out.
636+
const [revealIssues, setRevealIssues] = React.useState(false);
637+
const [inlineIssueFields, setInlineIssueFields] = React.useState<ReadonlySet<string>>(() => new Set());
638+
const inlineIssueFieldsRef = React.useRef(inlineIssueFields);
639+
React.useEffect(() => {
640+
inlineIssueFieldsRef.current = inlineIssueFields;
641+
});
632642

633643
// Wrap setDraft so that editing a field clears any *server-side*
634644
// diagnostic issues whose path begins with that field. The user
@@ -946,7 +956,10 @@ function MetadataResourceEditPageImpl({
946956
//
947957
// So schema issues are surfaced loudly — banner, inline field errors,
948958
// `previewDiagnostics` — and left advisory; the server has the last word.
949-
// Pinned by `ResourceEditPage.schemaAdvisory.test.tsx`.
959+
// Pinned by `ResourceEditPage.schemaAdvisory.test.tsx`. One bounded
960+
// exception, outside this gate: a create whose NAME the live pass refuses is
961+
// held in `doSave` (objectstack#27865) — the create form always offers that
962+
// input, so no skew can wedge it.
950963
//
951964
// ⭐ THE BOUNDARY, stated so a later reader can apply it (`9073cf018`).
952965
// "Advisory" is about a verdict this client PREDICTS, and it is the whole of
@@ -1810,6 +1823,10 @@ function MetadataResourceEditPageImpl({
18101823
setSaving(true);
18111824
setError(null);
18121825
setIssues([]);
1826+
if (createMode) {
1827+
setRevealIssues(true);
1828+
setCreateDirty(true);
1829+
}
18131830
try {
18141831
// Ensure identity is set on create, and that any `createDefaults`
18151832
// / `createBuildBody` shape (e.g. `{ fields: {} }` for object,
@@ -1852,6 +1869,31 @@ function MetadataResourceEditPageImpl({
18521869
setSaving(false);
18531870
return;
18541871
}
1872+
// objectstack#27865 — a create whose NAME the client validator refuses is
1873+
// held: no request, and the refusal is the inline error on the Name field
1874+
// (revealed above). The verdict is the live pass's own, run here on the
1875+
// same draft so a Save inside its debounce cannot slip past it.
1876+
//
1877+
// ⭐ This is the one place a predicted verdict holds Save, and it is
1878+
// bounded so the dead-bolt the advisory ruling at `blockingReport`
1879+
// guards against stays out of reach: only the create door, and only the
1880+
// identity field — an input the create form always offers, so a client
1881+
// stricter than the server can never wedge the author, who can always
1882+
// type a name both accept. Every other live issue stays advisory, and an
1883+
// edit is never held here.
1884+
if (createMode && hasClientValidator(type, 'create')) {
1885+
const verdict = await validateMetadataDraft(
1886+
type,
1887+
draft,
1888+
entry?.schema as { required?: unknown } | undefined,
1889+
{ mode: 'create' },
1890+
);
1891+
if (verdict.issues.some((issue) => issue.path === identityField)) {
1892+
setIssues(verdict.issues);
1893+
setSaving(false);
1894+
return;
1895+
}
1896+
}
18551897
// objectui#12810 — a create never saves the seed's placeholder identity
18561898
// (`buildCreateModeBody` leaves it out). One the author left unset that
18571899
// the type requires — a dataset's base object — is refused HERE, named,
@@ -2023,7 +2065,16 @@ function MetadataResourceEditPageImpl({
20232065
held,
20242066
summary: held.map((h) => h.label).join(', '),
20252067
});
2026-
if (mapped.length === 1 && !mapped[0].path) {
2068+
// objectstack#27865 — a refusal the form draws in full on its fields is
2069+
// said there, once. Only where the summary banner reads these issues
2070+
// (a client validator exists), so a field that leaves the screen still
2071+
// has its message somewhere.
2072+
const drawnInline =
2073+
hasClientValidator(type, draftMode) &&
2074+
mapped.every((m) => !!m.path && inlineIssueFieldsRef.current.has(m.path));
2075+
if (drawnInline) {
2076+
setError(null);
2077+
} else if (mapped.length === 1 && !mapped[0].path) {
20272078
setError(mapped[0].message);
20282079
} else if (mapped.length === 1) {
20292080
setError(`${labelForIssuePath(mapped[0].path)}: ${mapped[0].message}`);
@@ -3027,7 +3078,11 @@ function MetadataResourceEditPageImpl({
30273078
) {
30283079
return null;
30293080
}
3030-
const errs = liveErrors;
3081+
// objectstack#27865 — an error the form already draws on its
3082+
// field is not repeated here.
3083+
const errs = liveErrors.filter(
3084+
(e) => !(inlineIssueFields.has(e.path) && displayIssues.some((d) => d.path === e.path)),
3085+
);
30313086
const warns = diag?.warnings ?? [];
30323087
const hasErrs = !liveValid && errs.length > 0;
30333088
const hasWarns = warns.length > 0;
@@ -3403,6 +3458,8 @@ function MetadataResourceEditPageImpl({
34033458
readOnly={formReadOnly}
34043459
createMode={createMode}
34053460
widgetContext={widgetContext}
3461+
revealIssues={revealIssues}
3462+
onInlineIssueFieldsChange={setInlineIssueFields}
34063463
/>
34073464
)}
34083465
</div>
@@ -3427,6 +3484,8 @@ function MetadataResourceEditPageImpl({
34273484
readOnly={formReadOnly}
34283485
createMode={createMode}
34293486
widgetContext={widgetContext}
3487+
revealIssues={revealIssues}
3488+
onInlineIssueFieldsChange={setInlineIssueFields}
34303489
/>
34313490
)}
34323491
{/* objectui#7611 — under the designer, too: `position` renders

0 commit comments

Comments
 (0)