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. 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..9b6ab922fa8 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredRow-28179.test.tsx @@ -0,0 +1,199 @@ +// 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, 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 + * `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, 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 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(); + }); +}); 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}',