From 1302a7910d20a63f2a72864f1e9241bcd2bb7e79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:54:10 +0000 Subject: [PATCH 1/3] fix(app-shell): a served form row marked required holds Save when left empty A form row's required flag is a layout override the schema may not share, so the live Zod pass and the server both accept the empty field. SchemaForm now reports the rows it offers whose served row says required (visible, editable), and the metadata edit page shows an unset one as the field's required error and holds Save on it. Claude-Session: https://claude.ai/code/session_01L9qibzECC3G6Nyga7Ysbjd Co-Authored-By: Claude --- ...esourceEditPage.requiredRow-28179.test.tsx | 193 ++++++++++++++++++ .../views/metadata-admin/ResourceEditPage.tsx | 43 +++- .../SchemaForm.requiredRows-28179.test.tsx | 108 ++++++++++ .../src/views/metadata-admin/SchemaForm.tsx | 62 ++++-- ui/packages/i18n/src/namespaces/studio/en.ts | 2 + ui/packages/i18n/src/namespaces/studio/zh.ts | 2 + 6 files changed, 391 insertions(+), 19 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredRow-28179.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/SchemaForm.requiredRows-28179.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredRow-28179.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredRow-28179.test.tsx new file mode 100644 index 00000000000..4e87670036b --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredRow-28179.test.tsx @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28179 — a served form row marked `required: true` is not only a + * star. The permission set's Identity row `label` is required in the served + * form while `PermissionSetSchema.label` stays optional, so neither the live + * Zod pass nor the server refuses an empty one: the page does. + * + * (a) New Permission Set, Label empty, Name typed, Save: no `PUT`, and the + * refusal is the inline error on Label; typing a Label then saves; + * (b) CONTROL — the same page over a served form whose Label row is not + * required: an empty Label saves, the field stays optional; + * (c) a field the served SCHEMA requires keeps its own verdict: Save still + * sends (the server's refusal path, as before) and the new message is not + * added beside the schema's. + * + * The REAL page over a REAL `MetadataClient` whose transport is an in-memory + * server recording every `PUT`, with the real served form from + * `METADATA_FORM_REGISTRY`. Which rows count as offered is pinned in + * `SchemaForm.requiredRows-28179.test.tsx`. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { z } from 'zod'; +import { getMetadataCreateSeed, getMetadataTypeSchema } from '@objectstack/spec/kernel'; +import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; +import { MetadataClient } from '@object-ui/data-objectstack'; +import { t } from './i18n'; + +const REQUIRED = t('engine.validation.fieldRequired', 'en-US'); + +const server = { + puts: [] as Array<{ name: string; body: Record }>, +}; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +async function serve(input: RequestInfo | URL, init?: RequestInit): Promise { + const method = (init?.method ?? 'GET').toUpperCase(); + const url = new URL(String(input), 'http://console.test'); + const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/); + if (!meta) return json(200, []); + const seg = meta[1].split('/').map(decodeURIComponent); + if (seg[0] === '_drafts') return json(200, { drafts: [] }); + if (seg.length === 1) return json(200, { items: [] }); + if (method === 'PUT') { + server.puts.push({ name: seg[1]!, body: JSON.parse(String(init?.body ?? '{}')) as Record }); + return json(200, { success: true, version: 'hmac-sha256:v1', seq: 1, state: 'draft', message: `Saved ${seg[0]}/${seg[1]}` }); + } + return json(404, { error: { code: 'NO_DRAFT', message: 'absent' } }); +} + +const PERMISSION_JSON_SCHEMA = z.toJSONSchema(getMetadataTypeSchema('permission') as z.ZodType, { + io: 'input', + unrepresentable: 'any', +}) as Record; + +const SERVED_FORM = METADATA_FORM_REGISTRY.permission as unknown as { + sections: Array<{ fields: Array }>; +}; + +/** The served form with the Label row's `required` taken off — (b) only. */ +function formWithOptionalLabel() { + return { + ...SERVED_FORM, + sections: SERVED_FORM.sections.map((s) => ({ + ...s, + fields: s.fields.map((f) => { + if (typeof f === 'string' || f.field !== 'label') return f; + const { required: _required, ...rest } = f; + return rest; + }), + })), + }; +} + +const served = vi.hoisted(() => ({ + form: undefined as unknown, + schema: undefined as unknown, +})); + +const client = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => client.current, + useMetadataTypes: () => ({ + loading: false, + entries: [ + { + type: 'permission', + label: 'Permission Set', + domain: 'security', + allowEnvironmentOverlay: true, + allowRuntimeCreate: true, + schema: served.schema, + form: served.form, + createSeed: getMetadataCreateSeed('permission') as Record, + }, + ], + }), + }; +}); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +import { registerDefaultMetadataSchemas } from './default-schemas'; +import { registerBuiltinAnchors } from './anchors'; + +client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch }); + +registerDefaultMetadataSchemas(); +// The live registration: Label → Name derive, `createFields: ['label', 'name']`. +registerBuiltinAnchors(); + +beforeEach(() => { + server.puts = []; + served.form = SERVED_FORM; + served.schema = PERMISSION_JSON_SCHEMA; +}); +afterEach(() => { + cleanup(); +}); + +const saveButton = () => screen.getByTestId('resource-save'); + +async function openNewPermissionSet() { + render( + + + } /> + } /> + + , + ); + const nameBox = await screen.findByRole('textbox', { name: /^Name/ }, { timeout: 8000 }); + const labelBox = screen.getByRole('textbox', { name: /^Label/ }); + return { nameBox, labelBox }; +} + +/** The row hosting `control` — the field's own block in the form grid. */ +function rowOf(control: HTMLElement): HTMLElement { + return control.closest('[style*="grid-column"]') as HTMLElement; +} + +describe('a served required row holds Save (objectstack#28179)', () => { + it('(a) empty Label + typed Name is refused at Label with no PUT; a typed Label then saves', async () => { + const { nameBox, labelBox } = await openNewPermissionSet(); + expect(PERMISSION_JSON_SCHEMA.required ?? []).not.toContain('label'); + fireEvent.change(nameBox, { target: { value: 'sales_ops' } }); + fireEvent.click(saveButton()); + const issue = await within(rowOf(labelBox)).findByTestId('schema-form-issue', {}, { timeout: 8000 }); + expect(issue).toHaveTextContent(REQUIRED); + expect(within(rowOf(nameBox)).queryByTestId('schema-form-issue')).toBeNull(); + await new Promise((r) => setTimeout(r, 300)); + expect(server.puts).toEqual([]); + expect(screen.queryByTestId('landed')).toBeNull(); + + fireEvent.change(labelBox, { target: { value: 'Sales Ops' } }); + await waitFor(() => expect(within(rowOf(labelBox)).queryByTestId('schema-form-issue')).toBeNull()); + fireEvent.click(saveButton()); + await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 }); + expect(server.puts[0]!.name).toBe('sales_ops'); + expect(server.puts[0]!.body.label).toBe('Sales Ops'); + await screen.findByTestId('landed', {}, { timeout: 8000 }); + }); + + it('(b) CONTROL — a served Label row that is not required stays optional: an empty Label saves', async () => { + served.form = formWithOptionalLabel(); + const { nameBox } = await openNewPermissionSet(); + fireEvent.change(nameBox, { target: { value: 'sales_ops' } }); + fireEvent.click(saveButton()); + await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 }); + expect(server.puts[0]!.body.label).toBeUndefined(); + expect(screen.queryByText(REQUIRED)).toBeNull(); + await screen.findByTestId('landed', {}, { timeout: 8000 }); + }); + + it('(c) a field the served schema requires keeps its own verdict: Save still sends, no second message', async () => { + served.schema = { ...PERMISSION_JSON_SCHEMA, required: [...((PERMISSION_JSON_SCHEMA.required as string[]) ?? []), 'label'] }; + const { nameBox } = await openNewPermissionSet(); + fireEvent.change(nameBox, { target: { value: 'sales_ops' } }); + fireEvent.click(saveButton()); + await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 }); + expect(screen.queryByText(REQUIRED)).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 82de52989a5..1a08a062628 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -639,6 +639,11 @@ function MetadataResourceEditPageImpl({ React.useEffect(() => { inlineIssueFieldsRef.current = inlineIssueFields; }); + // objectstack#28179 — the rows the form on screen offers whose served layout + // says `required: true` (`onRequiredRowFieldsChange`). The flag is a layout + // override the Zod need not share, so neither the live pass nor the server + // judges it: this page does, below (`requiredRowIssues`). + const [requiredRowFields, setRequiredRowFields] = React.useState>(() => new Set()); // Wrap setDraft so that editing a field clears any *server-side* // diagnostic issues whose path begins with that field. The user @@ -838,11 +843,32 @@ function MetadataResourceEditPageImpl({ window.clearTimeout(handle); }; }, [type, draft, entry?.schema, draftMode, createMode]); + // objectstack#28179 — an offered row whose served layout says `required: + // true`, left unset, is a located "required" issue on that field, for every + // type. Only where the override is STRICTER than the schema: a field the + // schema requires already has its own verdict (the live pass's message, the + // server's refusal) and keeps it, and a value the schema defaults is never + // unset. Computed from the draft, never from the debounced live pass, so it + // holds the same Save it is shown beside (`doSave`). + const requiredRowIssues = React.useMemo(() => { + const props = (schema?.properties ?? {}) as Record; + const schemaRequired = Array.isArray(schema?.required) ? (schema.required as unknown[]).map(String) : []; + const unset = (v: unknown) => + v === undefined || v === null || (typeof v === 'string' && v.trim() === '') || (Array.isArray(v) && v.length === 0); + const out: SchemaFormIssue[] = []; + for (const field of requiredRowFields) { + if (schemaRequired.includes(field) || props[field]?.default !== undefined) continue; + if (unset((draft as Record)[field])) { + out.push({ path: field, message: t('engine.validation.fieldRequired', locale) }); + } + } + return out; + }, [requiredRowFields, draft, schema, locale]); // Issues to DISPLAY (banner + inline). Suppressed on a pristine create form // so a blank new item doesn't open covered in required-field errors. const displayIssues = React.useMemo( - () => (createMode && !createDirty ? [] : issues), - [createMode, createDirty, issues], + () => (createMode && !createDirty ? [] : requiredRowIssues.length > 0 ? [...issues, ...requiredRowIssues] : issues), + [createMode, createDirty, issues, requiredRowIssues], ); // Per-item draft pending publish (mode=draft saves land here). // When non-null, the editor is "viewing the draft" and we surface @@ -1894,6 +1920,17 @@ function MetadataResourceEditPageImpl({ return; } } + // objectstack#28179 — an unset row the served form marks `required: + // true` holds the save, create or edit: no request, and the refusal is + // the inline error `displayIssues` already draws on that field. The + // server accepts the body (the override is stricter than its Zod), so + // nothing else would refuse it. Bounded like the Name hold above: only + // rows the form on screen offers and the author can edit report, so the + // author can always fill what holds the save. + if (requiredRowIssues.length > 0) { + setSaving(false); + return; + } // objectui#12810 — a create never saves the seed's placeholder identity // (`buildCreateModeBody` leaves it out). One the author left unset that // the type requires — a dataset's base object — is refused HERE, named, @@ -3460,6 +3497,7 @@ function MetadataResourceEditPageImpl({ widgetContext={widgetContext} revealIssues={revealIssues} onInlineIssueFieldsChange={setInlineIssueFields} + onRequiredRowFieldsChange={setRequiredRowFields} /> )} @@ -3486,6 +3524,7 @@ function MetadataResourceEditPageImpl({ widgetContext={widgetContext} revealIssues={revealIssues} onInlineIssueFieldsChange={setInlineIssueFields} + onRequiredRowFieldsChange={setRequiredRowFields} /> )} {/* objectui#7611 — under the designer, too: `position` renders diff --git a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.requiredRows-28179.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.requiredRows-28179.test.tsx new file mode 100644 index 00000000000..a11fdd60fc2 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.requiredRows-28179.test.tsx @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28179 — `onRequiredRowFieldsChange` reports the rows the form + * OFFERS whose served layout says `required: true`, and only those: the host + * holds Save on an unset one, so a row the author cannot reach or edit must + * never be reported. The page half is pinned in + * `ResourceEditPage.requiredRow-28179.test.tsx`. + */ + +import * as React from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { SchemaForm, type FormViewSpec } from './SchemaForm'; + +const schema = { + type: 'object', + properties: { + kind: { type: 'string' }, + label: { type: 'string' }, + code: { type: 'string' }, + gated: { type: 'string' }, + locked: { type: 'string' }, + frozen: { type: 'string' }, + advanced: { type: 'string' }, + }, +}; + +function reported(form: FormViewSpec, opts: { value?: Record; createMode?: boolean; readOnly?: boolean; hiddenFields?: string[] } = {}) { + const calls: string[][] = []; + render( + {}} + createMode={opts.createMode} + readOnly={opts.readOnly} + hiddenFields={opts.hiddenFields} + onRequiredRowFieldsChange={(fields) => calls.push([...fields].sort())} + />, + ); + return calls; +} + +const form: FormViewSpec = { + type: 'simple', + sections: [ + { + label: 'Identity', + fields: [ + { field: 'label', required: true }, + // Not a row override: the schema decides this one, so it is not reported. + { field: 'code' }, + { field: 'gated', required: true, visibleWhen: { dialect: 'cel', source: "data.kind == 'shown'" } }, + { field: 'locked', required: true, readonly: true }, + { field: 'frozen', required: true, immutable: true }, + { field: 'missing_from_schema', required: true }, + ], + }, + { + label: 'Advanced', + collapsible: true, + collapsed: true, + fields: [{ field: 'advanced', required: true }], + }, + ], +}; + +afterEach(() => cleanup()); + +describe('SchemaForm reports the required rows it offers (objectstack#28179)', () => { + it('reports an offered required row, including one in a collapsed section', () => { + const calls = reported(form, { createMode: true }); + expect(calls.at(-1)).toEqual(['advanced', 'frozen', 'label']); + }); + + it('leaves out a row its predicate hides, until the predicate shows it', () => { + expect(reported(form, { createMode: true }).at(-1)).not.toContain('gated'); + cleanup(); + expect(reported(form, { createMode: true, value: { kind: 'shown' } }).at(-1)).toContain('gated'); + }); + + it('leaves out read-only rows: `readonly`, `immutable` outside create, and a read-only form', () => { + expect(reported(form, { createMode: false }).at(-1)).toEqual(['advanced', 'label']); + cleanup(); + expect(reported(form, { readOnly: true }).at(-1) ?? []).toEqual([]); + }); + + it('leaves out a row the host hides', () => { + expect(reported(form, { createMode: true, hiddenFields: ['label'] }).at(-1)).toEqual(['advanced', 'frozen']); + }); + + it('reports nothing for a form that falls back to the flat face (no layout row matches the schema)', () => { + const calls = reported({ type: 'simple', sections: [{ label: 'X', fields: [{ field: 'nope', required: true }] }] }); + expect(calls.flat()).toEqual([]); + }); + + it('reports none once the sectioned form unmounts', () => { + const calls: string[][] = []; + const { unmount } = render( + {}} onRequiredRowFieldsChange={(f) => calls.push([...f])} />, + ); + expect(calls.at(-1)?.length).toBeGreaterThan(0); + unmount(); + expect(calls.at(-1)).toEqual([]); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx index 5d94e31a1e2..92341066189 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx @@ -1134,6 +1134,15 @@ export interface SchemaFormProps { * out, so one mistake is one message (objectstack#27865). */ onInlineIssueFieldsChange?: (fields: ReadonlySet) => void; + /** + * Told which TOP-LEVEL rows this form offers whose served layout row says + * `required: true`: in a visible section, not hidden, editable. That flag is + * a layout override the schema need not share, so no `issues` the host gets + * from the schema carry it; the host judges an unset one itself + * (objectstack#28179). Only a sectioned layout has rows, so any other face + * reports none. + */ + onRequiredRowFieldsChange?: (fields: ReadonlySet) => void; } function SchemaFormBody({ @@ -1148,6 +1157,7 @@ function SchemaFormBody({ createMode = false, widgetContext, idPath, + onRequiredRowFieldsChange, }: SchemaFormProps) { // Live app locale (follows the i18next language, not just the browser) — // hoisted above the no-schema early return so the hook order is stable. @@ -1272,6 +1282,7 @@ function SchemaFormBody({ readOnly={readOnly} createMode={createMode} widgetContext={widgetContext} + onRequiredRowFieldsChange={onRequiredRowFieldsChange} onChange={setField} /> ); @@ -1583,6 +1594,7 @@ function SectionedSchemaForm({ createMode, widgetContext, idPath, + onRequiredRowFieldsChange, onChange, }: { /** The form's sections, already through {@link resolveFormSections}. */ @@ -1598,6 +1610,7 @@ function SectionedSchemaForm({ widgetContext?: WidgetContext; /** Ancestor id path, forwarded from the hosting `SchemaForm` (#5062). */ idPath?: string; + onRequiredRowFieldsChange?: SchemaFormProps['onRequiredRowFieldsChange']; onChange: (key: string, val: unknown) => void; }) { const locale = useMetadataLocale(); @@ -1611,11 +1624,9 @@ function SectionedSchemaForm({ return !visibility || evaluatePredicate(visibility, buildPredicateCtx(value, hostScope)); }); - // Decide whether to render as tabs or stacked sections. - const isTabbed = formType === 'tabbed' && sections.length > 1; - - const renderSection = (s: ResolvedFormSection, idx: number) => { - const fields = s.fields + // The rows a section offers: the one visibility rule every read below shares. + const visibleFields = (s: ResolvedFormSection): FormFieldSpec[] => + s.fields .map(normaliseField) .filter((f) => { if (f.hidden) return false; @@ -1626,6 +1637,33 @@ function SectionedSchemaForm({ } return true; }); + const rowReadOnly = (f: FormFieldSpec): boolean => + Boolean(readOnly || f.readonly || (f.immutable && !createMode)); + + // objectstack#28179 — report the offered rows whose layout says + // `required: true`. A row in a collapsed section or another tab is still + // offered; a hidden, read-only or schema-less one is not, so the host never + // holds Save on a row the author cannot fill. Keyed on the joined names, so + // no array identity re-runs the effect (AGENTS.md #10). + const requiredRowKey = sections + .flatMap(visibleFields) + .filter((f) => f.required === true && props[f.field] !== undefined && !rowReadOnly(f)) + .map((f) => f.field) + .join('\n'); + const onRequiredRowsRef = React.useRef(onRequiredRowFieldsChange); + React.useEffect(() => { + onRequiredRowsRef.current = onRequiredRowFieldsChange; + }); + React.useEffect(() => { + onRequiredRowsRef.current?.(new Set(requiredRowKey ? requiredRowKey.split('\n') : [])); + }, [requiredRowKey]); + React.useEffect(() => () => onRequiredRowsRef.current?.(new Set()), []); + + // Decide whether to render as tabs or stacked sections. + const isTabbed = formType === 'tabbed' && sections.length > 1; + + const renderSection = (s: ResolvedFormSection, idx: number) => { + const fields = visibleFields(s); if (fields.length === 0) return null; const cols = s.columns ?? 1; const fieldsGrid = ( @@ -1674,7 +1712,7 @@ function SectionedSchemaForm({ value={value[f.field]} required={f.required ?? required.includes(f.field)} issues={issuesByPath[f.field]} - readOnly={readOnly || f.readonly || (f.immutable && !createMode)} + readOnly={rowReadOnly(f)} fieldSpec={f} widgetContext={widgetContext} formData={value} @@ -1739,17 +1777,7 @@ function SectionedSchemaForm({ }; if (isTabbed) { - const tabSections = sections.filter( - (s) => - s.fields - .map(normaliseField) - .some((f) => { - if (f.hidden) return false; - if (hiddenFields.includes(f.field)) return false; - const visibility = readVisibility(f); - return !visibility || evaluatePredicate(visibility, buildPredicateCtx(value, hostScope)); - }), - ); + const tabSections = sections.filter((s) => visibleFields(s).length > 0); if (tabSections.length === 0) return null; const defaultTab = (tabSections[0].label ?? 'section-0').toLowerCase(); return ( diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 7c7b3082255..3930e9dfe52 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -4030,6 +4030,8 @@ const STUDIO_EN: Record = { // objectui#12810 — a create that leaves a required seed placeholder unset // (a dataset's base object) is refused before any request. 'engine.validation.seedPlaceholderUnset': 'Set {fields} before saving: a new item is never saved with the placeholder its template starts from.', + // objectstack#28179 — a row the served form marks required, left unset. + 'engine.validation.fieldRequired': 'This field is required.', 'engine.embedded.saved': 'Saved.', 'engine.embedded.readOnlyParent': 'The parent type is read-only — saving will still attempt a PUT and may be refused by the server.', 'engine.embedded.saveIntoParent': 'Save into {parentType}', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 45168260a74..3a1d6814292 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -3637,6 +3637,8 @@ const STUDIO_ZH: Record = { 'engine.validation.nameRequired': '名称必填。', // objectui#12810 — 见英文表同名条目。 'engine.validation.seedPlaceholderUnset': '请先设置 {fields} 再保存:新建项不会带着模板的占位值保存。', + // objectstack#28179 — 见英文表同名条目。 + 'engine.validation.fieldRequired': '此项为必填项。', 'engine.embedded.saved': '已保存。', 'engine.embedded.readOnlyParent': '父类型为只读;仍会尝试 PUT 保存,但服务器可能拒绝。', 'engine.embedded.saveIntoParent': '保存到 {parentType}', From cc15e14e58393be35911206573009c964e193655 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:56:24 +0000 Subject: [PATCH 2/3] test(app-shell): pin a schema-required field keeping its own verdict Claude-Session: https://claude.ai/code/session_01L9qibzECC3G6Nyga7Ysbjd Co-Authored-By: Claude --- .../ResourceEditPage.requiredRow-28179.test.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredRow-28179.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredRow-28179.test.tsx index 4e87670036b..9b6ab922fa8 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredRow-28179.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredRow-28179.test.tsx @@ -10,9 +10,9 @@ * refusal is the inline error on Label; typing a Label then saves; * (b) CONTROL — the same page over a served form whose Label row is not * required: an empty Label saves, the field stays optional; - * (c) a field the served SCHEMA requires keeps its own verdict: Save still - * sends (the server's refusal path, as before) and the new message is not - * added beside the schema's. + * (c) a field the served SCHEMA requires keeps its own verdict, the one this + * page gave before (the create seed's placeholder refusal), and the new + * message is not added beside it. * * The REAL page over a REAL `MetadataClient` whose transport is an in-memory * server recording every `PUT`, with the real served form from @@ -182,12 +182,18 @@ describe('a served required row holds Save (objectstack#28179)', () => { await screen.findByTestId('landed', {}, { timeout: 8000 }); }); - it('(c) a field the served schema requires keeps its own verdict: Save still sends, no second message', async () => { + it('(c) a field the served schema requires keeps its own verdict, unchanged — no second message', async () => { + // The served schema requires `label`, so the schema's own door decides an + // empty one: the create seed's placeholder is never saved + // (`engine.validation.seedPlaceholderUnset`, objectui#12810). That is the + // verdict this page gave before; the required-row rule adds nothing to it. served.schema = { ...PERMISSION_JSON_SCHEMA, required: [...((PERMISSION_JSON_SCHEMA.required as string[]) ?? []), 'label'] }; const { nameBox } = await openNewPermissionSet(); fireEvent.change(nameBox, { target: { value: 'sales_ops' } }); fireEvent.click(saveButton()); - await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 8000 }); + await screen.findByText(/before saving: a new item is never saved with the placeholder/, {}, { timeout: 8000 }); + await new Promise((r) => setTimeout(r, 300)); + expect(server.puts).toEqual([]); expect(screen.queryByText(REQUIRED)).toBeNull(); }); }); From 75f14a8c46828e827d452c6e1297b4a07a88aa2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 03:02:52 +0000 Subject: [PATCH 3/3] chore(changeset): required form row holds Save Claude-Session: https://claude.ai/code/session_01L9qibzECC3G6Nyga7Ysbjd Co-Authored-By: Claude --- .changeset/28179-required-form-row-holds-save.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/28179-required-form-row-holds-save.md diff --git a/.changeset/28179-required-form-row-holds-save.md b/.changeset/28179-required-form-row-holds-save.md new file mode 100644 index 00000000000..0a449ea3f3f --- /dev/null +++ b/.changeset/28179-required-form-row-holds-save.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(console): a metadata form row the served form marks required holds Save when it is left empty + +Clause-②: no +Metadata-protocol: none + +A served metadata form row's `required: true` is a layout override, and the type's schema may leave the field optional: the permission set's Label is the case today. The Console drew such a row with a required star only, so the create page saved it empty, because its checks read the schema and the server accepts the body. + +Now the metadata create and edit page judges those rows itself, for every metadata type. A row the form offers (in a visible section, not hidden, editable) whose served row says `required: true` and whose value is empty shows "This field is required." at that field, and Save sends no request until it is filled. A row in a collapsed section or another tab still counts; a hidden or read-only row never does, so Save is never held on a field the author cannot fill. A field the schema itself requires keeps the verdict it had before, and a field the schema gives a default is never empty. The server's accept sets do not change. + +`SchemaForm` gains an optional `onRequiredRowFieldsChange(fields)` prop that reports those rows.