From b4ae0e486542c5926624973578887378b65b2bf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:12:31 +0000 Subject: [PATCH] docs(ui): field-group derivation is authorized by `fieldGroups`, not by `field.group` alone (#5458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UI guides taught that form sectioning derives from `field.group` by itself, and neither page mentioned `fieldGroups` at all. Copying either one yields a FLAT form: `deriveFieldGroupLayout` (ADR-0085 §5) buckets only fields whose `group` matches a declared `fieldGroups[].key`, and with no declared group it returns null outright — so an undeclared `group` renders exactly like no `group`, and `os lint` reports `field-group-undeclared`. Same falsehood as #5443, one layer up: that issue fixed the showcase object the two pages link to as their runnable example; this fixes the prose that cites it. - create-vs-edit-form.mdx §2: the derivation's authorization source, the undeclared-equals-unwritten equivalence, and the lint rule name. Also drops "equivalent grouped form" for the two measured differences a derived form really has (platform-injected `owner_id` lands in the trailing untitled section; `columns` is a form-view knob the group declaration cannot carry). - field-grouping-and-order.mdx: same three facts on the semantic-grouping section, and its sample — previously a `fields: {…}` fragment with no declaration side — becomes a self-contained `ObjectSchema.create` with the matching `fieldGroups`, marked `{/* os:check */}` so the gate type-checks it against the built spec instead of leaving the corrected example ungated. --- content/docs/ui/create-vs-edit-form.mdx | 22 +++++++++--- content/docs/ui/field-grouping-and-order.mdx | 38 ++++++++++++++------ 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/content/docs/ui/create-vs-edit-form.mdx b/content/docs/ui/create-vs-edit-form.mdx index ad1f9af719..a77b8244bc 100644 --- a/content/docs/ui/create-vs-edit-form.mdx +++ b/content/docs/ui/create-vs-edit-form.mdx @@ -23,14 +23,25 @@ The create-form subset is not an arbitrary pick; it is *derivable* from signals | `readonly` / formula / rollup / autonumber / system-stamped | **never** on create (you can't set it) | | `defaultValue` present | **can be omitted** from create (it self-fills) | | `hidden` | off everywhere by default | -| `group` | which section the field belongs to (semantic, travels with the model) | +| `group` | which section the field belongs to (semantic, travels with the model — and only once that group is **declared** in `fieldGroups`; see §2) | | *declaration order* | the order you write fields in **is** the default order everywhere — there is no `field.order` | So a sensible create form is: *editable, required-or-core fields, in declaration order* — and it **falls out** of the object. This is ADR-0047's guardrail: **omission is correct** — emit nothing extra and you still get a complete, correct form. -### 2. The default (edit) form derives from `field.group` +### 2. The default (edit) form derives from `field.group` — as authorized by `fieldGroups` -The full edit form materialises each `field.group` into a section. You can omit it entirely and let the platform derive an equivalent grouped form. When you do write it, list fields as **bare strings** so each one inherits its type / validation / FLS / default from the object — the form carries layout only, never data semantics. +`field.group` on its own does not create a section. The **authorization source is the object's `fieldGroups` declaration**: `deriveFieldGroupLayout` (ADR-0085 §5) — the single derivation every renderer and the i18n walker consume — sections only those fields whose `group` matches a declared `fieldGroups[].key`. + +- An **undeclared** `group` renders **exactly like no `group` at all**: the field drops into a trailing untitled bucket. +- Declare **no** `fieldGroups` at all and grouping does not apply — the derivation yields nothing and you get a flat form. +- Either way, `os lint` (and `os build` / `os validate`) reports every unmatched reference as **`field-group-undeclared`**. + +So tag the fields **and** declare the groups — [`contact.object.ts`](#runnable-example) does both. With both halves present, the full edit form materialises each declared group into a section, and you can omit the `form` view entirely and let the platform derive the grouped form. Two ways the derived form still differs from the hand-written one below: + +- fields the platform injects and you never grouped (`owner_id`, under `sharingModel: 'private'`) surface in that trailing untitled section; +- `columns` is a **form-view** knob, and the group declaration has no column count to give — a derived section carries only `key` / `label` / `icon` / `description` / `collapse` and its member fields — so a hand-written `columns: 2` is not part of what derivation gives back. + +When you do write the form, list fields as **bare strings** so each one inherits its type / validation / FLS / default from the object — the form carries layout only, never data semantics. ### 3. Hand-shape the create form *only when layout or flow diverges* @@ -50,7 +61,8 @@ export const ContactViews = defineView({ addRecord: { enabled: true, mode: 'form', formView: 'create' }, }, - // Full edit form — grouped by field.group; bare strings inherit field defs. + // Full edit form — one section per group DECLARED in the object's + // `fieldGroups`; bare strings inherit field defs. form: { type: 'simple', data, sections: [ @@ -102,7 +114,7 @@ Rule of thumb: **"different field subset" → derive. "different layout or flow" ## Runnable example -- Object: [`examples/app-showcase/src/data/objects/contact.object.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/data/objects/contact.object.ts) — flat, grouped, intent-tagged field set. +- Object: [`examples/app-showcase/src/data/objects/contact.object.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/data/objects/contact.object.ts) — flat, intent-tagged field set, with the four `fieldGroups` its `group` tags point at. - Views: [`examples/app-showcase/src/ui/views/contact.view.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/ui/views/contact.view.ts) — full edit form + sparse `formViews.create` + `addRecord` binding. ## Anti-patterns diff --git a/content/docs/ui/field-grouping-and-order.mdx b/content/docs/ui/field-grouping-and-order.mdx index 24dadcc58d..9d6601e257 100644 --- a/content/docs/ui/field-grouping-and-order.mdx +++ b/content/docs/ui/field-grouping-and-order.mdx @@ -25,17 +25,34 @@ So "flat vs grouped" is not a contradiction — it's one flat set seen through d There are **two** grouping concepts. Keep them distinct: -**1. Semantic grouping — `field.group` (on the object).** A field's logical home ("billing", "contact_info", "system"). It travels with the model. The Studio field editor folds fields by it, and auto-generated forms use it as the **default** sectioning — so you are not starting from zero. +**1. Semantic grouping — `field.group` + `fieldGroups` (on the object).** A field's logical home ("billing", "contact_info", "system"). It travels with the model. The Studio field editor folds fields by it, and auto-generated forms use it as the **default** sectioning — so you are not starting from zero. +Semantic grouping has **two halves, and you need both**. `fieldGroups` on the object is the **authorization source** for the derivation: `deriveFieldGroupLayout` (ADR-0085 §5) — the one implementation every renderer and the i18n walker consume — sections only those fields whose `group` matches a declared `fieldGroups[].key`. An **undeclared** `group` renders **exactly like no `group` at all** (the field falls into a trailing untitled bucket); with **no** `fieldGroups` declared at all, grouping does not apply and the form comes out flat. `os lint` (and `os build` / `os validate`) reports the mismatch as **`field-group-undeclared`**. + +{/* os:check */} ```ts -fields: { - name: Field.text({ label: 'Full name', group: 'contact' }), - email: Field.email({ label: 'Email', group: 'contact' }), - stage: Field.select({ label: 'Stage', group: 'status', options: [/* … */] }), -} +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const Contact = ObjectSchema.create({ + name: 'showcase_contact', + label: 'Contact', + + // The DECLARATION half — array order is display order (there is no `order` key). + fieldGroups: [ + { key: 'contact', label: 'Contact' }, + { key: 'status', label: 'Status' }, + ], + + // The MEMBERSHIP half — each `group` must match a `key` declared above. + fields: { + name: Field.text({ label: 'Full name', group: 'contact' }), + email: Field.email({ label: 'Email', group: 'contact' }), + stage: Field.select({ label: 'Stage', group: 'status', options: [{ label: 'New', value: 'new' }] }), + }, +}); ``` -**2. Layout grouping — form `sections` (on a view).** A specific form's explicit arrangement: which fields, which section, how many columns, collapsible. It can **inherit** `field.group` as the default or **override** it per form. +**2. Layout grouping — form `sections` (on a view).** A specific form's explicit arrangement: which fields, which section, how many columns, collapsible. It can **inherit** the object's declared groups as its default or **override** them per form. ```ts form: { @@ -68,17 +85,18 @@ Keeping grouping off the field (beyond an optional semantic hint) is what lets * ## Runnable example -- [`examples/app-showcase/src/data/objects/contact.object.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/data/objects/contact.object.ts) — fields tagged with `group`. +- [`examples/app-showcase/src/data/objects/contact.object.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/data/objects/contact.object.ts) — four declared `fieldGroups`, and the fields tagged with the matching `group`. - [`examples/app-showcase/src/ui/views/contact.view.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/ui/views/contact.view.ts) — sections that materialise those groups. ## Anti-patterns - **Adding structural nesting to the data model to satisfy a form.** The model is flat; let the form group. -- **Re-typing the grouping in every form.** Declare `field.group` once; forms inherit and only override on real divergence. +- **Re-typing the grouping in every form.** Declare the groups (`fieldGroups`) and tag the fields (`field.group`) once; forms inherit and only override on real divergence. +- **Tagging fields with a `group` you never declared in `fieldGroups`.** The tag reads as intent but authorizes nothing — the fields render ungrouped, and `os lint` says so as `field-group-undeclared`. - **Assuming a grid "group" will section your form fields.** It buckets rows by value — a different axis entirely. ## See also - [Create form ≠ edit form](/docs/ui/create-vs-edit-form). -- Reference: [Field schema](/docs/references/data/field) (`group`), [View schema](/docs/references/ui/view) (`sections`, `grouping`). +- Reference: [Object schema](/docs/references/data/object) (`fieldGroups`), [Field schema](/docs/references/data/field) (`group`), [View schema](/docs/references/ui/view) (`sections`, `grouping`). - Studio: [Object Designer](/docs/references/studio/object-designer) — field editor groups by `field.group`.