From b8278df824a11b3806bc9d51442e39a47c169248 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:22:41 +0800 Subject: [PATCH] docs(skills): document line-item grid behaviors (data + ui) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectstack-ui → Master-Detail Forms: add "Line-item grid behaviors" — computed columns via a stored field's expression, the trailing ghost row, item-typeahead auto-fill, persisted drag-reorder (position field), the Subtotal/Tax/Total stack (parent tax_rate / taxRateField), inline validation + duplicate. objectstack-data → Relationships: add a "Modeling a 'grid' line item" recipe (expression computed column, catalog lookup auto-fill, position sort field, parent tax_rate + summary rollup). Co-Authored-By: Claude Opus 4.8 --- .../objectstack-data/rules/relationships.md | 38 +++++++++++++++++++ skills/objectstack-ui/SKILL.md | 33 ++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/skills/objectstack-data/rules/relationships.md b/skills/objectstack-data/rules/relationships.md index f855fc899..beaf184aa 100644 --- a/skills/objectstack-data/rules/relationships.md +++ b/skills/objectstack-data/rules/relationships.md @@ -249,6 +249,44 @@ inlineEdit: 'form' // read-only list; "Add" / per-row edit opens the FULL fo rich/form-only fields (textarea, file, image, json, location…) or more than ~8 editable fields, else `grid`. Set the string to override. +**Modeling a `'grid'` line item for the full editor.** The grid lights up extra +behaviors purely from how you model the child + parent (no UI config — details +in the objectstack-ui skill → Master-Detail Forms): + +```typescript +// Parent +Invoice = { + fields: { + tax_rate: Field.number({ label: 'Tax Rate (%)' }), // → live Subtotal/Tax/Total stack + total: Field.summary({ summaryOperations: { // server roll-up of the line subtotal + object: 'invoice_line', field: 'amount', function: 'sum' } }), + }, +} +// Child line +InvoiceLine = { + fields: { + invoice: Field.masterDetail('invoice', { inlineEdit: 'grid' }), + position: Field.number({ defaultValue: 0 }), // → drag-reorder, persisted (auto-hidden col) + product: Field.lookup('product', { required: true }), // → catalog typeahead + description: Field.text(), // ← auto-filled from product.description + quantity: Field.number({ required: true, defaultValue: 1 }), + unit_price: Field.currency(), // ← auto-filled from product.unit_price + amount: Field.currency({ expression: 'record.quantity * record.unit_price' }), // computed, read-only, live + }, +} +``` + +- **Computed column** — a *stored* `currency`/`number` field with an `expression` + renders read-only and recomputes live in the grid, then persists. Keep it + stored (NOT a `formula` field) so the parent `summary` can roll it up; the + server only treats `type: 'formula'` as computed, so on a stored field the + `expression` is a client compute hint and the sent value is stored verbatim. +- **Catalog auto-fill** — a `lookup` line field + sibling columns whose names + match fields on the referenced record (e.g. `unit_price`, `description`) → + picking a record fills those cells. +- **Sort field** — a numeric `position` / `sort_order` / `sequence` field is + auto-detected, hidden from the grid, and stamped on drag-reorder. + ### Detail-page related lists (the read-side mirror) Where `inlineEdit` is the **write** side (child pulled into the parent's entry diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index aefa6a1ab..a4df4bfc0 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -103,6 +103,39 @@ 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). +**Line-item grid behaviors (`grid` mode).** The editable grid is a real +spreadsheet-style line editor (the QuickBooks / Stripe / NetSuite pattern). All +of the following come from the DATA MODEL — no UI config — so they apply to any +inline grid, not just invoices: + +- **Computed columns.** A child field with an arithmetic `expression` + (e.g. `amount: Field.currency({ expression: 'record.quantity * record.unit_price' })`) + renders **read-only** and is recomputed **live** client-side as its inputs + change, then persisted. Keep it a *stored* field (`currency`/`number`), NOT a + `formula` field, so a parent `summary` can still roll it up — the server only + treats `type: 'formula'` as computed, so a stored field's `expression` is a + client-side display/compute hint and the sent value is stored as-is. The + evaluator supports `+ - * / %`, parens and `record.` refs only. +- **Trailing "ghost" row.** The grid always shows one empty line at the bottom; + typing in it materialises a real row and a fresh ghost appears — users never + click "Add line", and an untouched ghost is never persisted. +- **Item typeahead auto-fill.** When a `lookup` cell's record is picked, the grid + copies the chosen record's fields into any **same-named** sibling columns + (e.g. a product's `unit_price` / `description` drop into the line). Model it by + giving the line a `lookup` to the catalog plus columns whose names match the + catalog fields. Opt out per column with `autofill: false`. +- **Persisted drag-reorder.** Add a numeric sort field to the child named + `position` (or `sort_order` / `sequence` / `line_no`). The grid auto-detects + it, hides it from the editable columns, and stamps `row[position] = index` on + reorder so line order survives a reload. +- **Totals stack.** Give the PARENT a tax-rate field named `tax_rate` (percent + number). The master-detail form then renders a live **Subtotal → Tax → Total** + block under the lines (override the field name with the form's `taxRateField`). + The parent `summary` persists the line subtotal; the tax-inclusive grand total + is a live entry-time aid. +- Per-cell **inline validation** (required-empty cells flag red in place) and a + hover **duplicate** action come for free. + **Read side — detail-page related lists.** The mirror of `inlineEdit` is the related list on the parent's record DETAIL page. You don't author it: every child relationship is shown as a related list by default (owned `master_detail`