diff --git a/.objectui-sha b/.objectui-sha index 0ce93d3c59..8f285974f6 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -514f426600bcc6284b67909dd7ced7e1bcb1d762 +de3224ecc47a8add79fa9a7ca8467ceed389a49c diff --git a/docs/adr/0035-config-driven-master-detail.md b/docs/adr/0035-config-driven-master-detail.md new file mode 100644 index 0000000000..07f86bcbb5 --- /dev/null +++ b/docs/adr/0035-config-driven-master-detail.md @@ -0,0 +1,119 @@ +# ADR-0035: Config-driven master-detail (declare on the relationship, forms derive) + +**Status**: Accepted +**Author**: surfaced while making master-detail (parent + child line-item) entry a first-class, low-config capability +**Affects**: `@objectstack/spec` (field schema), `@object-ui/plugin-form`, `@object-ui/plugin-view`, `@object-ui/app-shell`; builds on ADR-0034 (atomic multi-write) and the cross-object batch endpoint (#1604) + +--- + +## TL;DR + +Entering a record together with its child line items (invoice + lines, project + +tasks) must **not** require a hand-authored page or a per-form columns block. The +structure is already declared in the data model — a child's `master_detail` +relationship to its parent. So the **inline-editing intent belongs on the +relationship too**: set `inlineEdit: true` on the child's FK field, and every +standard create/edit form for the parent auto-renders an atomic master-detail +form. Forms **derive** the UI from metadata; they don't re-declare it. + +--- + +## Context + +Master-detail is one of the most common enterprise entry patterns. The naive +implementations force authors to either (a) build a custom page that composes a +form + an editable grid, or (b) hand-write the child columns in every form. Both +duplicate information that already lives in the metadata (the relationship, the +child's fields) and don't scale to an AI-authored platform, where the generator +should target a small, semantic vocabulary — not bespoke UI. + +Two facts make derivation possible: + +- The child→parent link is a `master_detail` field whose `reference` is the + parent (ownership + cascade already implied). +- The child object's fields fully describe the editable grid columns. + +What was missing was (1) a place to declare *intent* ("edit these inline"), +(2) runtime plumbing to persist parent + children atomically, and (3) the +derivation that turns the relationship into a rendered, editable grid. + +## Decision + +**Intent lives in the data model; the UI is derived; forms just follow.** + +``` +relationship inlineEdit (data model) ──derive──▶ every standard form renders + an atomic master-detail form +``` + +### The layered surface (most → least automatic) + +| Layer | Where | When | +|---|---|---| +| **Relationship `inlineEdit`** | child `master_detail` field | default; zero view/page config | +| **Form view `subforms`** | object form view | override derived columns/order, or expose a non-`inlineEdit` child | +| **`object-master-detail-form`** | page block | bespoke/free-form layouts | + +All three converge on the same runtime: `MasterDetailForm`, which renders the +parent's fields + an editable child grid per collection and persists everything +through the transactional batch. + +### Derivation rules + +- **Relationship FK**: the child field of type `master_detail`/`lookup` whose + `reference` is the parent (master_detail preferred). Auto-detected; override + with `relationshipField`. +- **Grid columns**: the child object's fields, skipping system/audit fields, the + back-reference FK, and non-editable types (formula/summary/autonumber/file/ + json/…); select options and lookup references carry through. Override with + `columns` / `inlineColumns`. +- **Running total**: first numeric/currency column; override with `amountField` / + `inlineAmountField`. +- **Only `inlineEdit` children are inlined.** `master_detail` ≠ "show in the + entry form": comments, attachments, audit, activity are commonly + `master_detail` (cascade delete) but are associations, not line items, and + must stay out of the parent's create form (surface as related lists). + +### Runtime backbone (relied upon, not re-litigated here) + +- **Atomic write**: parent + children in one `POST /api/v1/batch`; intra-batch + `{ $ref: }` resolves a child FK to the parent created earlier in the + same transaction. Edit mode diffs children into create/update/delete ops. + (Depends on ADR-0034's ambient transaction.) +- **Server-side rollup**: a parent `summary` field is recomputed by the engine + when children change, inside the same transaction — totals are server-owned, + not summed on the client. + +### Where derivation is wired (objectui) + +`MetadataProvider.attachInlineSubforms` scans objects for `inlineEdit` +relationships and merges the resulting child collections into each parent's form +view as `subforms`. Because every form host — the create/edit modal +(`AppContent` → `ModalForm`), `DrawerForm`, full-page `RecordFormPage`, and +`ObjectView`'s own form — already reads `form.subforms`, they all render the +master-detail form for free. `ModalForm`/`DrawerForm` host the master-detail form +inside their envelope and suppress their own footer (the form owns its Save). + +## Consequences + +- **For authors / AI generators**: master-detail is a one-line modeling decision + (`inlineEdit: true`) made where the schema is defined — not a UI task. The skills + (`objectstack-data` → Relationships → Inline Editing; `objectstack-ui` → + Master-Detail Forms) document the convention. +- **Single source of truth**: relationship + child metadata drive the UI; changing + a child field updates every parent form. Skins/form types/apps stay consistent. +- **Safe by default**: opt-in per relationship avoids inlining associations. +- **Escape hatches preserved**: `subforms` (view) overrides; a page block handles + bespoke layouts; explicit `relationshipField`/`columns` override derivation. +- **Read/view mode** is unaffected — inline editing applies to create/edit forms; + detail pages use related lists. + +## Status of implementation + +Shipped and live-verified: spec `field.inlineEdit` (+ `inlineTitle`/ +`inlineColumns`/`inlineAmountField`); column/FK derivation; `subforms` on +`ObjectFormSchema`/`FormViewSchema`; rendering in ObjectForm/ModalForm/DrawerForm/ +RecordFormPage/ObjectView; `attachInlineSubforms`; server-side `summary` rollup; +atomic `/api/v1/batch` with `$ref`. Covered by unit tests and live browser e2e +(`e2e/live/master-detail.spec.ts`, `form-view-subforms.spec.ts`, +`summary-rollup.spec.ts`). diff --git a/skills/objectstack-data/rules/relationships.md b/skills/objectstack-data/rules/relationships.md index 5a16932d01..4a409e8ca0 100644 --- a/skills/objectstack-data/rules/relationships.md +++ b/skills/objectstack-data/rules/relationships.md @@ -169,21 +169,67 @@ Configure `deleteBehavior` on `master_detail` relationships: ## Roll-up Summaries -Available only on `master_detail` relationships: +A parent `summary` field aggregates a child collection. The engine recomputes it +**server-side** whenever a child is inserted/updated/deleted (inside the same +transaction as the write, so it's consistent and never summed on the client). ```typescript -// On parent object +// On the PARENT object: { type: 'summary', - reference: 'child_object', // Name of child object - summaryType: 'count', // 'count' | 'sum' | 'min' | 'max' | 'avg' - summaryField: 'amount', // Field to aggregate (not needed for count) - referenceFilters: { // Optional: filter which children to include - status: 'active', + summaryOperations: { + object: 'invoice_line', // child object to aggregate + field: 'amount', // child field to aggregate (ignored for count) + function: 'sum', // 'count' | 'sum' | 'min' | 'max' | 'avg' + // relationshipField: 'invoice' // optional; auto-detected from the child's + // master_detail/lookup field referencing + // this parent when omitted }, } ``` +Empty collections roll up to `0` for `count`/`sum`, `null` for `min`/`max`/`avg`. +Pairs naturally with inline editing (below): the parent total updates atomically +as line items are saved. + +## Inline Editing (Master-Detail Entry) + +Declare inline editing **on the relationship**, in the data model — not in a form +view. Set `inlineEdit: true` on the child's `master_detail` (or `lookup`) field +that points back to the parent. The parent's **standard** create/edit form then +renders an editable grid for these children and saves parent + children in **one +atomic transaction** — with no form-view config and no bespoke page. The UI is +derived from metadata (relationship FK + child fields → grid columns). + +```typescript +// On the CHILD object's FK field: +export default ObjectSchema.create({ + name: 'invoice_line', + fields: { + invoice: { + type: 'master_detail', + reference: 'invoice', + inlineEdit: true, // ← edited inline within the Invoice form + inlineTitle: 'Line Items', // optional grid title + // inlineColumns / inlineAmountField — optional overrides; columns are + // otherwise derived from this object's fields. + }, + quantity: { type: 'number' }, + amount: { type: 'currency' }, + }, +}); +``` + +**Set `inlineEdit` only for true line-item / composition children** (invoice +lines, order items, expense lines) — the things a user enters *together with* +the parent. **Leave it off for associations** (comments, attachments, activity, +audit): those are also `master_detail` (cascade delete) but should NOT clutter +the parent's entry form — surface them as related lists on the detail page. + +A form view may still set `subforms` to override the derived columns/order, but +the relationship `inlineEdit` is the primary, zero-config path. See the +objectstack-ui skill (Master-Detail Forms) for the rendering side. + ## Incorrect vs Correct ### ❌ Incorrect — Using lookup When master_detail is Needed diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 9c4ebe2c95..97c68e1b42 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -61,6 +61,44 @@ App navigation, Dashboards, Reports, and Actions. | `tabbed` | Tabbed sections — for complex objects with many field groups | | `wizard` | Step-by-step flow — guided data entry (onboarding, applications) | +### Master-Detail Forms (parent + child line items) + +To let users enter a record **together with its child line items** (invoice + +lines, project + tasks) and save them **atomically**, you almost never need a +custom page or form config. Prefer, in order: + +1. **Relationship `inlineEdit` (default, zero UI config).** Declare it in the + DATA MODEL — set `inlineEdit: true` on the child's `master_detail` field that + references the parent (see the objectstack-data skill → Relationships → + Inline Editing). Every standard New/Edit form for the parent (modal, drawer, + full-page) then auto-renders an editable child grid and saves parent + + children in one atomic `/api/v1/batch`. **No view metadata needed.** + +2. **Form view `subforms` (override / tuning).** Add to a form view only when you + need to override the derived columns/order, or expose a child the + relationship didn't mark inline: + + ```typescript + formViews: { + default: { + type: 'simple', + sections: [{ label: 'Invoice', fields: ['number', 'account'] }], + subforms: [ + { childObject: 'invoice_line', // relationshipField + columns are + title: 'Line Items', // derived from the child object; + addLabel: 'Add line' }, // set `columns` here only to override. + ], + }, + }, + ``` + +3. **`object-master-detail-form` page block (bespoke layout).** Use a page only + for free-form layouts. Same `details: [{ childObject }]` shorthand. + +The relationship FK and grid columns are derived from the child object's +metadata in every case; select options and lookups carry through. A parent +`summary` field rolls child values up server-side (see objectstack-data). + --- ## Configuring a List View