diff --git a/.changeset/20951-inline-grid-derived-columns.md b/.changeset/20951-inline-grid-derived-columns.md new file mode 100644 index 00000000000..b50622e7602 --- /dev/null +++ b/.changeset/20951-inline-grid-derived-columns.md @@ -0,0 +1,11 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': patch +--- + +`deriveInlineGridColumns` (`@objectstack/spec/data`) derives the default columns of an inline master-detail grid, and `field-no-consumers` stops calling two kinds of in-use child field "inert" (#20951). + +Clause-②: yes (widening) + +- **`@objectstack/spec`.** New exports from `@objectstack/spec/data`: `deriveInlineGridColumns(def, { relationshipField?, exclude?, maxColumns? })`, its element type `DerivedInlineGridColumn`, and `DEFAULT_MAX_INLINE_GRID_COLUMNS` (`6`). The function answers which child fields an inline grid draws when its author listed no columns: a relationship field with `inlineEdit` and no `inlineColumns`, or a `subforms` entry with no `columns`. It returns identity-only entries (`{ name }`, plus `defaultHidden: true` on columns past the visible budget, which collapse into the column chooser and are never dropped), in the child's field order, skipping system, audit, tenancy, ownership and sort-position names, the relationship field, `system` / `readonly` / `hidden` fields and the types a grid cell cannot edit. It is the renderer's current rule, reproduced exactly; the renderer hydrates each column from the child field. No schema accepts anything new or refuses anything new. +- **`@objectstack/lint`.** `field-no-consumers` now reads a `subforms` entry's `amountField` and `relationshipField` against the entry's `childObject`, and keeps `totalField` on the parent. It also credits the columns of a derived inline grid through `deriveInlineGridColumns`. Before, `os validate` warned that the child's summed amount column, the subform's relationship field and every derived grid column were inert, and credited a same-named parent field in the amount column's place. A field the derivation leaves out (for example a `hidden` one) is still reported. diff --git a/packages/lint/src/validate-field-consumers.test.ts b/packages/lint/src/validate-field-consumers.test.ts index 8675754a919..b8fa3117783 100644 --- a/packages/lint/src/validate-field-consumers.test.ts +++ b/packages/lint/src/validate-field-consumers.test.ts @@ -669,3 +669,169 @@ describe('[#20929] validateFieldConsumers — an inline grid column names a fiel expect(verdicts(stack({}, {}, { datasets }))).toEqual({ 'inv.qty': 'inert', 'line.qty': 'inert', 'line.memo': 'inert' }); }); }); + +/** + * [#20951] Two more positions where an inline child collection names a CHILD + * field without the walk crediting it there. + * + * Site 1 — a `subforms` entry's child-field keys, read per key. `amountField` + * ("Numeric child column summed for the running total") and + * `relationshipField` ("FK on the child pointing back to the parent") name + * fields of the entry's `childObject`; `totalField` ("Parent field to receive + * the rolled-up sum") names a field of the PARENT. The walk carried the + * parent's context into the entry, so the child's amount column read as inert + * while a same-named parent field was credited in its place. + * + * Site 2 — a DERIVED inline grid. A relationship field with `inlineEdit` and + * no `inlineColumns`, or a `subforms` entry with no `columns`, draws the + * columns `deriveInlineGridColumns` (`@objectstack/spec/data`) derives from + * the child object. The rule credits exactly those, and nothing the derivation + * leaves out. + * + * `inv` is the parent, `line` the child. Each declares a field the other's + * key names, so a key read against the wrong object shows up as the wrong + * one of the pair going quiet. + */ +describe('[#20951] validateFieldConsumers — a child collection credits its child fields per key, and a derived grid its derived columns', () => { + const data = { provider: 'object', object: 'inv' }; + const MASTER_DETAIL = { type: 'master_detail', reference: 'inv' }; + + const stack = (lineFields: AnyRec, view: AnyRec = {}, invoice: AnyRec = MASTER_DETAIL): AnyRec => ({ + objects: [ + { name: 'inv', fields: { name: { type: 'text' }, total: { type: 'number' }, line_total: { type: 'number' } } }, + { name: 'line', fields: { name: { type: 'text' }, invoice, ...lineFields } }, + ], + views: [{ list: { type: 'grid', data, columns: [{ field: 'name' }] }, ...view }], + }); + + /** `object.field` → verdict, for every field the rule reports. */ + const verdicts = (s: AnyRec): Record => + Object.fromEntries(validateFieldConsumers(s).map((f) => [`${f.object}.${f.field}`, f.verdict])); + + describe('site 1: `amountField` and `relationshipField` on the child, `totalField` on the parent', () => { + const lineFields = { qty: { type: 'number' }, line_total: { type: 'number' }, total: { type: 'number' }, memo: { type: 'text' } }; + const entry = (extra: AnyRec): AnyRec => ({ childObject: 'line', columns: [{ name: 'qty' }], ...extra }); + + it('baseline: with neither key, every field but the grid column is reported', () => { + const form = { type: 'simple', data, subforms: [entry({})] }; + expect(verdicts(stack(lineFields, { form }))).toEqual({ + 'inv.total': 'inert', + 'inv.line_total': 'inert', + 'line.line_total': 'inert', + 'line.total': 'inert', + 'line.memo': 'inert', + }); + }); + + it("`amountField` credits the CHILD's field, `totalField` the PARENT's — each same-named counterpart stays reported", () => { + const form = { type: 'simple', data, subforms: [entry({ amountField: 'line_total', totalField: 'total' })] }; + expect(verdicts(stack(lineFields, { form }))).toEqual({ + 'inv.line_total': 'inert', + 'line.total': 'inert', + 'line.memo': 'inert', + }); + }); + + it('each `formViews` entry, the same way', () => { + const edit = { type: 'simple', data, subforms: [entry({ amountField: 'line_total', totalField: 'total' })] }; + expect(verdicts(stack(lineFields, { formViews: { edit } }))).toEqual({ + 'inv.line_total': 'inert', + 'line.total': 'inert', + 'line.memo': 'inert', + }); + }); + + it('`relationshipField` credits the child FK the rows are loaded and saved by', () => { + const link = { type: 'lookup', reference: 'inv' }; + const without = { type: 'simple', data, subforms: [entry({ amountField: 'line_total', totalField: 'total' })] }; + const withKey = { type: 'simple', data, subforms: [entry({ amountField: 'line_total', totalField: 'total', relationshipField: 'link' })] }; + expect(verdicts(stack({ ...lineFields, link }, { form: without }))['line.link']).toBe('inert'); + expect(verdicts(stack({ ...lineFields, link }, { form: withKey }))['line.link']).toBeUndefined(); + }); + + it('control: each key is read against its own object, so swapped keys credit the swapped pair', () => { + const form = { type: 'simple', data, subforms: [entry({ amountField: 'total', totalField: 'line_total' })] }; + // `amountField: 'total'` names the CHILD's `total`, `totalField: + // 'line_total'` the PARENT's `line_total` — what the keys literally say. + expect(verdicts(stack(lineFields, { form }))).toEqual({ + 'inv.total': 'inert', + 'line.line_total': 'inert', + 'line.memo': 'inert', + }); + }); + }); + + describe('site 2: a grid with no authored columns draws the derived ones', () => { + /** Three the derivation draws, and three it leaves out: JSON, `hidden`, `readonly`. */ + const lineFields = { + qty: { type: 'number' }, + line_total: { type: 'currency' }, + memo: { type: 'text' }, + blob: { type: 'json' }, + secret: { type: 'text', hidden: true }, + frozen: { type: 'number', readonly: true }, + }; + /** The parent's two fields (nothing reads them) and the three the derivation leaves out. */ + const DERIVED = { + 'inv.total': 'inert', + 'inv.line_total': 'inert', + 'line.blob': 'inert', + 'line.secret': 'inert', + 'line.frozen': 'inert', + }; + + it('baseline: without `inlineEdit` no grid is drawn, and every child field is reported', () => { + expect(verdicts(stack(lineFields))).toEqual({ + ...DERIVED, + 'line.qty': 'inert', + 'line.line_total': 'inert', + 'line.memo': 'inert', + }); + }); + + it.each([['grid'], ['form'], [true]])('`inlineEdit: %s` with no `inlineColumns` credits the derived columns, and only them', (inlineEdit) => { + expect(verdicts(stack(lineFields, {}, { ...MASTER_DETAIL, inlineEdit }))).toEqual(DERIVED); + }); + + it('an empty `inlineColumns` is no authored list: the grid is derived', () => { + expect(verdicts(stack(lineFields, {}, { ...MASTER_DETAIL, inlineEdit: 'grid', inlineColumns: [] }))).toEqual(DERIVED); + }); + + it('an authored `inlineColumns` replaces the derivation: only the named column is credited', () => { + expect(verdicts(stack(lineFields, {}, { ...MASTER_DETAIL, inlineEdit: 'grid', inlineColumns: [{ name: 'qty' }] }))).toEqual({ + ...DERIVED, + 'line.line_total': 'inert', + 'line.memo': 'inert', + }); + }); + + it('`inlineEdit` on a field that is not a relationship draws no grid', () => { + expect(verdicts(stack({ ...lineFields, tag: { type: 'text', inlineEdit: 'grid' } }))).toEqual({ + ...DERIVED, + 'line.qty': 'inert', + 'line.line_total': 'inert', + 'line.memo': 'inert', + 'line.tag': 'inert', + }); + }); + + it('a column the budget collapses into the chooser is credited too: it is drawn on demand, never dropped', () => { + const wide = Object.fromEntries(Array.from({ length: 9 }, (_, i) => [`f${i}`, { type: 'text' }])); + const findings = verdicts(stack(wide, {}, { ...MASTER_DETAIL, inlineEdit: 'grid' })); + expect(findings).toEqual({ 'inv.total': 'inert', 'inv.line_total': 'inert' }); + }); + + it('a `subforms` entry with no `columns` credits the derived columns of its `childObject`', () => { + const form = { type: 'simple', data, subforms: [{ childObject: 'line' }] }; + expect(verdicts(stack(lineFields, { form }))).toEqual(DERIVED); + }); + + it("a `subforms` entry's derivation excludes the `relationshipField` it names, which that key credits instead", () => { + const link = { type: 'lookup', reference: 'inv' }; + const form = { type: 'simple', data, subforms: [{ childObject: 'line', relationshipField: 'link' }] }; + const findings = validateFieldConsumers(stack({ ...lineFields, link }, { form })); + expect(findings.find((f) => f.object === 'line' && f.field === 'link')).toBeUndefined(); + expect(Object.fromEntries(findings.map((f) => [`${f.object}.${f.field}`, f.verdict]))).toEqual(DERIVED); + }); + }); +}); diff --git a/packages/lint/src/validate-field-consumers.ts b/packages/lint/src/validate-field-consumers.ts index 4c109d4ef9e..091ee6b988a 100644 --- a/packages/lint/src/validate-field-consumers.ts +++ b/packages/lint/src/validate-field-consumers.ts @@ -48,11 +48,11 @@ * must clean up; none of them is evidence that anything reads the field. * A seeded value nothing reads is precisely the shape being hunted. * - * ## Two consumers that name the field nowhere + * ## Three consumers that name the field nowhere * * A metadata reference is not the only way a field is read, and the first real * app to take this rule reported 12 fields that were all on screen or - * load-bearing. Both paths are read off the SPEC, not off a hand-kept list: + * load-bearing. All three paths are read off the SPEC, not off a hand-kept list: * * - **The synthesized layout** ({@link deriveFieldGroupLayout}, ADR-0085 §5). * An object's `fieldGroups` plus a field's `group` membership are what the @@ -75,6 +75,14 @@ * precisely so no real row can acquire one, and a consumer anywhere else * would defeat it. Nothing here exempts `hidden` as such — a `hidden` field * that no upsert matches on and nothing reads is still reported. + * - **A derived inline grid** ({@link creditDerivedInlineGrid}, + * `deriveInlineGridColumns`). A relationship field that sets `inlineEdit` + * with no `inlineColumns`, and a `subforms` entry with no `columns`, both + * draw a grid of the child object whose columns are derived from the + * child's fields. The derivation is the spec's, and it is the renderer's + * rule reproduced exactly, so the rule credits exactly the columns it + * returns — the hidden, readonly, system and non-editable fields it leaves + * out stay uncredited. * * A field with at least one behaviour OR display site is consumed and gets no * finding — a field that is only drawn is the ordinary state of most fields @@ -120,7 +128,7 @@ * maintainer's call, not this rule's. */ -import { deriveFieldGroupLayout, resolveDisplayField } from '@objectstack/spec/data'; +import { deriveFieldGroupLayout, deriveInlineGridColumns, resolveDisplayField } from '@objectstack/spec/data'; import type { DisplayNameObjectMeta } from '@objectstack/spec/data'; import { referenceTargetOf } from '@objectstack/spec/data'; import { collectionEntries } from './collection-entries.js'; @@ -281,6 +289,18 @@ const WRITE_KEYS: ReadonlySet = new Set([ */ const CHILD_COLLECTION_KEYS: ReadonlySet = new Set(['subforms']); +/** + * [#20951] Keys of a child collection entry whose value names a field of the + * CHILD object, read per key against the entry's `childObject`. On a + * `subforms` entry, `amountField` is "Numeric child column summed for the + * running total" and `relationshipField` is "FK on the child pointing back to + * the parent" (`FormViewSchema.subforms`). `totalField` is deliberately absent: + * it is "Parent field to receive the rolled-up sum", so it stays on the object + * the enclosing view is bound to, which is the context the walk already + * carries. The read is per key, never a context switch for the whole entry. + */ +const CHILD_ENTRY_FIELD_KEYS: ReadonlySet = new Set(['amountField', 'relationshipField']); + /** * Keys whose value is a literal from some other vocabulary, never a field * name. Without this list `type: 'summary'` on a roll-up reads as a reference @@ -359,6 +379,8 @@ class ConsumerLedger { readonly objectsByField = new Map>(); /** dataset name → the object it reads */ readonly datasetObject = new Map(); + /** object → its declared field map, the shape the spec's derivations read */ + readonly fieldMapByObject = new Map>(); /** `object.field` → sites */ readonly sites = new Map(); /** Tokens that looked like a field but resolved to no object — counted, never dropped. */ @@ -515,6 +537,72 @@ function creditInlineGridColumns( }); } +/** + * Whether a carrier's authored columns are the grid's columns. The renderer + * draws an authored list only when it has at least one entry; an absent or + * empty list is derived from the child object instead. + */ +function hasAuthoredColumns(columns: unknown): boolean { + return Array.isArray(columns) && columns.length > 0; +} + +/** + * [#20951] Credit the fields a DERIVED inline grid draws, on the child object. + * + * An inline grid with no authored columns still draws columns: the ones + * `deriveInlineGridColumns` (`@objectstack/spec/data`) derives from the child + * object's declared fields. That function is the platform's own rule — the + * renderer's column derivation, reproduced exactly — so this credits exactly + * what it returns, the way + * {@link creditFieldGroupLayout} credits `deriveFieldGroupLayout`. A column the + * budget marks `defaultHidden` is credited too: it is collapsed into the + * grid's column chooser, never dropped. + * + * `relationshipField` is the child's field back to the parent, which the grid + * fills in and does not draw. A `subforms` entry that names none leaves the + * renderer to detect it; this rule does not carry a second copy of that + * detection, so the derived list it credits includes the relationship field. + * That relationship is read either way: it is the key the child rows are + * loaded and saved by. + */ +function creditDerivedInlineGrid( + ledger: ConsumerLedger, + childObject: string | undefined, + relationshipField: string | undefined, + root: string, + path: string, +): void { + const fields = childObject === undefined ? undefined : ledger.fieldMapByObject.get(childObject); + if (childObject === undefined || fields === undefined) return; + for (const { name } of deriveInlineGridColumns({ fields }, { relationshipField })) { + if (ledger.declares(childObject, name)) ledger.record(childObject, name, { root, path, kind: 'display' }); + } +} + +/** + * [#20951] Credit one {@link CHILD_ENTRY_FIELD_KEYS} value of a child + * collection entry, against the entry's `childObject`. A name the child does + * not declare is counted unresolved, like every other field-shaped token that + * lands on no object. + */ +function creditChildEntryField( + ledger: ConsumerLedger, + value: unknown, + childObject: string | undefined, + root: string, + path: string, + segments: readonly string[], + key: string, +): void { + const field = strName(value); + if (field === undefined || !ledger.objectsByField.has(field)) return; + if (ledger.declares(childObject, field)) { + ledger.record(childObject, field, { root, path, kind: bucketFor(root, segments, key) }); + } else { + ledger.unresolved += 1; + } +} + /** The object context a record establishes for its own subtree, if any. */ function contextOf(ledger: ConsumerLedger, rec: AnyRec, ctx: string | undefined): string | undefined { const named = (v: unknown): string | undefined => (ledger.isObject(v) ? v : undefined); @@ -573,10 +661,22 @@ function walk( const inner = contextOf(ledger, rec, ctx); // [#20929] An entry of a child collection: its grid draws `childObject`'s // fields, whatever object the enclosing view is bound to. - if (CHILD_COLLECTION_KEYS.has(leafKey)) { - creditInlineGridColumns(ledger, rec.columns, strName(rec.childObject), 'display', root, `${path}.columns`); + const childEntry = CHILD_COLLECTION_KEYS.has(leafKey); + if (childEntry) { + const childObject = strName(rec.childObject); + creditInlineGridColumns(ledger, rec.columns, childObject, 'display', root, `${path}.columns`); + // [#20951] With no authored columns, the grid draws the derived ones. + if (!hasAuthoredColumns(rec.columns)) { + creditDerivedInlineGrid(ledger, childObject, strName(rec.relationshipField), root, `${path}.childObject`); + } + // [#20951] The child-field keys, each read against the child — and only + // there, which is why the loop below skips them. + for (const key of CHILD_ENTRY_FIELD_KEYS) { + creditChildEntryField(ledger, rec[key], childObject, root, `${path}.${key}`, [...segments, key], key); + } } for (const [key, value] of Object.entries(rec)) { + if (childEntry && CHILD_ENTRY_FIELD_KEYS.has(key)) continue; const childPath = `${path}.${key}`; const childSegments = [...segments, key]; // A predicate map spells the field as its KEY (`{ is_active: true }`); a @@ -651,6 +751,21 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj 'objects', `${fieldPath}.inlineColumns`, ); + // [#20951] With `inlineEdit` and no authored `inlineColumns`, the parent's + // form still draws a grid of THIS object: the derived columns. The grid + // exists for a relationship to a parent, which is what objectui's + // `attachInlineSubforms` requires before it builds one: a `master_detail` + // or `lookup` field whose target resolves. + const fieldName = strName(field.name); + if ( + fieldName !== undefined && + field.inlineEdit && + (field.type === 'master_detail' || field.type === 'lookup') && + !!reference && + !hasAuthoredColumns(field.inlineColumns) + ) { + creditDerivedInlineGrid(ledger, objectName, fieldName, 'objects', `${fieldPath}.inlineEdit`); + } for (const [key, value] of Object.entries(field)) { if (FIELD_SELF_KEYS.has(key) || key === 'displayField') continue; walk(ledger, value, objectName, 'objects', `${fieldPath}.${key}`, [key], key); @@ -746,6 +861,9 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] { const fieldName = strName(field.name); if (!fieldName) continue; ledger.declare(objectName, fieldName); + let fieldMap = ledger.fieldMapByObject.get(objectName); + if (!fieldMap) ledger.fieldMapByObject.set(objectName, (fieldMap = {})); + fieldMap[fieldName] = field; const exempt = injected.has(fieldName) || fieldName === titleField || field.type === 'master_detail'; declared.push({ object: objectName, field: fieldName, path: fieldPath, exempt }); } diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 3c53fe5b1b3..0790ae1918a 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -135,6 +135,7 @@ "DATE_RANGE_PRESETS (const)", "DATE_RANGE_PRESET_MACRO_WINDOWS (const)", "DEFAULT_AUTONUMBER_FORMAT (const)", + "DEFAULT_MAX_INLINE_GRID_COLUMNS (const)", "DEFAULT_VALUE_TOKENS (const)", "DEFAULT_VALUE_TOKEN_CURRENT_USER (const)", "DEFAULT_VALUE_TOKEN_DESCRIPTIONS (const)", @@ -193,6 +194,7 @@ "DateRangePreset (type)", "DefaultValueShape (type)", "DefaultValueToken (type)", + "DerivedInlineGridColumn (interface)", "Dimension (type)", "DimensionSchema (const)", "DimensionType (const)", @@ -800,6 +802,7 @@ "definePicklist (function)", "defineSeed (function)", "deriveFieldGroupLayout (function)", + "deriveInlineGridColumns (function)", "deriveRecordFlowSurface (function)", "deriveRecordSurface (function)", "describeManagedApiMethodConflicts (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 129de63c943..6e35d38b6a6 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -132,6 +132,7 @@ "DATE_RANGE_PRESETS": "src/data/date-range-presets.ts#DATE_RANGE_PRESETS (const)", "DATE_RANGE_PRESET_MACRO_WINDOWS": "src/data/date-range-presets.ts#DATE_RANGE_PRESET_MACRO_WINDOWS (const)", "DEFAULT_AUTONUMBER_FORMAT": "src/data/autonumber-format.ts#DEFAULT_AUTONUMBER_FORMAT (const)", + "DEFAULT_MAX_INLINE_GRID_COLUMNS": "src/data/inline-grid-columns.ts#DEFAULT_MAX_INLINE_GRID_COLUMNS (const)", "DEFAULT_VALUE_TOKENS": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKENS (const)", "DEFAULT_VALUE_TOKEN_CURRENT_USER": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_CURRENT_USER (const)", "DEFAULT_VALUE_TOKEN_DESCRIPTIONS": "src/data/default-value-tokens.ts#DEFAULT_VALUE_TOKEN_DESCRIPTIONS (const)", @@ -190,6 +191,7 @@ "DateRangePreset": "src/data/date-range-presets.ts#DateRangePreset (type)", "DefaultValueShape": "src/data/default-value-shape.ts#DefaultValueShape (type)", "DefaultValueToken": "src/data/default-value-tokens.ts#DefaultValueToken (type)", + "DerivedInlineGridColumn": "src/data/inline-grid-columns.ts#DerivedInlineGridColumn (interface)", "Dimension": "src/data/analytics.zod.ts#Dimension (type)", "DimensionSchema": "src/data/analytics.zod.ts#DimensionSchema (const)", "DimensionType": "src/data/analytics.zod.ts#DimensionType (type)", @@ -787,6 +789,7 @@ "definePicklist": "src/data/picklist.zod.ts#definePicklist (function)", "defineSeed": "src/data/seed.zod.ts#defineSeed (function)", "deriveFieldGroupLayout": "src/data/field-group-layout.ts#deriveFieldGroupLayout (function)", + "deriveInlineGridColumns": "src/data/inline-grid-columns.ts#deriveInlineGridColumns (function)", "deriveRecordFlowSurface": "src/data/record-surface.ts#deriveRecordFlowSurface (function)", "deriveRecordSurface": "src/data/record-surface.ts#deriveRecordSurface (function)", "describeManagedApiMethodConflicts": "src/data/managed-api-affordance.ts#describeManagedApiMethodConflicts (function)", diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index f6f8ee7b258..c6d39f3d917 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -305,6 +305,11 @@ export * from './search-fields'; // grouping semantics every renderer (form, detail, drawer, designer) applies. export * from './field-group-layout'; +// Default inline-grid columns — the single source of which child fields an +// inline master-detail grid draws when its author listed none. Consumed by the +// renderer and credited by lint's `field-no-consumers`, so the two agree. +export * from './inline-grid-columns'; + // record-surface derivation (ADR-0085 §5) — the single source for how a record's // create/edit/detail opens by default (full page vs drawer/modal overlay). export * from './record-surface'; diff --git a/packages/spec/src/data/inline-grid-columns.test.ts b/packages/spec/src/data/inline-grid-columns.test.ts new file mode 100644 index 00000000000..363cfc3050b --- /dev/null +++ b/packages/spec/src/data/inline-grid-columns.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { DEFAULT_MAX_INLINE_GRID_COLUMNS, deriveInlineGridColumns } from './inline-grid-columns'; +import { InlineGridColumnSchema } from './field.zod'; + +/** + * The default columns of an inline master-detail grid with no authored + * columns. The fixtures are the renderer's own `deriveColumns` cases + * (objectui `packages/plugin-form/src/deriveMasterDetail.test.ts`), so the two + * agree on the cases the renderer pins: the same names, the same order, the + * same overflow collapsed into the column chooser. + */ +describe('deriveInlineGridColumns', () => { + const taskSchema = { + name: 'showcase_task', + fields: { + id: { type: 'text', system: true }, + title: { type: 'text', label: 'Title', required: true }, + status: { type: 'select', label: 'Status', options: [{ label: 'To Do', value: 'todo' }] }, + estimate_hours: { type: 'number', label: 'Estimate (h)' }, + budget: { type: 'currency', label: 'Budget' }, + due_date: { type: 'date', label: 'Due Date' }, + assignee: { type: 'lookup', label: 'Assignee', reference: 'user' }, + project: { type: 'master_detail', label: 'Project', reference: 'showcase_project', required: true }, + health: { type: 'formula', label: 'Health', expression: 'x' }, + created_at: { type: 'datetime' }, + }, + }; + + const names = (cols: { name: string }[]) => cols.map((c) => c.name); + const visible = (cols: { name: string; defaultHidden?: true }[]) => cols.filter((c) => !c.defaultHidden).map((c) => c.name); + + it('draws every editable child field in field order, skipping system, audit, the relationship and non-editable types', () => { + expect(deriveInlineGridColumns(taskSchema, { relationshipField: 'project' })).toEqual([ + { name: 'title' }, + { name: 'status' }, + { name: 'estimate_hours' }, + { name: 'budget' }, + { name: 'due_date' }, + { name: 'assignee' }, + ]); + }); + + it('without a relationship field, the relationship is an ordinary lookup column', () => { + expect(names(deriveInlineGridColumns(taskSchema, { maxColumns: 0 }))).toContain('project'); + }); + + it('skips sort-position names, `readonly` / `hidden` fields, and anything in `exclude`', () => { + const def = { + fields: { + line_no: { type: 'number' }, + sort_order: { type: 'number' }, + frozen: { type: 'text', readonly: true }, + secret: { type: 'text', hidden: true }, + owner: { type: 'lookup', reference: 'sys_user' }, + note: { type: 'text' }, + qty: { type: 'number' }, + }, + }; + expect(names(deriveInlineGridColumns(def, { exclude: ['note'] }))).toEqual(['qty']); + }); + + it('keeps file-family fields (an upload cell) and drops rich text, JSON and computed types', () => { + const def = { + fields: { + receipt: { type: 'file' }, + photo: { type: 'image' }, + body: { type: 'richtext' }, + meta: { type: 'json' }, + place: { type: 'location' }, + seq: { type: 'autonumber' }, + total: { type: 'summary' }, + memo: { type: 'textarea' }, + }, + }; + expect(names(deriveInlineGridColumns(def))).toEqual(['receipt', 'photo', 'memo']); + }); + + describe('the column budget collapses, never drops', () => { + const wideSchema = { + fields: { + title: { type: 'text', required: true }, + assignee: { type: 'text' }, + status: { type: 'select', required: true }, + priority: { type: 'select' }, + estimate_hours: { type: 'number' }, + progress: { type: 'text' }, + done: { type: 'boolean' }, + due_date: { type: 'date' }, + start_date: { type: 'date' }, + end_date: { type: 'date' }, + labels: { type: 'text' }, + notes: { type: 'text' }, + parent: { type: 'master_detail', reference: 'p', required: true }, + }, + }; + + it(`returns every column, ${DEFAULT_MAX_INLINE_GRID_COLUMNS} of them visible, in field order`, () => { + const cols = deriveInlineGridColumns(wideSchema, { relationshipField: 'parent' }); + expect(cols).toHaveLength(12); + expect(names(cols)).toEqual(Object.keys(wideSchema.fields).filter((n) => n !== 'parent')); + expect(visible(cols)).toEqual(['title', 'status', 'priority', 'estimate_hours', 'done', 'due_date']); + }); + + it('collapses the low-signal text columns into the chooser', () => { + const byName = Object.fromEntries(deriveInlineGridColumns(wideSchema, { relationshipField: 'parent' }).map((c) => [c.name, c])); + expect(byName.notes).toEqual({ name: 'notes', defaultHidden: true }); + expect(byName.labels).toEqual({ name: 'labels', defaultHidden: true }); + }); + + it('`maxColumns: 0` marks no column hidden', () => { + const cols = deriveInlineGridColumns(wideSchema, { relationshipField: 'parent', maxColumns: 0 }); + expect(cols).toHaveLength(12); + expect(cols.every((c) => c.defaultHidden === undefined)).toBe(true); + }); + + it('keeps every required column visible, even past the budget', () => { + const reqHeavy = { + fields: { + a: { type: 'text', required: true }, + b: { type: 'text', required: true }, + c: { type: 'text', required: true }, + d: { type: 'text', required: true }, + e: { type: 'text', required: true }, + f: { type: 'text', required: true }, + g: { type: 'text', required: true }, + h: { type: 'text' }, + parent: { type: 'master_detail', reference: 'p', required: true }, + }, + }; + const cols = deriveInlineGridColumns(reqHeavy, { relationshipField: 'parent' }); + expect(visible(cols)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g']); + expect(cols.find((c) => c.name === 'h')).toEqual({ name: 'h', defaultHidden: true }); + }); + + it('a computed column is never required, so `required` does not keep it visible', () => { + const def = { + fields: { + name: { type: 'text' }, + a: { type: 'select' }, + b: { type: 'select' }, + c: { type: 'select' }, + d: { type: 'select' }, + e: { type: 'select' }, + line_total: { type: 'currency', required: true, expression: { dialect: 'cel', source: 'record.qty * record.price' } }, + }, + }; + const cols = deriveInlineGridColumns(def); + expect(cols.find((c) => c.name === 'line_total')).toEqual({ name: 'line_total', defaultHidden: true }); + expect(visible(cols)).toEqual(['name', 'a', 'b', 'c', 'd', 'e']); + }); + }); + + it('returns no columns for a definition with no field map', () => { + expect(deriveInlineGridColumns(undefined)).toEqual([]); + expect(deriveInlineGridColumns(null)).toEqual([]); + expect(deriveInlineGridColumns('line')).toEqual([]); + expect(deriveInlineGridColumns({ name: 'line' })).toEqual([]); + }); + + it('every derived entry is a valid identity-only `inlineColumns` entry', () => { + const wide = { fields: Object.fromEntries(Array.from({ length: 9 }, (_, i) => [`f${i}`, { type: 'text' }])) }; + const cols = deriveInlineGridColumns(wide); + expect(cols.some((c) => c.defaultHidden)).toBe(true); + for (const col of cols) expect(InlineGridColumnSchema.parse(col)).toEqual(col); + }); +}); diff --git a/packages/spec/src/data/inline-grid-columns.ts b/packages/spec/src/data/inline-grid-columns.ts new file mode 100644 index 00000000000..aeb7c8e8361 --- /dev/null +++ b/packages/spec/src/data/inline-grid-columns.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Default inline-grid columns — the single source of WHICH child fields an + * inline master-detail grid draws when its author listed no columns. + * + * Two carriers draw a grid of a child object's records inside the parent's + * form, and both say "derived from the child object when omitted": + * + * - a child relationship field that sets `inlineEdit`, with no + * `inlineColumns` (`FieldSchema.inlineColumns`); + * - a form view's `subforms[]` entry with no `columns` + * (`FormViewSchema.subforms`). + * + * The derivation used to live only in the renderer (objectui's + * `deriveColumns`), so no author-time tool could ask what such a grid draws: + * `field-no-consumers` reported every derived column's field as inert. This + * module is that rule promoted to the protocol, the same move + * `deriveFieldGroupLayout` made for `fieldGroups` (ADR-0085 §5): a pure, + * dependency-free helper the renderer consumes and the lint credits from, so + * the two cannot disagree about what a grid draws. + * + * ## The rule, reproduced exactly + * + * Measured against objectui `main` at `be5211522412` + * (`packages/plugin-form/src/deriveMasterDetail.ts`, `deriveColumns` and its + * `curateColumns`). Every child field, in the field map's own order, except: + * + * - a name in {@link INLINE_GRID_SYSTEM_FIELDS} (identity, audit, tenancy and + * ownership columns) or {@link INLINE_GRID_SORT_FIELDS} (a line's sort + * position: the grid stamps it on drag-reorder instead); + * - the relationship field back to the parent, and any name in `exclude`; + * - a field flagged `system`, `readonly` or `hidden`; + * - a field whose `type` is in {@link INLINE_GRID_NON_EDITABLE_TYPES}. + * + * NOTHING is dropped by the column budget. Past `maxColumns` (default + * {@link DEFAULT_MAX_INLINE_GRID_COLUMNS}) the overflow is marked + * `defaultHidden`, which collapses it into the grid's column chooser: the + * column is still drawn on demand. The visible set keeps the first name-like + * column (else the first column) and every required column, then fills by the + * cell type's usefulness, ties broken by field order. + * + * Some type names in the sets below are not `FieldType` members (`rollup`, + * `auto_number`, `picklist`, …). They are the renderer's own legacy + * tolerances, carried so the derivation agrees with it on every input — a + * spec-valid field never carries them, so they change no answer for one. + * + * ## What it returns, and what it does not + * + * Each entry is an identity-only inline grid column — `{ name }`, plus + * `defaultHidden: true` on the overflow — which is a valid `inlineColumns` + * entry in its own right. The label, cell type, options, lookup target, + * conditional rules and computed expression are NOT derived here: the + * renderer hydrates them from the child field, exactly as it hydrates an + * identity-only column an author wrote. So the derived list is precisely the + * `inlineColumns` an author could have written to draw the same grid. + */ + +/** Default-visible column budget of a derived inline grid; the rest are `defaultHidden`. */ +export const DEFAULT_MAX_INLINE_GRID_COLUMNS = 6; + +/** One derived column: identity-only, as an author would write it in `inlineColumns`. */ +export interface DerivedInlineGridColumn { + /** The child field the column draws. */ + name: string; + /** Present (and `true`) only on a column past the visible budget: collapsed into the column chooser, never dropped. */ + defaultHidden?: true; +} + +type AnyRec = Record; + +/** Identity, audit, tenancy and ownership columns — never an editable line-item column. */ +const INLINE_GRID_SYSTEM_FIELDS: ReadonlySet = new Set([ + 'id', '_id', 'recordId', + 'created_at', 'updated_at', 'created_by', 'updated_by', + 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', + 'organization_id', 'tenant_id', 'space', 'owner', +]); + +/** Field names that hold a line's sort position: the grid stamps them on drag-reorder. */ +const INLINE_GRID_SORT_FIELDS: ReadonlySet = new Set([ + 'position', 'sort_order', 'sequence', 'line_no', 'line_number', 'sort', +]); + +/** Field types a line-item cell cannot edit. File-family types are absent: they render an upload cell. */ +const INLINE_GRID_NON_EDITABLE_TYPES: ReadonlySet = new Set([ + 'formula', 'summary', 'rollup', 'autonumber', 'auto_number', + 'json', 'object', 'grid', 'table', + 'location', 'vector', 'html', 'markdown', 'richtext', +]); + +/** Field names that read as a record's primary column — kept visible first. */ +const NAME_LIKE_FIELDS: readonly string[] = ['name', 'title', 'subject', 'label', 'full_name', 'display_name', 'code']; + +/** + * The cell a field type renders as, which is all the budget's fill order + * needs. It mirrors the renderer's field-type → cell-type map; the cell + * types are `InlineGridColumnSchema.type`'s members. + */ +function cellTypeOf(type: unknown): string { + switch (type) { + case 'number': + case 'percent': + case 'rating': + case 'slider': + return 'number'; + case 'currency': + return 'currency'; + case 'date': + return 'date'; + case 'datetime': + return 'datetime'; + case 'time': + return 'time'; + case 'select': + case 'picklist': + case 'radio': + case 'boolean': + case 'toggle': + return 'select'; + case 'lookup': + case 'master_detail': + return 'lookup'; + case 'file': + case 'image': + case 'avatar': + return 'file'; + default: + return 'text'; + } +} + +/** Lower is kept first when filling the visible budget; a cell type absent here sorts last (5). */ +const CELL_FILL_PRIORITY: Readonly> = { + select: 0, + currency: 1, + number: 1, + lookup: 2, + date: 3, + datetime: 3, + time: 3, + text: 4, +}; + +/** A field's own key, read the way the renderer reads `def?.key` off an untyped bag. */ +function prop(def: unknown, key: string): unknown { + return def === null || def === undefined ? undefined : (def as AnyRec)[key]; +} + +/** A field carrying an arithmetic `expression` (bare string, or a CEL envelope's `source`) is a computed column. */ +function isComputed(def: unknown): boolean { + const expression = prop(def, 'expression'); + const source = typeof expression === 'string' ? expression : prop(expression, 'source'); + return typeof source === 'string' && source.length > 0; +} + +interface Candidate { + name: string; + priority: number; + required: boolean; +} + +/** + * Derive the default columns of an inline master-detail grid from the child + * object's definition (or any bare record shaped like one: `{ fields }` with + * the field map the spec declares). + * + * `relationshipField` is the child's field back to the parent — excluded, + * since the grid fills it in. `exclude` drops further names. `maxColumns` is + * the visible budget; `0` (or less) marks no column hidden. + * + * Returns `[]` when the definition carries no field map. + */ +export function deriveInlineGridColumns( + def: unknown, + opts: { relationshipField?: string; exclude?: readonly string[]; maxColumns?: number } = {}, +): DerivedInlineGridColumn[] { + const fields = prop(def, 'fields'); + if (!fields || typeof fields !== 'object') return []; + const exclude = new Set([...(opts.exclude ?? []), ...(opts.relationshipField ? [opts.relationshipField] : [])]); + + const candidates: Candidate[] = []; + for (const [name, field] of Object.entries(fields as AnyRec)) { + if (INLINE_GRID_SYSTEM_FIELDS.has(name) || exclude.has(name) || INLINE_GRID_SORT_FIELDS.has(name)) continue; + if (prop(field, 'system') || prop(field, 'readonly') || prop(field, 'hidden')) continue; + if (INLINE_GRID_NON_EDITABLE_TYPES.has(prop(field, 'type'))) continue; + candidates.push({ + name, + priority: CELL_FILL_PRIORITY[cellTypeOf(prop(field, 'type'))] ?? 5, + // A computed column is never user-entered, so never required. + required: !!prop(field, 'required') && !isComputed(field), + }); + } + + const max = opts.maxColumns ?? DEFAULT_MAX_INLINE_GRID_COLUMNS; + if (max <= 0 || candidates.length <= max) return candidates.map(({ name }) => ({ name })); + + const visible = new Set(); + const primary = candidates.find((c) => NAME_LIKE_FIELDS.includes(c.name)) ?? candidates[0]; + if (primary) visible.add(primary.name); + for (const c of candidates) if (c.required) visible.add(c.name); + const remaining = candidates + .map((c, i) => ({ c, i })) + .filter(({ c }) => !visible.has(c.name)) + .sort((a, b) => a.c.priority - b.c.priority || a.i - b.i); + for (const { c } of remaining) { + if (visible.size >= max) break; + visible.add(c.name); + } + return candidates.map(({ name }) => (visible.has(name) ? { name } : { name, defaultHidden: true })); +}