diff --git a/.changeset/showcase-contact-field-groups.md b/.changeset/showcase-contact-field-groups.md new file mode 100644 index 0000000000..a429774dd9 --- /dev/null +++ b/.changeset/showcase-contact-field-groups.md @@ -0,0 +1,28 @@ +--- +--- + +fix(showcase): `showcase_contact` 声明 `fieldGroups`,让视图注释承诺的自动分组成真(#5443) + +`examples/app-showcase/src/data/objects/contact.object.ts` 的九个字段都写了 +`group`(contact/work/status/notes),对象却没有声明 `fieldGroups`。按 ADR-0085 §5, +`deriveFieldGroupLayout` 只把 `group` 命中已声明 `fieldGroups[].key` 的字段归入分组, +其余一律落进末尾的未分组桶 —— 所以这九个 `group` 与完全不写 `group` 渲染结果相同, +`os lint` 也据此报了九条 `field-group-undeclared`。 + +而 `src/ui/views/contact.view.ts` 的头注释(`content/docs/ui/create-vs-edit-form.mdx` +引用的那份参考实现)写的是:手写的 `form`「镜像平台自动派生的结果」,「省掉它就能免费 +得到等价的分组表单」。在没有 `fieldGroups` 的前提下这句是假的 —— 照抄示例的读者省掉 +`form` 拿到的是一张平铺表单。这正是 Prime Directive #10 的推论(不要宣传运行时并不 +提供的能力),只不过说谎的是示例自己的注释。 + +本次改动:① 给 `showcase_contact` 补上四个 `fieldGroups` 声明(label 与视图四个段落 +一致);② 校准视图头注释 —— 点明派生以 `fieldGroups` 声明为授权来源,并如实写出省掉 +`form` 后与手写版的两处差异(派生结果会在末尾追加平台注入的 `owner_id` 未分组段; +`columns: 2` 这类段落排版是表单视图的旋钮,分组声明不携带);③ `test/seed.test.ts` +新增用例,把「派生段落 == 视图手写段落(顺序与成员)」这条承诺本身钉住。 + +九条 `field-group-undeclared` 归零(484 → 475 warnings,无新规则)。`_sections` 派生 +键集合不变(仍是 contact/work/status/notes 四键,与 #5438 已译的四键同名,walker 去重), +`scripts/i18n-coverage-baseline.json` 与 `supportedLocales` 均未改动。 + +仅改示例应用(`examples/app-showcase` 为 private 包),不发布任何包。 diff --git a/examples/app-showcase/src/data/objects/contact.object.ts b/examples/app-showcase/src/data/objects/contact.object.ts index 177bc4dee4..729aac0da2 100644 --- a/examples/app-showcase/src/data/objects/contact.object.ts +++ b/examples/app-showcase/src/data/objects/contact.object.ts @@ -13,7 +13,9 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * single source — no second hand-maintained field list: * * - `group` → which section a field belongs to (semantic grouping that - * travels with the data model, not a per-form layout). + * travels with the data model, not a per-form layout). The + * group must also be DECLARED in `fieldGroups` below — that + * declaration is what authorizes the derivation (ADR-0085 §5). * - declaration order = display order (there is no `field.order`; the order * you write fields in IS the default order everywhere). * - `required` → must appear on the create form. @@ -67,4 +69,23 @@ export const Contact = ObjectSchema.create({ // ── Notes group: long-form, edit-time only. ── notes: Field.text({ label: 'Notes', maxLength: 4000, group: 'notes' }), }, + + // [ADR-0085 §5] The DECLARATION side of the grouping edge, and the reason the + // section headings above are real sections rather than decoration. + // + // `Field.group` alone does not create a group: `deriveFieldGroupLayout` — + // the one derivation every renderer and the i18n walker consume — only + // buckets a field whose `group` matches a key declared HERE, and drops + // everything else into the trailing untitled bucket. So a `group` with no + // matching entry renders exactly like no `group` at all (`os lint` says so + // as `field-group-undeclared`), which is what these nine fields did before + // #5443. Array order is display order; the labels match the four sections + // `ui/views/contact.view.ts` writes out by hand, because the whole point of + // that file's comment is that the two agree. + fieldGroups: [ + { key: 'contact', label: 'Contact' }, + { key: 'work', label: 'Work' }, + { key: 'status', label: 'Status' }, + { key: 'notes', label: 'Notes' }, + ], }); diff --git a/examples/app-showcase/src/ui/views/contact.view.ts b/examples/app-showcase/src/ui/views/contact.view.ts index 26aa2fa6e6..04ba8b5983 100644 --- a/examples/app-showcase/src/ui/views/contact.view.ts +++ b/examples/app-showcase/src/ui/views/contact.view.ts @@ -13,11 +13,27 @@ const data = { provider: 'object' as const, object: 'showcase_contact' }; * Two projections of ONE flat, grouped field set (see objects/contact.object.ts): * * • `form` (default edit/detail) — the FULL record, grouped into sections by - * each field's `group`. This mirrors what the platform auto-derives from - * `field.group`; it is written out explicitly here only so the example is - * legible. In the target model you can OMIT it and get an equivalent - * grouped form for free. Sections list fields as bare strings → every - * field inherits its type / validation / FLS / default from the object. + * each field's `group`. This mirrors what the platform auto-derives, and + * it is written out explicitly here only so the example is legible: OMIT + * it and `deriveFieldGroupLayout` (ADR-0085 §5) produces these same four + * sections, in this order, with these members. Sections list fields as + * bare strings → every field inherits its type / validation / FLS / + * default from the object. + * + * The derivation's authority is the object's `fieldGroups` DECLARATION, + * not `field.group` on its own: a field whose `group` names no declared + * key falls into the trailing ungrouped bucket exactly as if it carried no + * `group` at all (`os lint` reports it as `field-group-undeclared`). That + * is why `objects/contact.object.ts` declares all four keys — without them + * this comment would be promising a grouping the platform never derives + * (#5443). + * + * Two honest differences from the hand-written version below, both of them + * the reason the explicit `form` is still worth authoring here: the + * derived layout appends the platform-injected `owner_id` in a trailing + * untitled section (audit/system columns are excluded, ownership is not), + * and per-section presentation like `columns: 2` is a form-view knob the + * group declaration does not carry. * * • `formViews.create` (the escape hatch) — a SPARSE override for the create * experience: just the core fields, one ungrouped section. Note what is diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index 18acf91c96..c122b63cdf 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { deriveFieldGroupLayout } from '@objectstack/spec/data'; import stack from '../objectstack.config.js'; import { ShowcaseSeedData } from '../src/data/seed/index.js'; import * as viewBarrel from '../src/ui/views/index.js'; @@ -122,6 +123,54 @@ describe('showcase stack', () => { } }); + /** + * #5443 — the comment at the top of `ui/views/contact.view.ts` promises that + * the hand-written `form` "mirrors what the platform auto-derives", i.e. that + * you may OMIT it and get the same grouped form. That promise is only true if + * the object DECLARES the four groups: `deriveFieldGroupLayout` (ADR-0085 §5, + * the one derivation every renderer and the i18n walker consume) buckets a + * field only when its `group` matches a declared `fieldGroups[].key`, and + * otherwise drops it into the trailing untitled bucket — which is exactly + * what nine `showcase_contact` fields did while the comment claimed + * otherwise (`os lint`: 9 × `field-group-undeclared`). + * + * So this pins the promise itself, not the declaration: the derived sections + * must equal the authored `form.sections` in ORDER and MEMBERSHIP. Deleting + * `fieldGroups` makes `deriveFieldGroupLayout` return `null` (no declared + * groups → grouping does not apply), and the assertion goes red on the first + * line rather than passing vacuously on an empty comparison. + * + * Read on the composed stack for the same reason the section-key test above + * is: what renderers and gates see is `stack.objects`, not the imported + * module. The trailing ungrouped bucket the SERVED object grows (the + * platform injects `owner_id`, which is neither hidden nor an audit column) + * is deliberately out of frame here — it is a registration-time addition, and + * the view comment states it in prose. + */ + it('derives the contact form sections from `fieldGroups` exactly as the view authors them', () => { + const object = (stack.objects ?? []).find( + (o: { name: string }) => o.name === 'showcase_contact', + ); + const derived = deriveFieldGroupLayout(object); + expect(derived, 'showcase_contact must declare fieldGroups (#5443)').not.toBeNull(); + expect(derived!.map((s) => s.key)).toEqual(['contact', 'work', 'status', 'notes']); + + const contact = (stack.views ?? []).find((v) => targetObject(v) === 'showcase_contact'); + const form = (contact as { form?: unknown } | undefined)?.form; + const authored = (form as { sections?: unknown } | undefined)?.sections; + const authoredPairs = (Array.isArray(authored) ? authored : []).map((s) => { + const section = s as { name?: unknown; label?: unknown; fields?: unknown }; + return { + key: section.name, + label: section.label, + fields: Array.isArray(section.fields) ? section.fields : [], + }; + }); + expect(derived!.map((s) => ({ key: s.key, label: s.label, fields: s.fields }))).toEqual( + authoredPairs, + ); + }, 60_000); + it('registers UI, automation, security, and AI metadata', () => { expect((stack.views ?? []).length).toBeGreaterThan(0); expect((stack.dashboards ?? []).length).toBeGreaterThan(0);