From b879c7c134a82fc90d03f295733e6c517f151630 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:12:18 +0000 Subject: [PATCH 1/3] feat(plugin-form): derive the inline grid's default columns through the spec's deriveInlineGridColumns (objectui#11345) `deriveColumns` now takes which columns an inline master-detail grid draws, their order and their `defaultHidden` flag from `@objectstack/spec`'s `deriveInlineGridColumns`, and builds each column from its child field with the per-column builder it already had (extracted as `deriveColumn`). The local budget (`curateColumns`, `NAME_LIKE_FIELDS`, `TYPE_FILL_PRIORITY`), the local column filter (`NON_EDITABLE_TYPES`) and the module's own `DEFAULT_MAX_INLINE_COLUMNS = 6` are deleted; the budget is the spec's `DEFAULT_MAX_INLINE_GRID_COLUMNS`. Signature and output of the public `deriveColumns` are unchanged. A new pin holds a corpus to both answers: the spec's names / order / defaultHidden, and the full output the replaced implementation returned. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL --- ...sterDetail.inlineGridColumns-11345.test.ts | 285 ++++++++++++++++++ .../plugin-form/src/deriveMasterDetail.ts | 211 +++++-------- 2 files changed, 365 insertions(+), 131 deletions(-) create mode 100644 packages/plugin-form/src/deriveMasterDetail.inlineGridColumns-11345.test.ts diff --git a/packages/plugin-form/src/deriveMasterDetail.inlineGridColumns-11345.test.ts b/packages/plugin-form/src/deriveMasterDetail.inlineGridColumns-11345.test.ts new file mode 100644 index 0000000000..113e39c65e --- /dev/null +++ b/packages/plugin-form/src/deriveMasterDetail.inlineGridColumns-11345.test.ts @@ -0,0 +1,285 @@ +/** + * objectui#11345 — `deriveColumns` takes WHICH columns an inline master-detail + * grid draws, their order and their `defaultHidden` flag from + * `@objectstack/spec`'s `deriveInlineGridColumns`, and keeps its own per-column + * builder for what each column carries. + * + * Two pins over one corpus, and they guard different things: + * + * 1. The names, order and `defaultHidden` ARE the spec's answer. This is what + * lets objectstack's `field-no-consumers` lint credit exactly the columns + * this grid draws: a local filter or budget creeping back in would let the + * two disagree, and this pin goes red the moment one changes an answer. + * 2. The full output is what `deriveColumns` returned BEFORE the swap. The + * expected arrays below were produced by the implementation this card + * replaced (the local `curateColumns` budget), and `deriveColumns` is a + * public export of `@object-ui/plugin-form`: the swap must not move a single + * column, label, type or flag. The corpus covers the budget edge from both + * sides, `maxColumns` (explicit, `0`, negative), the relationship field, + * `exclude`, the filters, more required columns than the budget, a computed + * required column, and FALSY field definitions — the one input where the + * spec's identity-only names, hydrated by `hydrateColumns`, would answer a + * bare `{ name }` where this builder answers a text column. + * + * `toStrictEqual` on purpose: an own `defaultHidden: undefined` (or a dropped + * `displayField: undefined`) is a different object to a consumer that spreads + * or serializes it, and `toEqual` would read both as equal. + */ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_MAX_INLINE_GRID_COLUMNS, deriveInlineGridColumns } from '@objectstack/spec/data'; +import { deriveColumns, type ChildObjectSchemaLike } from './deriveMasterDetail'; + +type Opts = { relationshipField?: string; exclude?: string[]; maxColumns?: number }; + +const atBudget = { + name: { type: 'text', label: 'Name' }, + qty: { type: 'number', label: 'Qty' }, + unit_price: { type: 'currency', label: 'Unit price' }, + status: { type: 'select', options: ['open', 'closed'] }, + due: { type: 'date' }, + notes: { type: 'text' }, + order: { type: 'master_detail', reference: 'order', required: true }, +}; + +const onePast = { ...atBudget, memo: { type: 'textarea', label: 'Memo' } }; + +const wide = { + sku: { type: 'text', label: 'SKU' }, + photo: { type: 'image' }, + product: { type: 'lookup', reference: 'product', displayField: 'name' }, + code: { type: 'text' }, + amount: { type: 'currency', required: true, expression: 'qty * unit_price', scale: 2 }, + qty: { type: 'number', required: true }, + unit_price: { type: 'currency' }, + discount: { type: 'percent' }, + delivered_on: { type: 'datetime' }, + shipped_at: { type: 'time' }, + tax_class: { type: 'radio', options: [{ label: 'Standard', value: 'std' }, { label: 'Zero', value: 0 }] }, + gift: { type: 'boolean' }, + remark: { type: 'email' }, + invoice: { type: 'master_detail', reference: 'invoice' }, +}; + +const statusOptions = [{ label: 'open', value: 'open' }, { label: 'closed', value: 'closed' }]; + +const CORPUS: Array<{ title: string; schema: ChildObjectSchemaLike | undefined; opts?: Opts; before: unknown[] }> = [ + { + title: 'exactly at the budget: no column hidden', + schema: { name: 'line', fields: atBudget }, + opts: { relationshipField: 'order' }, + before: [ + { name: 'name', label: 'Name', type: 'text', required: false }, + { name: 'qty', label: 'Qty', type: 'number', required: false }, + { name: 'unit_price', label: 'Unit price', type: 'currency', required: false }, + { name: 'status', label: 'status', type: 'select', required: false, options: statusOptions }, + { name: 'due', label: 'due', type: 'date', required: false }, + { name: 'notes', label: 'notes', type: 'text', required: false }, + ], + }, + { + title: 'one past the budget: the lowest-priority column is hidden', + schema: { name: 'line', fields: onePast }, + opts: { relationshipField: 'order' }, + before: [ + { name: 'name', label: 'Name', type: 'text', required: false }, + { name: 'qty', label: 'Qty', type: 'number', required: false }, + { name: 'unit_price', label: 'Unit price', type: 'currency', required: false }, + { name: 'status', label: 'status', type: 'select', required: false, options: statusOptions }, + { name: 'due', label: 'due', type: 'date', required: false }, + { name: 'notes', label: 'notes', type: 'text', required: false }, + { name: 'memo', label: 'Memo', type: 'text', required: false, defaultHidden: true }, + ], + }, + { + title: '`exclude` alone: the unexcluded relationship field is a required candidate', + schema: { name: 'line', fields: onePast }, + opts: { exclude: ['notes'] }, + before: [ + { name: 'name', label: 'Name', type: 'text', required: false }, + { name: 'qty', label: 'Qty', type: 'number', required: false }, + { name: 'unit_price', label: 'Unit price', type: 'currency', required: false }, + { name: 'status', label: 'status', type: 'select', required: false, options: statusOptions }, + { name: 'due', label: 'due', type: 'date', required: false }, + { name: 'order', label: 'order', type: 'lookup', required: true, reference: 'order', displayField: undefined }, + { name: 'memo', label: 'Memo', type: 'text', required: false, defaultHidden: true }, + ], + }, + { + title: 'maxColumns 3', + schema: { name: 'line', fields: onePast }, + opts: { relationshipField: 'order', maxColumns: 3 }, + before: [ + { name: 'name', label: 'Name', type: 'text', required: false }, + { name: 'qty', label: 'Qty', type: 'number', required: false }, + { name: 'unit_price', label: 'Unit price', type: 'currency', required: false, defaultHidden: true }, + { name: 'status', label: 'status', type: 'select', required: false, options: statusOptions }, + { name: 'due', label: 'due', type: 'date', required: false, defaultHidden: true }, + { name: 'notes', label: 'notes', type: 'text', required: false, defaultHidden: true }, + { name: 'memo', label: 'Memo', type: 'text', required: false, defaultHidden: true }, + ], + }, + { + title: 'maxColumns 0 hides nothing', + schema: { name: 'line', fields: onePast }, + opts: { relationshipField: 'order', maxColumns: 0 }, + before: [ + { name: 'name', label: 'Name', type: 'text', required: false }, + { name: 'qty', label: 'Qty', type: 'number', required: false }, + { name: 'unit_price', label: 'Unit price', type: 'currency', required: false }, + { name: 'status', label: 'status', type: 'select', required: false, options: statusOptions }, + { name: 'due', label: 'due', type: 'date', required: false }, + { name: 'notes', label: 'notes', type: 'text', required: false }, + { name: 'memo', label: 'Memo', type: 'text', required: false }, + ], + }, + { + title: 'a negative maxColumns hides nothing', + schema: { name: 'line', fields: onePast }, + opts: { relationshipField: 'order', maxColumns: -1 }, + before: [ + { name: 'name', label: 'Name', type: 'text', required: false }, + { name: 'qty', label: 'Qty', type: 'number', required: false }, + { name: 'unit_price', label: 'Unit price', type: 'currency', required: false }, + { name: 'status', label: 'status', type: 'select', required: false, options: statusOptions }, + { name: 'due', label: 'due', type: 'date', required: false }, + { name: 'notes', label: 'notes', type: 'text', required: false }, + { name: 'memo', label: 'Memo', type: 'text', required: false }, + ], + }, + { + title: 'wide: a name-like primary that is not first, a computed required column, fill-priority ties', + schema: { name: 'invoice_line', fields: wide }, + opts: { relationshipField: 'invoice' }, + before: [ + { name: 'sku', label: 'SKU', type: 'text', required: false, defaultHidden: true }, + { name: 'photo', label: 'photo', type: 'file', required: false, accept: ['image/*'], defaultHidden: true }, + { name: 'product', label: 'product', type: 'lookup', required: false, reference: 'product', displayField: 'name', defaultHidden: true }, + { name: 'code', label: 'code', type: 'text', required: false }, + { name: 'amount', label: 'amount', type: 'currency', required: false, computed: true, expr: 'qty * unit_price', scale: 2 }, + { name: 'qty', label: 'qty', type: 'number', required: true }, + { name: 'unit_price', label: 'unit_price', type: 'currency', required: false }, + { name: 'discount', label: 'discount', type: 'number', required: false, defaultHidden: true }, + { name: 'delivered_on', label: 'delivered_on', type: 'datetime', required: false, defaultHidden: true }, + { name: 'shipped_at', label: 'shipped_at', type: 'time', required: false, defaultHidden: true }, + { name: 'tax_class', label: 'tax_class', type: 'select', required: false, options: [{ label: 'Standard', value: 'std' }, { label: 'Zero', value: '0' }] }, + { name: 'gift', label: 'gift', type: 'select', required: false, options: [{ label: 'Yes', value: 'true' }, { label: 'No', value: 'false' }] }, + { name: 'remark', label: 'remark', type: 'text', required: false, defaultHidden: true }, + ], + }, + { + title: 'filters: system names, sort fields, flags, non-editable types, the relationship field and `exclude`', + schema: { + name: 'line', + fields: { + id: { type: 'text' }, + _id: {}, + recordId: {}, + created_at: { type: 'datetime' }, + updated_by: { type: 'lookup', reference: 'user' }, + owner: { type: 'lookup', reference: 'user' }, + tenant_id: {}, + space: {}, + position: { type: 'number' }, + sort_order: { type: 'number' }, + secret: { type: 'text', hidden: true }, + locked: { type: 'text', readonly: true }, + internal: { type: 'text', system: true }, + total: { type: 'formula' }, + blob: { type: 'json' }, + place: { type: 'location' }, + body: { type: 'richtext' }, + seq: { type: 'autonumber' }, + roll: { type: 'rollup' }, + parent_ref: { type: 'master_detail', reference: 'parent' }, + skip_me: { type: 'text' }, + title: { type: 'text', required: true }, + status: { type: 'picklist', options: ['a', 'b'] }, + attachment: { type: 'file', multiple: true }, + approver: { type: 'lookup', reference: 'user', reference_field: 'email', readonlyWhen: "parent.status == 'locked'" }, + note: { type: 'text', requiredWhen: "record.status == 'b'" }, + }, + }, + opts: { relationshipField: 'parent_ref', exclude: ['skip_me'] }, + before: [ + { name: 'title', label: 'title', type: 'text', required: true }, + { name: 'status', label: 'status', type: 'select', required: false, options: [{ label: 'a', value: 'a' }, { label: 'b', value: 'b' }] }, + { name: 'attachment', label: 'attachment', type: 'file', required: false, multiple: true }, + { name: 'approver', label: 'approver', type: 'lookup', required: false, reference: 'user', displayField: 'email', readonlyWhen: "parent.status == 'locked'" }, + { name: 'note', label: 'note', type: 'text', required: false, requiredWhen: "record.status == 'b'" }, + ], + }, + { + title: 'falsy field definitions are text columns, and count toward the budget', + schema: { + name: 'line', + fields: { + a_null: null, + b_undefined: undefined, + c_zero: 0, + d_empty: '', + e_false: false, + name: { type: 'text', label: 'Name' }, + qty: { type: 'number' }, + price: { type: 'currency' }, + }, + }, + before: [ + { name: 'a_null', label: 'a_null', type: 'text', required: false }, + { name: 'b_undefined', label: 'b_undefined', type: 'text', required: false }, + { name: 'c_zero', label: 'c_zero', type: 'text', required: false }, + { name: 'd_empty', label: 'd_empty', type: 'text', required: false, defaultHidden: true }, + { name: 'e_false', label: 'e_false', type: 'text', required: false, defaultHidden: true }, + { name: 'name', label: 'Name', type: 'text', required: false }, + { name: 'qty', label: 'qty', type: 'number', required: false }, + { name: 'price', label: 'price', type: 'currency', required: false }, + ], + }, + { + title: 'more required columns than the budget: every required column stays visible', + schema: { + fields: { + a: { type: 'text', required: true }, + b: { type: 'text', required: true }, + c: { type: 'number', required: true }, + d: { type: 'text', required: true }, + e: { type: 'date', required: true }, + f: { type: 'text', required: true }, + g: { type: 'select', required: true, options: ['x'] }, + h: { type: 'select' }, + }, + }, + before: [ + { name: 'a', label: 'a', type: 'text', required: true }, + { name: 'b', label: 'b', type: 'text', required: true }, + { name: 'c', label: 'c', type: 'number', required: true }, + { name: 'd', label: 'd', type: 'text', required: true }, + { name: 'e', label: 'e', type: 'date', required: true }, + { name: 'f', label: 'f', type: 'text', required: true }, + { name: 'g', label: 'g', type: 'select', required: true, options: [{ label: 'x', value: 'x' }] }, + { name: 'h', label: 'h', type: 'select', required: false, defaultHidden: true }, + ], + }, + { title: 'no schema', schema: undefined, before: [] }, + { title: 'no field map', schema: { name: 'line' }, before: [] }, + { title: 'a null field map', schema: { name: 'line', fields: null as unknown as Record }, before: [] }, + { title: 'a field map that is not an object', schema: { name: 'line', fields: 'nope' as unknown as Record }, before: [] }, +]; + +describe('deriveColumns derives its columns through the spec rule (objectui#11345)', () => { + it.each(CORPUS)("$title: names, order and defaultHidden are the spec's deriveInlineGridColumns", ({ schema, opts }) => { + const identity = deriveColumns(schema, opts).map(({ name, defaultHidden }) => + defaultHidden === undefined ? { name } : { name, defaultHidden }, + ); + expect(identity).toStrictEqual(deriveInlineGridColumns(schema, opts)); + }); + + it.each(CORPUS)('$title: the output is unchanged from the local budget it replaced', ({ schema, opts, before }) => { + expect(deriveColumns(schema, opts)).toStrictEqual(before); + }); + + it("an omitted maxColumns leaves the spec's budget visible", () => { + const cols = deriveColumns({ name: 'invoice_line', fields: wide }, { relationshipField: 'invoice' }); + expect(cols.filter((c) => !c.defaultHidden)).toHaveLength(DEFAULT_MAX_INLINE_GRID_COLUMNS); + expect(cols.length).toBeGreaterThan(DEFAULT_MAX_INLINE_GRID_COLUMNS); + }); +}); diff --git a/packages/plugin-form/src/deriveMasterDetail.ts b/packages/plugin-form/src/deriveMasterDetail.ts index a8519172c2..d0e8590de7 100644 --- a/packages/plugin-form/src/deriveMasterDetail.ts +++ b/packages/plugin-form/src/deriveMasterDetail.ts @@ -15,6 +15,7 @@ * `reportCurrencyColumnScale` (objectui#10783). */ +import { DEFAULT_MAX_INLINE_GRID_COLUMNS, deriveInlineGridColumns } from '@objectstack/spec/data'; import type { GridColumn } from '@object-ui/fields'; /** @@ -42,7 +43,7 @@ export interface ChildObjectSchemaLike { fields?: Record; } -/** Fields never shown as editable line-item columns. */ +/** System / audit fields never offered in a line's row form. */ const SYSTEM_FIELDS = new Set([ 'id', '_id', 'recordId', 'created_at', 'updated_at', 'created_by', 'updated_by', @@ -50,19 +51,11 @@ const SYSTEM_FIELDS = new Set([ 'organization_id', 'tenant_id', 'space', 'owner', ]); -/** Field names that hold a line's sort position — excluded from the editable - * columns and the row form (the grid stamps them on drag-reorder instead). */ +/** Field names that hold a line's sort position — excluded from the row form + * (the grid stamps them on drag-reorder instead), and the names `deriveDetail` + * picks the line's sort field from. */ const SORT_FIELD_NAMES = new Set(['position', 'sort_order', 'sequence', 'line_no', 'line_number', 'sort']); -/** Field types that are not directly editable in a line-item grid. - * file/image/avatar are NOT here: they render a compact upload cell - * (`GridColumn.type: 'file'` → FileCell) since objectui#2360. */ -const NON_EDITABLE_TYPES = new Set([ - 'formula', 'summary', 'rollup', 'autonumber', 'auto_number', - 'json', 'object', 'grid', 'table', - 'location', 'vector', 'html', 'markdown', 'richtext', -]); - /** Map an ObjectQL field type to a LineItems grid column type. */ export function fieldTypeToColumnType(type: string | undefined): GridColumn['type'] { switch (type) { @@ -155,75 +148,24 @@ export function findRelationshipField( } /** - * Default-visible column budget for an auto-derived inline grid. An inline - * line-item grid lives in a constrained width (modal / detail card), so we show - * a focused set by default and mark the rest `defaultHidden` — they are NOT - * dropped: the grid's column chooser reveals them on demand (the mainstream - * "personalize columns" pattern; cf. Odoo `optional` / Salesforce column - * personalization). Required columns are always visible. Authors can override - * with explicit `columns` / `inlineColumns` (no curation), or `maxColumns: 0`. - */ -export const DEFAULT_MAX_INLINE_COLUMNS = 6; - -/** Field names that read as a record's primary/display column. */ -const NAME_LIKE_FIELDS = ['name', 'title', 'subject', 'label', 'full_name', 'display_name', 'code']; - -/** Lower number = kept first when filling the column budget. */ -const TYPE_FILL_PRIORITY: Record = { - select: 0, - currency: 1, - number: 1, - lookup: 2, - date: 3, - // Same usefulness as `date` — they were literally the same column type until - // objectui#3569 split them, and omitting them here would have silently - // demoted every datetime/time column to the unknown-type bucket (5). - datetime: 3, - time: 3, - text: 4, -}; - -/** - * Fill priority for a column. `GridColumn.type` is optional, and a column - * without one sorts with the unknown types at the back of the budget — the - * same place the bare `TYPE_FILL_PRIORITY[undefined] ?? 5` lookup put it. - */ -function fillPriority(col: GridColumn): number { - return (col.type ? TYPE_FILL_PRIORITY[col.type] : undefined) ?? 5; -} - -/** - * Choose the default-visible subset of `max` columns — always keeping the - * primary (name-like) column and every required column, then filling the - * remaining budget by type usefulness. Columns NOT in the visible set are - * marked `defaultHidden` (revealable via the grid's column chooser); none are - * dropped, so business-critical fields stay reachable. Output preserves the - * original schema order so the grid still reads naturally. - */ -function curateColumns(cols: GridColumn[], max: number): GridColumn[] { - if (max <= 0 || cols.length <= max) return cols; - const visible = new Set(); - const primary = cols.find((c) => NAME_LIKE_FIELDS.includes(c.name)) ?? cols[0]; - if (primary) visible.add(primary.name); - for (const c of cols) if (c.required) visible.add(c.name); // required is always visible - const remaining = cols - .map((c, i) => ({ c, i })) - .filter(({ c }) => !visible.has(c.name)) - .sort((a, b) => fillPriority(a.c) - fillPriority(b.c) || a.i - b.i); - for (const { c } of remaining) { - if (visible.size >= max) break; - visible.add(c.name); - } - // Keep every column; collapse the overflow into the chooser. - return cols.map((c) => (visible.has(c.name) ? c : { ...c, defaultHidden: true })); -} - -/** - * Derive editable grid columns from a child object's fields, skipping system / - * audit fields, non-editable types, and the back-reference FK to the parent. - * Every editable column is returned; those beyond {@link DEFAULT_MAX_INLINE_COLUMNS} - * are flagged `defaultHidden` (collapsed into the grid's column chooser, not - * dropped). Pass `maxColumns: 0` to flag none. + * Derive editable grid columns from a child object's fields — the grid an + * inline master-detail collection draws when its author listed no columns. + * + * WHICH columns, in what order, and which are `defaultHidden`, is + * `@objectstack/spec`'s rule, `deriveInlineGridColumns` (objectui#11345): it + * skips system / audit fields, sort-position fields, fields flagged `system`, + * `readonly` or `hidden`, non-editable types, the back-reference FK to the + * parent and any `exclude`d name, and keeps every other field. Past the + * visible budget ({@link DEFAULT_MAX_INLINE_GRID_COLUMNS}, the spec's) the + * overflow is flagged `defaultHidden` — collapsed into the grid's column + * chooser, never dropped; required columns always stay visible. Pass + * `maxColumns: 0` to flag none. The rule lives in the spec so that an + * author-time tool (objectstack's `field-no-consumers` lint) credits exactly + * the columns this grid draws, and the two cannot disagree. + * + * What each column carries — label, cell type, options, lookup target, + * conditional rules, computed expression — is built here, from the child + * field, by {@link deriveColumn}. */ export function deriveColumns( childSchema: ChildObjectSchemaLike | undefined, @@ -231,57 +173,64 @@ export function deriveColumns( ): GridColumn[] { const fields = childSchema?.fields; if (!fields || typeof fields !== 'object') return []; - const exclude = new Set([...(opts.exclude ?? []), ...(opts.relationshipField ? [opts.relationshipField] : [])]); - const cols: GridColumn[] = []; - for (const [name, def] of Object.entries(fields)) { - const d = def as any; - if (SYSTEM_FIELDS.has(name) || exclude.has(name) || SORT_FIELD_NAMES.has(name)) continue; - if (d?.system || d?.readonly || d?.hidden) continue; - if (NON_EDITABLE_TYPES.has(d?.type)) continue; - const col: GridColumn = { - name, - label: d?.label || name, - type: fieldTypeToColumnType(d?.type), - required: !!d?.required, - }; - const options = optionsFor(d); - if (col.type === 'select' && options) col.options = options; - if (col.type === 'lookup') { - col.reference = d?.reference; - // The display pointer is read in `@objectstack/spec`'s spelling, - // `displayField` (objectui#11070 round 6). This read used to be - // `display_field || reference_field` with no camel leg, so a spec-valid - // def's `displayField` never reached the column. The `display_field` leg - // is retired, with no alias: a def served through `ObjectStackAdapter` - // reaches here with a stored `display_field` already stamped onto - // `displayField` by the ingestion fold (objectui#7650 ruling A). An - // external caller of this PUBLIC export that passes an unfolded - // `childSchema` loses the snake value; the round's changeset states that - // break. `reference_field` stays behind it: `FieldSchema` declares no - // twin the fold could stamp it onto. The same read recurs in - // `hydrateColumns` below. - col.displayField = d?.displayField || d?.reference_field; - } - if (col.type === 'file') applyFileColumnProps(col, d); - // Field-level CEL conditional rules (B2 in grids). Carried through verbatim - // so the grid cell evaluates them per row (against the row + `parent` - // header). - if (d?.readonlyWhen) col.readonlyWhen = d.readonlyWhen; - if (d?.requiredWhen) col.requiredWhen = d.requiredWhen; - // A field carrying an arithmetic `expression` (e.g. amount = quantity * - // unit_price) becomes a live read-only computed column. The expression may - // be a bare string or the normalized CEL envelope `{ dialect, source }`. - const expr = typeof d?.expression === 'string' ? d.expression : d?.expression?.source; - if (expr && typeof expr === 'string') { - col.computed = true; - col.expr = expr; - col.required = false; // computed → never user-entered, so never required - if (typeof d?.scale === 'number') col.scale = d.scale; - } - cols.push(col); + const derived = deriveInlineGridColumns(childSchema, { + relationshipField: opts.relationshipField, + exclude: opts.exclude, + maxColumns: opts.maxColumns ?? DEFAULT_MAX_INLINE_GRID_COLUMNS, + }); + return derived.map(({ name, defaultHidden }) => { + const col = deriveColumn(name, fields[name]); + return defaultHidden ? { ...col, defaultHidden } : col; + }); +} + +/** + * Build one derived grid column from its child field definition `d`. A falsy + * `d` (the field map holds `null` / `undefined` / `0` / `''` / `false` under + * that name) still builds a plain text column headed by the field name. + */ +function deriveColumn(name: string, d: any): GridColumn { + const col: GridColumn = { + name, + label: d?.label || name, + type: fieldTypeToColumnType(d?.type), + required: !!d?.required, + }; + const options = optionsFor(d); + if (col.type === 'select' && options) col.options = options; + if (col.type === 'lookup') { + col.reference = d?.reference; + // The display pointer is read in `@objectstack/spec`'s spelling, + // `displayField` (objectui#11070 round 6). This read used to be + // `display_field || reference_field` with no camel leg, so a spec-valid + // def's `displayField` never reached the column. The `display_field` leg + // is retired, with no alias: a def served through `ObjectStackAdapter` + // reaches here with a stored `display_field` already stamped onto + // `displayField` by the ingestion fold (objectui#7650 ruling A). An + // external caller of this PUBLIC export that passes an unfolded + // `childSchema` loses the snake value; the round's changeset states that + // break. `reference_field` stays behind it: `FieldSchema` declares no + // twin the fold could stamp it onto. The same read recurs in + // `hydrateColumns` below. + col.displayField = d?.displayField || d?.reference_field; + } + if (col.type === 'file') applyFileColumnProps(col, d); + // Field-level CEL conditional rules (B2 in grids). Carried through verbatim + // so the grid cell evaluates them per row (against the row + `parent` + // header). + if (d?.readonlyWhen) col.readonlyWhen = d.readonlyWhen; + if (d?.requiredWhen) col.requiredWhen = d.requiredWhen; + // A field carrying an arithmetic `expression` (e.g. amount = quantity * + // unit_price) becomes a live read-only computed column. The expression may + // be a bare string or the normalized CEL envelope `{ dialect, source }`. + const expr = typeof d?.expression === 'string' ? d.expression : d?.expression?.source; + if (expr && typeof expr === 'string') { + col.computed = true; + col.expr = expr; + col.required = false; // computed → never user-entered, so never required + if (typeof d?.scale === 'number') col.scale = d.scale; } - const maxColumns = opts.maxColumns ?? DEFAULT_MAX_INLINE_COLUMNS; - return curateColumns(cols, maxColumns); + return col; } /** From 399fc03b0de1269bd6bc1df44a23789c1b833230 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:35:12 +0000 Subject: [PATCH 2/3] chore(plugin-form): floor @objectstack/spec at ^17.6.0 (objectui#11345) `@object-ui/plugin-form`'s published `dist/index.js` now imports `deriveInlineGridColumns` and `DEFAULT_MAX_INLINE_GRID_COLUMNS` from `@objectstack/spec/data`, which the spec first exports in 17.6.0. The declared `^17.0.0` admitted 17.0.0 to 17.5.x, none of which carries either name. `pnpm-lock.yaml` follows the specifier; the resolved version (17.6.0) does not move. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL --- packages/plugin-form/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-form/package.json b/packages/plugin-form/package.json index d749117308..e6457f44ac 100644 --- a/packages/plugin-form/package.json +++ b/packages/plugin-form/package.json @@ -28,7 +28,7 @@ "@object-ui/permissions": "workspace:*", "@object-ui/react": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0", + "@objectstack/spec": "^17.6.0", "lucide-react": "^1.43.0" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ede9da9d9..cfd2b9b176 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1998,7 +1998,7 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0 + specifier: ^17.6.0 version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 From 738b3be088e3d96e00d506633d914eb38bbc77a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:35:41 +0000 Subject: [PATCH 3/3] chore(changeset): plugin-form patch for the spec-derived inline grid columns (objectui#11345) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL --- .changeset/11345-inline-grid-columns-spec-rule.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/11345-inline-grid-columns-spec-rule.md diff --git a/.changeset/11345-inline-grid-columns-spec-rule.md b/.changeset/11345-inline-grid-columns-spec-rule.md new file mode 100644 index 0000000000..36fe4feb37 --- /dev/null +++ b/.changeset/11345-inline-grid-columns-spec-rule.md @@ -0,0 +1,9 @@ +--- +'@object-ui/plugin-form': patch +--- + +`deriveColumns`, the default columns of a master-detail inline grid whose author listed none, now takes which columns it draws, their order and which of them are `defaultHidden` from `@objectstack/spec`'s `deriveInlineGridColumns`, and its visible budget from the spec's `DEFAULT_MAX_INLINE_GRID_COLUMNS` (objectui#11345). The rule is the spec's now, so objectstack's `field-no-consumers` lint credits exactly the columns this grid draws. + +The output does not change. The signature is the same, and each column's label, cell type, options, lookup target, conditional rules and computed expression are still built from the child field here, including a plain text column for a field whose definition is falsy. The module-level `DEFAULT_MAX_INLINE_COLUMNS` constant, which the package entry never exported, is removed. + +`@object-ui/plugin-form` raises its `@objectstack/spec` floor from `^17.0.0` to `^17.6.0`, because its published entry now imports `deriveInlineGridColumns` and `DEFAULT_MAX_INLINE_GRID_COLUMNS`, which the spec first exports in 17.6.0.