Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/showcase-contact-field-groups.md
Original file line number Diff line number Diff line change
@@ -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 包),不发布任何包。
23 changes: 22 additions & 1 deletion examples/app-showcase/src/data/objects/contact.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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' },
],
});
26 changes: 21 additions & 5 deletions examples/app-showcase/src/ui/views/contact.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions examples/app-showcase/test/seed.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Loading