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
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
514f426600bcc6284b67909dd7ced7e1bcb1d762
de3224ecc47a8add79fa9a7ca8467ceed389a49c
119 changes: 119 additions & 0 deletions docs/adr/0035-config-driven-master-detail.md
Original file line numberDiff line numberDiff line change
@@ -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: <opIndex> }` 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`).
60 changes: 53 additions & 7 deletions skills/objectstack-data/rules/relationships.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
38 changes: 38 additions & 0 deletions skills/objectstack-ui/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading