diff --git a/.changeset/21091-inline-row-form-join-key.md b/.changeset/21091-inline-row-form-join-key.md new file mode 100644 index 00000000000..2348612b2d4 --- /dev/null +++ b/.changeset/21091-inline-row-form-join-key.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': patch +--- + +`deriveInlineRowFormFields` and `isInlineRowFormOffered` (`@objectstack/spec/data`) state which fields an inline master-detail grid's per-row expand form draws and when that form is offered, and `field-no-consumers` stops calling four more kinds of in-use child field "inert" (#21091). + +Clause-②: yes (widening) + +- **`@objectstack/spec`.** Two new exports from `@objectstack/spec/data`, beside `deriveInlineGridColumns`: + - `deriveInlineRowFormFields(def, { relationshipField?, exclude? })` returns the child field names of the per-row expand form, in the child's field order. It skips the same system, audit, tenancy, ownership and sort-position names as the grid, the relationship field, `exclude`, `system` and `hidden` fields, and the computed types (`formula`, `summary`, `rollup`, `autonumber`, `auto_number`). Unlike the grid it keeps `readonly` fields and the rich types a cell cannot edit (`richtext`, `json`, `markdown`, …), so the derived grid's columns are always a subset of its fields. + - `isInlineRowFormOffered({ inlineMode?, formFields?, columns? })` is `true` when the form factor is `form`, or when the form has more fields than the grid has columns. + - Both are the renderer's current rule, reproduced exactly. No schema accepts anything new or refuses anything new. +- **`@objectstack/lint`.** `os validate` no longer warns that these fields are inert: + - a `lookup` field that sets `inlineEdit`: it is the inline grid's join key, read whatever columns the grid draws, as a `master_detail` field already was; + - a field a derived inline grid's per-row expand form draws, through `deriveInlineRowFormFields`, such as a `readonly`, `richtext` or `json` child field; + - a field named in an `object-master-detail-form` detail entry's `formFields`, now read against the entry's `childObject` instead of the block's object. When the form is never offered for the list, the list is reported as a carrier. That is judged on an entry that names both its `relationshipField` and its `columns` under its declared `inlineMode` or none. On any other entry it is judged under a declared `inlineMode` where the grid can be counted: authored `columns`, or the derived grid of a named `relationshipField`. Otherwise the list is credited as drawn; + - a field named in a `record:line_items` block's `columns`, `relationshipField`, `amountField`, `sort` or `filter`, now read against the block's `childObject`. + + A parent field that shares a name with one of those child fields was credited in the child's place, and is now reported if nothing else reads it. A child field nothing draws or names, such as 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 a4c64f789c3..0bffff8e49c 100644 --- a/packages/lint/src/validate-field-consumers.test.ts +++ b/packages/lint/src/validate-field-consumers.test.ts @@ -771,25 +771,29 @@ describe('[#20951] validateFieldConsumers — a child collection credits its chi 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. */ + /** + * The parent's two fields (nothing reads them) and the one child field no + * derivation draws: the `hidden` one. The grid leaves out the JSON and + * `readonly` fields too, but [#21091] its per-row expand form draws them. + */ const DERIVED = { 'inv.total': 'inert', 'inv.line_total': 'inert', - 'line.blob': 'inert', 'line.secret': 'inert', - 'line.frozen': 'inert', }; + /** {@link DERIVED} plus the two the grid alone leaves out: reported wherever no derived row form is drawn. */ + const NO_ROW_FORM = { ...DERIVED, 'line.blob': 'inert', 'line.frozen': 'inert' }; it('baseline: without `inlineEdit` no grid is drawn, and every child field is reported', () => { expect(verdicts(stack(lineFields))).toEqual({ - ...DERIVED, + ...NO_ROW_FORM, '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) => { + it.each([['grid'], ['form'], [true]])('`inlineEdit: %s` with no `inlineColumns` credits the derived columns and row form, and only them', (inlineEdit) => { expect(verdicts(stack(lineFields, {}, { ...MASTER_DETAIL, inlineEdit }))).toEqual(DERIVED); }); @@ -799,7 +803,7 @@ describe('[#20951] validateFieldConsumers — a child collection credits its chi 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, + ...NO_ROW_FORM, 'line.line_total': 'inert', 'line.memo': 'inert', }); @@ -807,7 +811,7 @@ describe('[#20951] validateFieldConsumers — a child collection credits its chi it('`inlineEdit` on a field that is not a relationship draws no grid', () => { expect(verdicts(stack({ ...lineFields, tag: { type: 'text', inlineEdit: 'grid' } }))).toEqual({ - ...DERIVED, + ...NO_ROW_FORM, 'line.qty': 'inert', 'line.line_total': 'inert', 'line.memo': 'inert', @@ -924,3 +928,338 @@ describe('[#20928] validateFieldConsumers — an `object-master-detail-form` det expect(verdicts(stack([], { details: [component] }))).toEqual(elsewhere); }); }); + +/** + * [#21091] The family's closeout: the last positions where an inline child + * collection reads a CHILD field that the rule called inert. + * + * Position 1 — the join key. A `lookup` or `master_detail` field that sets + * `inlineEdit` is the key the renderer loads the child rows by and stamps on + * every row it saves, whatever columns the grid draws. `master_detail` was + * exempt already; `lookup` now reads the same. + * + * Position 2 — the per-row expand form. Each row of a derived grid can open a + * full form whose fields `deriveInlineRowFormFields` (`@objectstack/spec/data`) + * derives: it keeps the `richtext`, `json` and `readonly` fields the grid + * leaves out. It is offered when `isInlineRowFormOffered` says so. + * + * Position 3 — an `object-master-detail-form` detail entry's authored + * `formFields`, read against its `childObject` rather than the parent. + * + * `ord` is the parent and `itm` the child. `ord.notes` is a parent twin of the + * child's `notes`, so a list read against the wrong object shows up as the + * wrong one of the pair going quiet. `itm.secret` is `hidden`: no derivation + * draws it and nothing names it — the control every case keeps reported. + */ +describe('[#21091] validateFieldConsumers — an inline collection reads its join key and its per-row expand form', () => { + const data = { provider: 'object', object: 'ord' }; + const LOOKUP = { type: 'lookup', reference: 'ord' }; + const MASTER_DETAIL = { type: 'master_detail', reference: 'ord' }; + + /** Drawn by the grid (`name`, `qty`), by the row form only (`notes`, `spec`, `frozen`), by nothing (`secret`). */ + const ITEM_FIELDS = { + name: { type: 'text' }, + qty: { type: 'number' }, + notes: { type: 'richtext' }, + spec: { type: 'json' }, + frozen: { type: 'text', readonly: true }, + secret: { type: 'text', hidden: true }, + }; + + const stack = (relationship: AnyRec, extra: { form?: AnyRec; details?: unknown[] } = {}): AnyRec => ({ + objects: [ + { name: 'ord', fields: { name: { type: 'text' }, notes: { type: 'textarea' } } }, + { name: 'itm', fields: { ...ITEM_FIELDS, ord: relationship } }, + ], + views: [{ list: { type: 'grid', data, columns: [{ field: 'name' }] }, ...(extra.form ? { form: extra.form } : {}) }], + ...(extra.details + ? { + pages: [{ + name: 'ord_entry', + regions: [{ + name: 'main', + components: [{ type: 'object-master-detail-form', properties: { objectName: 'ord', details: extra.details } }], + }], + }], + } + : {}), + }); + + /** `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])); + + /** Left reported once the grid, its row form and the join key are all credited. */ + const ALL_DRAWN = { 'ord.notes': 'inert', 'itm.secret': 'inert' }; + /** What a grid with no row form leaves: the row-form-only fields stay reported. */ + const GRID_ONLY = { ...ALL_DRAWN, 'itm.notes': 'inert', 'itm.spec': 'inert', 'itm.frozen': 'inert' }; + + describe('position 1: the inline relationship is its grid\'s join key', () => { + it('baseline: a `lookup` with no `inlineEdit` draws no grid, and it and every child field are reported', () => { + expect(verdicts(stack(LOOKUP))).toEqual({ ...GRID_ONLY, 'itm.qty': 'inert', 'itm.ord': 'inert' }); + }); + + it.each([['grid'], ['form'], [true]])('a `lookup` with `inlineEdit: %s` is read', (inlineEdit) => { + expect(verdicts(stack({ ...LOOKUP, inlineEdit }))['itm.ord']).toBeUndefined(); + }); + + it('and with authored `inlineColumns` that do not name it, too: the key is read whatever the grid draws', () => { + const findings = verdicts(stack({ ...LOOKUP, inlineEdit: 'grid', inlineColumns: [{ name: 'qty' }] })); + expect(findings).toEqual(GRID_ONLY); + }); + + it('`inlineEdit: false` draws no grid, so the `lookup` stays reported', () => { + expect(verdicts(stack({ ...LOOKUP, inlineEdit: false }))['itm.ord']).toBe('inert'); + }); + + it('a `lookup` whose `reference` is missing draws no grid, so it stays reported', () => { + expect(verdicts(stack({ type: 'lookup', inlineEdit: 'grid' }))['itm.ord']).toBe('inert'); + }); + }); + + describe('position 2: a derived grid\'s per-row expand form draws the fields the grid leaves out', () => { + it.each([ + ['a `lookup` with `inlineEdit: grid`', { ...LOOKUP, inlineEdit: 'grid' }], + ['a `lookup` with `inlineEdit: true`', { ...LOOKUP, inlineEdit: true }], + ['a `master_detail` with `inlineEdit: form`', { ...MASTER_DETAIL, inlineEdit: 'form' }], + ])('%s credits the row form\'s `richtext`, `json` and `readonly` fields — never the `hidden` one', (_label, relationship) => { + expect(verdicts(stack(relationship))).toEqual(ALL_DRAWN); + }); + + it('a `subforms` entry with no `columns` credits its row form the same way', () => { + const form = { type: 'simple', data, subforms: [{ childObject: 'itm', relationshipField: 'ord' }] }; + expect(verdicts(stack(LOOKUP, { form }))).toEqual(ALL_DRAWN); + }); + + it('a detail entry with no `columns` credits its row form the same way', () => { + expect(verdicts(stack(LOOKUP, { details: [{ childObject: 'itm', relationshipField: 'ord' }] }))).toEqual(ALL_DRAWN); + }); + + it('an authored grid keeps no derived row form: a `subforms` entry with `columns` and a `relationshipField`', () => { + const form = { type: 'simple', data, subforms: [{ childObject: 'itm', relationshipField: 'ord', columns: [{ name: 'qty' }] }] }; + expect(verdicts(stack(LOOKUP, { form }))).toEqual(GRID_ONLY); + }); + + it('a computed field is no row-form input: it stays reported', () => { + const withFormula: AnyRec = { ...stack({ ...LOOKUP, inlineEdit: 'grid' }) }; + const [ord, itm] = withFormula.objects as AnyRec[]; + withFormula.objects = [ord, { ...itm, fields: { ...(itm.fields as AnyRec), calc: { type: 'formula', expression: '1' } } }]; + expect(verdicts(withFormula)).toEqual({ ...ALL_DRAWN, 'itm.calc': 'inert' }); + }); + }); + + describe('position 3: a detail entry\'s authored `formFields` names fields of its CHILD', () => { + const entry = (extra: AnyRec): AnyRec => ({ childObject: 'itm', relationshipField: 'ord', columns: [{ name: 'qty' }], ...extra }); + /** The authored grid draws `qty`; `name` is the title field; `ord` is read as `relationshipField`. */ + const AUTHORED_GRID = { 'ord.notes': 'inert', 'itm.notes': 'inert', 'itm.spec': 'inert', 'itm.frozen': 'inert', 'itm.secret': 'inert' }; + + it('baseline: an authored grid with no `formFields` draws none of the row-form fields', () => { + expect(verdicts(stack(LOOKUP, { details: [entry({})] }))).toEqual(AUTHORED_GRID); + }); + + it("`formFields` with `inlineMode: form` credits the CHILD's field, and the parent's twin stays reported", () => { + const { 'itm.notes': _credited, ...rest } = AUTHORED_GRID; + expect(verdicts(stack(LOOKUP, { details: [entry({ formFields: ['qty', 'notes'], inlineMode: 'form' })] }))).toEqual(rest); + }); + + it('`inlineMode: grid` with more form fields than grid columns: the form is offered, and its fields credited', () => { + const details = [entry({ formFields: ['notes', 'spec', 'frozen'], inlineMode: 'grid' })]; + expect(verdicts(stack(LOOKUP, { details }))).toEqual({ 'ord.notes': 'inert', 'itm.secret': 'inert' }); + }); + + it('`inlineMode: grid` with no more form fields than grid columns: never offered, so the list is a carrier', () => { + const details = [entry({ columns: [{ name: 'qty' }, { name: 'name' }], formFields: ['notes'], inlineMode: 'grid' })]; + const findings = validateFieldConsumers(stack(LOOKUP, { details })); + const notes = findings.find((f) => f.object === 'itm' && f.field === 'notes'); + expect(notes?.verdict).toBe('carrier-only'); + expect(notes?.carriers).toEqual(['pages[0].regions[0].components[0].properties.details[0].formFields[0]']); + }); + + it('kept as authored (`relationshipField` and `columns`), an omitted `inlineMode` is no form factor: one form field against two columns is never offered, so the list is a carrier', () => { + const details = [entry({ columns: [{ name: 'qty' }, { name: 'name' }], formFields: ['notes'] })]; + expect(verdicts(stack(LOOKUP, { details }))['itm.notes']).toBe('carrier-only'); + }); + + it('kept as authored, an omitted `inlineMode` with more form fields than grid columns: the form is offered, and its fields credited', () => { + const details = [entry({ formFields: ['notes', 'spec'] })]; + const findings = verdicts(stack(LOOKUP, { details })); + expect(findings['itm.notes']).toBeUndefined(); + expect(findings['itm.spec']).toBeUndefined(); + expect(findings['itm.frozen']).toBe('inert'); + }); + + it('derived (no authored columns), an omitted `inlineMode` is resolved by the renderer from the child, which this rule does not reproduce: the list is credited as drawn', () => { + const details = [{ childObject: 'itm', relationshipField: 'ord', formFields: ['notes'] }]; + expect(verdicts(stack(LOOKUP, { details }))['itm.notes']).toBeUndefined(); + }); + + it('an authored list replaces the derived row form: with a derived grid, only the listed field is credited', () => { + // `inlineMode: form` offers it; the derived row form would also draw `spec` and `frozen`. + const details = [{ childObject: 'itm', relationshipField: 'ord', formFields: ['notes'], inlineMode: 'form' }]; + expect(verdicts(stack(LOOKUP, { details }))).toEqual({ + 'ord.notes': 'inert', + 'itm.spec': 'inert', + 'itm.frozen': 'inert', + 'itm.secret': 'inert', + }); + }); + + it('against a derived grid, the comparison counts the derived columns', () => { + // The derived grid draws `name` and `qty`: one form field is not more than two columns. + const details = [{ childObject: 'itm', relationshipField: 'ord', formFields: ['notes'], inlineMode: 'grid' }]; + expect(verdicts(stack(LOOKUP, { details }))['itm.notes']).toBe('carrier-only'); + }); + }); + + /** + * Position 4 — a `record:line_items` page block. Its `properties` is one + * child entry: objectui's `LineItemsPanel` lists the `childObject` rows + * whose `relationshipField` holds the page's record, draws the authored + * `columns`, sums `amountField` and writes the sum to the parent's + * `totalField`. It derives no column and offers no row form. + */ + describe('position 4: a `record:line_items` block reads its columns and child keys against its `childObject`', () => { + const block = (properties: AnyRec): AnyRec => ({ + ...stack(LOOKUP), + pages: [{ + name: 'ord_record', + type: 'record', + object: 'ord', + regions: [{ name: 'main', components: [{ type: 'record:line_items', properties }] }], + }], + }); + const UNREAD = { + 'ord.notes': 'inert', + 'itm.qty': 'inert', + 'itm.notes': 'inert', + 'itm.spec': 'inert', + 'itm.frozen': 'inert', + 'itm.secret': 'inert', + 'itm.ord': 'inert', + }; + + it('baseline: a block with no columns and no keys reads nothing, and derives nothing', () => { + expect(verdicts(block({ childObject: 'itm' }))).toEqual(UNREAD); + }); + + it('its authored columns, `relationshipField` and `amountField` credit the CHILD; the parent twin stays reported', () => { + const properties = { childObject: 'itm', relationshipField: 'ord', columns: [{ name: 'qty' }, { name: 'notes' }], amountField: 'spec' }; + expect(verdicts(block(properties))).toEqual({ 'ord.notes': 'inert', 'itm.frozen': 'inert', 'itm.secret': 'inert' }); + }); + + it('its `sort[].field` and `filter[].field` name CHILD fields: credited there, and the parent twin stays reported', () => { + const properties = { + childObject: 'itm', + relationshipField: 'ord', + columns: [{ name: 'qty' }], + sort: [{ field: 'notes', order: 'desc' }], + filter: [{ field: 'spec', operator: 'equals', value: 'x' }], + }; + expect(verdicts(block(properties))).toEqual({ 'ord.notes': 'inert', 'itm.frozen': 'inert', 'itm.secret': 'inert' }); + }); + + it('a field-keyed `filter` — a shape the panel lowers but the contract refuses — is read against the CHILD too', () => { + const properties = { childObject: 'itm', relationshipField: 'ord', columns: [{ name: 'qty' }], filter: { frozen: 'x' } }; + expect(verdicts(block(properties))).toEqual({ 'ord.notes': 'inert', 'itm.notes': 'inert', 'itm.spec': 'inert', 'itm.secret': 'inert' }); + }); + + it('control: a `sort` and `filter` read against the child do not credit a field only the parent declares', () => { + const properties = { childObject: 'itm', relationshipField: 'ord', columns: [{ name: 'qty' }], sort: [{ field: 'notes' }] }; + const s = block(properties); + const [ord, itm] = s.objects as AnyRec[]; + const { notes: _dropped, ...childFields } = itm.fields as AnyRec; + s.objects = [ord, { ...itm, fields: childFields }]; + // The child no longer declares `notes`: the sort names nothing it has, and the parent's `notes` stays reported. + expect(verdicts(s)['ord.notes']).toBe('inert'); + }); + + it('control: the same properties under a component type that is no child entry credit no child column', () => { + const s = block({ childObject: 'itm', columns: [{ name: 'qty' }] }); + const page = (s.pages as AnyRec[])[0]; + const region = (page.regions as AnyRec[])[0]; + (region.components as AnyRec[])[0].type = 'record:details'; + expect(verdicts(s)['itm.qty']).toBe('inert'); + }); + }); + + /** + * The boundary the pin does NOT cover: a row form opened with no field + * list. An authored grid in the `form` factor with no `formFields` makes the + * renderer open the child's default object form, which draws every visible + * child field the way the child's own create and edit forms do. No spec + * derivation states that form's field set, so this rule credits none of it + * — pinned here so the boundary is a reading, not an omission. + */ + describe('boundary: a row form opened with no field list is not credited', () => { + it('authored `inlineColumns` with `inlineEdit: form`: the child fields outside the columns stay reported', () => { + expect(verdicts(stack({ ...LOOKUP, inlineEdit: 'form', inlineColumns: [{ name: 'qty' }] }))).toEqual(GRID_ONLY); + }); + + it('a detail entry kept as authored with `inlineMode: form` and no `formFields`: the same', () => { + const details = [{ childObject: 'itm', relationshipField: 'ord', columns: [{ name: 'qty' }], inlineMode: 'form' }]; + expect(verdicts(stack(LOOKUP, { details }))).toEqual(GRID_ONLY); + }); + }); + + /** + * The family's closing check, one row per position the card enumerates: + * after it, `field-no-consumers` reports no inert verdict on any of these, + * and the control — a child field nothing draws or names — stays inert in + * every one of the same stacks. "The per-row expand form" here is the form + * the spec derives (`deriveInlineRowFormFields`) and an authored + * `formFields` list the form is offered for — not a form opened with no + * field list (the boundary above). + */ + describe('the enumeration pin', () => { + const form = (subform: AnyRec): { form: AnyRec } => ({ form: { type: 'simple', data, subforms: [subform] } }); + const rows: [string, AnyRec, string][] = [ + ["a field read as an inline grid's join key", stack({ ...LOOKUP, inlineEdit: 'grid' }), 'itm.ord'], + ['a field drawn by a derived inline-grid column', stack({ ...LOOKUP, inlineEdit: 'grid' }), 'itm.qty'], + ['a field drawn by the per-row expand form (`richtext`)', stack({ ...LOOKUP, inlineEdit: 'grid' }), 'itm.notes'], + ['a field drawn by the per-row expand form (`json`)', stack({ ...LOOKUP, inlineEdit: 'grid' }), 'itm.spec'], + ['a field drawn by the per-row expand form (`readonly`)', stack({ ...LOOKUP, inlineEdit: 'grid' }), 'itm.frozen'], + ["a subform entry's `amountField`", stack(LOOKUP, form({ childObject: 'itm', columns: [{ name: 'name' }], amountField: 'qty' })), 'itm.qty'], + ["a subform entry's `relationshipField`", stack(LOOKUP, form({ childObject: 'itm', columns: [{ name: 'name' }], relationshipField: 'ord' })), 'itm.ord'], + ['an authored `inlineColumns` member', stack({ ...LOOKUP, inlineEdit: 'grid', inlineColumns: [{ name: 'notes' }] }), 'itm.notes'], + ['an authored `subforms[].columns` member', stack(LOOKUP, form({ childObject: 'itm', columns: [{ name: 'spec' }] })), 'itm.spec'], + ["a detail entry's authored `formFields` member", stack(LOOKUP, { details: [{ childObject: 'itm', relationshipField: 'ord', columns: [{ name: 'qty' }], formFields: ['frozen'], inlineMode: 'form' }] }), 'itm.frozen'], + [ + 'a `record:line_items` block\'s column', + { + ...stack(LOOKUP), + pages: [{ + name: 'ord_record', + type: 'record', + object: 'ord', + regions: [{ name: 'main', components: [{ type: 'record:line_items', properties: { childObject: 'itm', relationshipField: 'ord', columns: [{ name: 'notes' }] } }] }], + }], + }, + 'itm.notes', + ], + ...([ + ['a `record:line_items` block\'s `sort[].field`', { sort: [{ field: 'spec', order: 'asc' }] }, 'itm.spec'], + ['a `record:line_items` block\'s `filter[].field`', { filter: [{ field: 'notes', operator: 'equals', value: 'x' }] }, 'itm.notes'], + ] as [string, AnyRec, string][]).map(([label, extra, key]): [string, AnyRec, string] => [ + label, + { + ...stack(LOOKUP), + pages: [{ + name: 'ord_record', + type: 'record', + object: 'ord', + regions: [{ name: 'main', components: [{ type: 'record:line_items', properties: { childObject: 'itm', relationshipField: 'ord', columns: [{ name: 'qty' }], ...extra } }] }], + }], + }, + key, + ]), + ]; + + it.each(rows)('%s is not reported', (_label, s, key) => { + expect(verdicts(s)[key]).toBeUndefined(); + }); + + it.each(rows)('control, beside %s: a child field nothing draws or names stays inert', (_label, s) => { + expect(verdicts(s)['itm.secret']).toBe('inert'); + }); + }); +}); diff --git a/packages/lint/src/validate-field-consumers.ts b/packages/lint/src/validate-field-consumers.ts index 3efd1236571..0feea678abb 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. * - * ## Three consumers that name the field nowhere + * ## 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. All three paths are read off the SPEC, not off a hand-kept list: + * load-bearing. Each path below is 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 @@ -83,6 +83,29 @@ * 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. + * - **That grid's per-row expand form** ({@link creditDerivedRowForm}, + * `deriveInlineRowFormFields` and `isInlineRowFormOffered`). Each row of + * a derived grid can open a full form whose fields are derived by a + * broader rule: it keeps the `readonly`, `richtext` and `json` fields the + * grid leaves out. Both the fields and the condition the form is offered + * under are the spec's; the hidden, system and computed fields stay + * uncredited. An authored `formFields` list is read against the child + * under the same condition ({@link creditAuthoredRowForm}). + * + * NOT credited: a row form opened with NO field list — an authored grid + * (`inlineColumns`, or an entry's authored `columns` and + * `relationshipField`) in the `form` factor, with no `formFields`. The + * renderer then opens the child's default object form, which draws every + * visible field the way the child's own create and edit forms do. No + * spec derivation states that form's field set, and this rule counts the + * default layout nowhere else (only a KEYED section of + * {@link creditFieldGroupLayout} is a site), so whether it counts here is + * not decided by this rule. + * + * One consumer is the relationship itself: a `lookup` or `master_detail` + * field that sets `inlineEdit` is the inline grid's join key — the renderer + * loads the child rows filtered on it and stamps it on every row it saves — + * so it is read whatever columns the grid draws. * * 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 @@ -128,7 +151,13 @@ * maintainer's call, not this rule's. */ -import { deriveFieldGroupLayout, deriveInlineGridColumns, resolveDisplayField } from '@objectstack/spec/data'; +import { + deriveFieldGroupLayout, + deriveInlineGridColumns, + deriveInlineRowFormFields, + isInlineRowFormOffered, + resolveDisplayField, +} from '@objectstack/spec/data'; import type { DisplayNameObjectMeta } from '@objectstack/spec/data'; import { referenceTargetOf } from '@objectstack/spec/data'; import { collectionEntries } from './collection-entries.js'; @@ -294,6 +323,9 @@ const WRITE_KEYS: ReadonlySet = new Set([ * component or an array of them. A component carries none of a child entry's * keys at its own level (they sit under its `properties`), so reading it as an * entry credits nothing and skips nothing. + * + * One page block is a single entry rather than a list of them: + * {@link CHILD_ENTRY_COMPONENT_TYPES}. */ const CHILD_COLLECTION_KEYS: ReadonlySet = new Set(['subforms', 'details']); @@ -309,6 +341,55 @@ const CHILD_COLLECTION_KEYS: ReadonlySet = new Set(['subforms', 'details */ const CHILD_ENTRY_FIELD_KEYS: ReadonlySet = new Set(['amountField', 'relationshipField']); +/** + * [#21091] The key of a child collection entry whose value LISTS fields of the + * CHILD object: an `object-master-detail-form` detail entry's `formFields`, + * "Child field names for the per-row expand form". Read against the entry's + * `childObject` by {@link creditAuthoredRowForm}, and skipped by the general + * walk, which would read it against the parent. + */ +const CHILD_ENTRY_FORM_FIELDS_KEY = 'formFields'; + +/** + * [#21091] Page component types whose `properties` IS one child collection + * entry. `record:line_items` (objectui `plugin-form/src/LineItemsPanel.tsx`, + * read at the `.objectui-sha` pin `31971ff1e28f`) lists the rows of + * `childObject` whose `relationshipField` holds the record the page is on, + * draws its authored `columns` over them, sums `amountField` across them and + * writes the sum to the parent's `totalField` — the keys and meanings of a + * `subforms` entry, read off the block's raw props. + * + * Unlike a `subforms` or `details` entry it derives nothing: with no authored + * `columns` it draws no column, and it offers no per-row expand form. So it is + * read as a {@link ChildEntryKind} `panel`: the authored columns and the + * {@link CHILD_ENTRY_FIELD_KEYS} against the child, nothing derived — and + * the two keys of {@link PANEL_CHILD_QUERY_KEYS}, walked in the child's + * context. + */ +const CHILD_ENTRY_COMPONENT_TYPES: ReadonlySet = new Set(['record:line_items']); + +/** + * [#21091] Keys of a `panel` entry that shape the CHILD query, so every field + * they name is a field of `childObject`. `LineItemsPanel` (at the same pin) + * converts `sort` (`SortConfig[]`) to the child fetch's order and merges + * `filter` into its `$filter`, beside the relationship condition. The + * contract declares `filter` as the ViewFilterRule array + * (`RecordLineItemsProps`); the panel's lowering also takes the field-keyed + * map and AST forms, and this rule reads whichever is authored. The general + * walk reads them as it reads any sort or filter — behaviour sites, a + * predicate map's keys included — but with `childObject` as the context + * instead of the page's object, which is the parent here. + */ +const PANEL_CHILD_QUERY_KEYS: ReadonlySet = new Set(['sort', 'filter']); + +/** + * How a child collection entry draws its child: a `collection` (a `subforms` + * or `details` entry) derives its grid and row form when they are not + * authored; a `panel` ({@link CHILD_ENTRY_COMPONENT_TYPES}) draws only what + * is authored. + */ +type ChildEntryKind = 'collection' | 'panel'; + /** * 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 @@ -572,19 +653,132 @@ function hasAuthoredColumns(columns: unknown): boolean { * 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. + * + * [#21091] The same grid offers each row a full expand form, credited here + * too when `rowForm` is set — see {@link creditDerivedRowForm}. */ function creditDerivedInlineGrid( ledger: ConsumerLedger, childObject: string | undefined, relationshipField: string | undefined, + rowForm: { inlineMode: 'grid' | 'form' | undefined } | 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 })) { + const columns = deriveInlineGridColumns({ fields }, { relationshipField }); + for (const { name } of columns) { if (ledger.declares(childObject, name)) ledger.record(childObject, name, { root, path, kind: 'display' }); } + if (rowForm === undefined) return; + creditDerivedRowForm(ledger, childObject, fields, relationshipField, rowForm.inlineMode, columns, root, path); +} + +/** + * [#21091] An inline collection's form factor when it is DECLARED — `grid` or + * `form`, the values `inlineEdit` and a detail entry's `inlineMode` both name. + * Anything else (`true`, absent) leaves it to the renderer to resolve, which + * this rule does not reproduce: `undefined`. + */ +function formFactorOf(v: unknown): 'grid' | 'form' | undefined { + return v === 'grid' || v === 'form' ? v : undefined; +} + +/** + * [#21091] Credit the fields a DERIVED per-row expand form draws, on the child. + * + * Each row of an inline grid can open a full form whose fields, when nobody + * listed them, are `deriveInlineRowFormFields` (`@objectstack/spec/data`) — + * broader than the grid: a `richtext`, `json` or `readonly` field the grid + * leaves out is drawn there. The renderer offers that form only when + * `isInlineRowFormOffered` says so, and this rule credits it under the same + * condition, both read off the spec rather than restated here. + * + * The derived grid's columns are a subset of the derived form's fields, so + * the condition decides nothing the columns had not already credited: a form + * that is not offered has no field the grid does not draw. That is why an + * `inlineMode` this rule cannot resolve (the renderer's smart default for + * `inlineEdit: true`) is passed as `undefined` without changing the verdict. + */ +function creditDerivedRowForm( + ledger: ConsumerLedger, + childObject: string, + fields: Record, + relationshipField: string | undefined, + inlineMode: 'grid' | 'form' | undefined, + columns: readonly unknown[], + root: string, + path: string, +): void { + const formFields = deriveInlineRowFormFields({ fields }, { relationshipField }); + if (!isInlineRowFormOffered({ inlineMode, formFields, columns })) return; + for (const name of formFields) { + if (ledger.declares(childObject, name)) ledger.record(childObject, name, { root, path, kind: 'display' }); + } +} + +/** + * [#21091] Credit an AUTHORED `formFields` list of a child collection entry, + * against the entry's `childObject`. + * + * Each name is a child field the per-row expand form draws — when the form is + * offered, which `isInlineRowFormOffered` decides from the entry's form + * factor, its form fields and its grid's columns. The renderer resolves an + * entry one of two ways (objectui `MasterDetailForm.tsx` at the `.objectui-sha` + * pin `31971ff1e28f`), and the lint feeds the predicate what each one feeds the + * expand control: + * + * - **Kept as authored** — the entry names BOTH its `relationshipField` and + * at least one column. Nothing is derived: the form factor is the + * declared `inlineMode`, or none at all, and the grid is the authored + * columns. So the predicate decides exactly, and an omitted mode offers + * the form only when the list is longer than the grid. + * - **Derived** — anything else. A declared `inlineMode` is kept, and an + * omitted one is resolved from the relationship's `inlineEdit`, else + * from the child's shape — a resolution this rule does not reproduce, so + * with an omitted mode the list is credited as drawn. With a declared + * mode the predicate decides whenever the grid can be counted: authored + * columns, or the derived grid when the entry names its + * `relationshipField` (without one the renderer detects the relationship + * and leaves it out of the grid, and this rule keeps no copy of that + * detection, so the list is credited as drawn). + * + * A list the form is never offered for names its fields without drawing + * them: a carrier a removal must clean, as `inlineColumns` is on a field that + * does not set `inlineEdit`. A name the child does not declare is counted + * unresolved. + */ +function creditAuthoredRowForm( + ledger: ConsumerLedger, + entry: AnyRec, + childObject: string | undefined, + root: string, + path: string, +): void { + const formFields = entry[CHILD_ENTRY_FORM_FIELDS_KEY]; + if (!Array.isArray(formFields)) return; + const inlineMode = formFactorOf(entry.inlineMode); + const relationshipField = strName(entry.relationshipField); + const authoredColumns = hasAuthoredColumns(entry.columns); + const keptAsAuthored = relationshipField !== undefined && authoredColumns; + const childFields = childObject === undefined ? undefined : ledger.fieldMapByObject.get(childObject); + const columns = authoredColumns + ? (entry.columns as unknown[]) + : relationshipField !== undefined && childFields !== undefined + ? deriveInlineGridColumns({ fields: childFields }, { relationshipField }) + : undefined; + const decidable = columns !== undefined && (keptAsAuthored || inlineMode !== undefined); + const offered = !decidable || isInlineRowFormOffered({ inlineMode, formFields, columns }); + formFields.forEach((value: unknown, i: number) => { + const field = strName(value); + if (field === undefined || !ledger.objectsByField.has(field)) return; + if (ledger.declares(childObject, field)) { + ledger.record(childObject, field, { root, path: `${path}[${i}]`, kind: offered ? 'display' : 'carrier' }); + } else { + ledger.unresolved += 1; + } + }); } /** @@ -650,6 +844,7 @@ function walk( path: string, segments: readonly string[], leafKey: string, + entryKind?: ChildEntryKind, ): void { if (node === null || node === undefined) return; if (typeof node === 'function') { @@ -669,13 +864,23 @@ 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. - const childEntry = CHILD_COLLECTION_KEYS.has(leafKey); - if (childEntry) { + const childEntry: ChildEntryKind | undefined = CHILD_COLLECTION_KEYS.has(leafKey) ? 'collection' : entryKind; + if (childEntry !== undefined) { 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`); + // [#21091] With no authored `formFields` either, its per-row expand form + // draws the derived fields; an authored list replaces them. A `panel` + // derives neither. + if (childEntry === 'collection' && !hasAuthoredColumns(rec.columns)) { + const authoredRowForm = Array.isArray(rec[CHILD_ENTRY_FORM_FIELDS_KEY]); + const rowForm = authoredRowForm ? undefined : { inlineMode: formFactorOf(rec.inlineMode) }; + const relationshipField = strName(rec.relationshipField); + creditDerivedInlineGrid(ledger, childObject, relationshipField, rowForm, root, `${path}.childObject`); + } + // [#21091] An authored row form, read against the child. + if (childEntry === 'collection') { + creditAuthoredRowForm(ledger, rec, childObject, root, `${path}.${CHILD_ENTRY_FORM_FIELDS_KEY}`); } // [#20951] The child-field keys, each read against the child — and only // there, which is why the loop below skips them. @@ -684,7 +889,8 @@ function walk( } } for (const [key, value] of Object.entries(rec)) { - if (childEntry && CHILD_ENTRY_FIELD_KEYS.has(key)) continue; + if (childEntry !== undefined && CHILD_ENTRY_FIELD_KEYS.has(key)) continue; + if (childEntry === 'collection' && key === CHILD_ENTRY_FORM_FIELDS_KEY) continue; const childPath = `${path}.${key}`; const childSegments = [...segments, key]; // A predicate map spells the field as its KEY (`{ is_active: true }`); a @@ -699,10 +905,17 @@ function walk( ledger.unresolved += 1; } } + // [#21091] A `record:line_items` block's `properties` is a child entry, + // and that entry's child-query keys name fields of its `childObject`. + const panel = key === 'properties' && typeof rec.type === 'string' && CHILD_ENTRY_COMPONENT_TYPES.has(rec.type); + if (childEntry === 'panel' && PANEL_CHILD_QUERY_KEYS.has(key)) { + walk(ledger, value, strName(rec.childObject), root, childPath, childSegments, key); + continue; + } // A map KEYED by object name — `translations[].en.objects.crm_x`, // `permissions[].objects.crm_x` — names its object in a position no // `object:` lookup reaches. - walk(ledger, value, ledger.isObject(key) ? key : inner, root, childPath, childSegments, key); + walk(ledger, value, ledger.isObject(key) ? key : inner, root, childPath, childSegments, key, panel ? 'panel' : undefined); } } @@ -769,10 +982,20 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj fieldName !== undefined && field.inlineEdit && (field.type === 'master_detail' || field.type === 'lookup') && - !!reference && - !hasAuthoredColumns(field.inlineColumns) + !!reference ) { - creditDerivedInlineGrid(ledger, objectName, fieldName, 'objects', `${fieldPath}.inlineEdit`); + // [#21091] The relationship itself is the grid's join key: the renderer + // loads the child rows filtered on it and stamps it on every row it + // saves. That holds whether the columns are authored or derived, and + // for a `lookup` as for a `master_detail` — which is exempt anyway. + ledger.record(objectName, fieldName, { root: 'objects', path: `${fieldPath}.inlineEdit`, kind: 'behaviour' }); + if (!hasAuthoredColumns(field.inlineColumns)) { + // [#21091] The derived grid's per-row expand form draws more of THIS + // object. An explicit `grid` / `form` is the form factor; `true` is + // the renderer's smart default, which this rule does not resolve. + const rowForm = { inlineMode: formFactorOf(field.inlineEdit) }; + creditDerivedInlineGrid(ledger, objectName, fieldName, rowForm, 'objects', `${fieldPath}.inlineEdit`); + } } for (const [key, value] of Object.entries(field)) { if (FIELD_SELF_KEYS.has(key) || key === 'displayField') continue; diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 0790ae1918a..91354442d84 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -803,6 +803,7 @@ "defineSeed (function)", "deriveFieldGroupLayout (function)", "deriveInlineGridColumns (function)", + "deriveInlineRowFormFields (function)", "deriveRecordFlowSurface (function)", "deriveRecordSurface (function)", "describeManagedApiMethodConflicts (function)", @@ -852,6 +853,7 @@ "isGlobalUnique (function)", "isIncoherentAggregate (function)", "isInjectedColumnDefinition (function)", + "isInlineRowFormOffered (function)", "isKnownFilterToken (function)", "isLegacyApiMethod (function)", "isMaskedOnReadFieldType (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 6e35d38b6a6..595920d9bfa 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -790,6 +790,7 @@ "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)", + "deriveInlineRowFormFields": "src/data/inline-grid-columns.ts#deriveInlineRowFormFields (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)", @@ -839,6 +840,7 @@ "isGlobalUnique": "src/data/field.zod.ts#isGlobalUnique (function)", "isIncoherentAggregate": "src/data/aggregation-policy.ts#isIncoherentAggregate (function)", "isInjectedColumnDefinition": "src/data/injected-system-column-provenance.ts#isInjectedColumnDefinition (function)", + "isInlineRowFormOffered": "src/data/inline-grid-columns.ts#isInlineRowFormOffered (function)", "isKnownFilterToken": "src/data/context-tokens.zod.ts#isKnownFilterToken (function)", "isLegacyApiMethod": "src/data/api-derivation.ts#isLegacyApiMethod (function)", "isMaskedOnReadFieldType": "src/data/masked-field-types.ts#isMaskedOnReadFieldType (function)", diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index c6d39f3d917..e51f4fc2569 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -306,8 +306,10 @@ export * from './search-fields'; 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. +// inline master-detail grid draws when its author listed none, and of which +// fields its per-row expand form draws (and when that form is offered). +// 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 diff --git a/packages/spec/src/data/inline-grid-columns.test.ts b/packages/spec/src/data/inline-grid-columns.test.ts index 363cfc3050b..99ba1774aad 100644 --- a/packages/spec/src/data/inline-grid-columns.test.ts +++ b/packages/spec/src/data/inline-grid-columns.test.ts @@ -1,7 +1,12 @@ // 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 { + DEFAULT_MAX_INLINE_GRID_COLUMNS, + deriveInlineGridColumns, + deriveInlineRowFormFields, + isInlineRowFormOffered, +} from './inline-grid-columns'; import { InlineGridColumnSchema } from './field.zod'; /** @@ -166,3 +171,123 @@ describe('deriveInlineGridColumns', () => { for (const col of cols) expect(InlineGridColumnSchema.parse(col)).toEqual(col); }); }); + +/** + * [#21091] The fields of an inline grid's per-row expand form. The first two + * fixtures are the renderer's own `deriveFormFields` cases (objectui + * `packages/plugin-form/src/deriveMasterDetail.test.ts` at the `.objectui-sha` + * pin `31971ff1e28f`), asserted here as whole lists rather than as the + * `toContain` probes they are there. + */ +describe('deriveInlineRowFormFields', () => { + 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' }, + }, + }; + + it('returns the business fields in field order, skipping system, audit, the relationship and computed types', () => { + expect(deriveInlineRowFormFields(taskSchema, { relationshipField: 'project' })).toEqual([ + 'title', 'status', 'estimate_hours', 'budget', 'due_date', 'assignee', + ]); + }); + + it('keeps the rich input types the grid omits, and drops the computed ones', () => { + const rich = { + fields: { + title: { type: 'text', required: true }, + parent: { type: 'master_detail', reference: 'p', required: true }, + notes: { type: 'textarea' }, + cover: { type: 'image' }, + attachment: { type: 'file' }, + total: { type: 'summary' }, + }, + }; + expect(deriveInlineRowFormFields(rich, { relationshipField: 'parent' })).toEqual(['title', 'notes', 'cover', 'attachment']); + }); + + it('keeps `readonly` fields and every type a cell cannot edit; drops `system`, `hidden`, sort positions and `exclude`', () => { + const def = { + fields: { + line_no: { type: 'number' }, + sort_order: { type: 'number' }, + frozen: { type: 'text', readonly: true }, + secret: { type: 'text', hidden: true }, + internal: { type: 'text', system: true }, + owner: { type: 'lookup', reference: 'sys_user' }, + body: { type: 'richtext' }, + meta: { type: 'json' }, + place: { type: 'location' }, + page: { type: 'html' }, + doc: { type: 'markdown' }, + seq: { type: 'autonumber' }, + roll: { type: 'rollup' }, + note: { type: 'text' }, + }, + }; + expect(deriveInlineRowFormFields(def, { exclude: ['note'] })).toEqual(['frozen', 'body', 'meta', 'place', 'page', 'doc']); + }); + + it('without a relationship field, the relationship is an ordinary field', () => { + expect(deriveInlineRowFormFields(taskSchema)).toContain('project'); + }); + + it('returns no fields for a definition with no field map', () => { + expect(deriveInlineRowFormFields(undefined)).toEqual([]); + expect(deriveInlineRowFormFields(null)).toEqual([]); + expect(deriveInlineRowFormFields('line')).toEqual([]); + expect(deriveInlineRowFormFields({ name: 'line' })).toEqual([]); + }); + + it('the derived grid draws a subset of the derived form: the form has every column, in the same order', () => { + const wide = { + fields: { + ...taskSchema.fields, + body: { type: 'richtext' }, + frozen: { type: 'number', readonly: true }, + ...Object.fromEntries(Array.from({ length: 6 }, (_, i) => [`f${i}`, { type: 'text' }])), + }, + }; + const opts = { relationshipField: 'project' }; + const form = deriveInlineRowFormFields(wide, opts); + const columns = deriveInlineGridColumns(wide, opts).map((c) => c.name); + expect(columns.length).toBeGreaterThan(0); + expect(form.filter((name) => columns.includes(name))).toEqual(columns); + expect(form.filter((name) => !columns.includes(name))).toEqual(['body', 'frozen']); + }); +}); + +/** + * [#21091] When an inline grid offers its per-row expand form — the condition + * objectui's `MasterDetailForm` applies at the `.objectui-sha` pin + * `31971ff1e28f` before it hands a row an expand control. + */ +describe('isInlineRowFormOffered', () => { + it('always in the `form` factor: the row form IS the editor', () => { + expect(isInlineRowFormOffered({ inlineMode: 'form', formFields: ['a'], columns: [{ name: 'a' }, { name: 'b' }] })).toBe(true); + expect(isInlineRowFormOffered({ inlineMode: 'form' })).toBe(true); + }); + + it('in the `grid` factor, only when the form has more fields than the grid has columns', () => { + expect(isInlineRowFormOffered({ inlineMode: 'grid', formFields: ['a', 'b', 'c'], columns: [{ name: 'a' }, { name: 'b' }] })).toBe(true); + expect(isInlineRowFormOffered({ inlineMode: 'grid', formFields: ['a', 'b'], columns: [{ name: 'a' }, { name: 'b' }] })).toBe(false); + expect(isInlineRowFormOffered({ inlineMode: 'grid', formFields: ['a'], columns: [{ name: 'a' }, { name: 'b' }] })).toBe(false); + }); + + it('with no form factor, the same count decides; an absent list counts as empty', () => { + expect(isInlineRowFormOffered({ formFields: ['a'], columns: [] })).toBe(true); + expect(isInlineRowFormOffered({ formFields: ['a'] })).toBe(true); + expect(isInlineRowFormOffered({ columns: [{ name: 'a' }] })).toBe(false); + expect(isInlineRowFormOffered({})).toBe(false); + }); +}); diff --git a/packages/spec/src/data/inline-grid-columns.ts b/packages/spec/src/data/inline-grid-columns.ts index aeb7c8e8361..4c0884e40d5 100644 --- a/packages/spec/src/data/inline-grid-columns.ts +++ b/packages/spec/src/data/inline-grid-columns.ts @@ -2,7 +2,8 @@ /** * Default inline-grid columns — the single source of WHICH child fields an - * inline master-detail grid draws when its author listed no columns. + * inline master-detail grid draws when its author listed no columns, and of + * which fields its per-row expand form draws when its author listed none. * * 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": @@ -54,6 +55,35 @@ * 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. + * + * ## The per-row expand form ({@link deriveInlineRowFormFields}) + * + * Each row of the grid can open a full form for that row, and that form draws + * more than the grid: it has room for the rich inputs a cell cannot hold. Its + * fields are derived from the child object too, by a broader rule — measured + * against objectui at the `.objectui-sha` pin `31971ff1e28f` + * (`packages/plugin-form/src/deriveMasterDetail.ts`, `deriveFormFields`). + * Every child field, in the field map's own order, except: + * + * - a name in {@link INLINE_GRID_SYSTEM_FIELDS} or + * {@link INLINE_GRID_SORT_FIELDS} — the same two sets the grid skips; + * - the relationship field back to the parent, and any name in `exclude`; + * - a field flagged `system` or `hidden` — NOT `readonly`: the form shows a + * read-only value, where a cell would only waste the width; + * - a field whose `type` is in {@link INLINE_ROW_FORM_NON_INPUT_TYPES}, the + * computed types nobody types into. `richtext`, `json`, `markdown` and the + * other types a cell cannot edit stay in. + * + * Every type the form skips the grid skips too, so with the same + * `relationshipField` and `exclude`, the derived grid's columns are always a + * subset of the derived form's fields. + * + * The form is not always offered ({@link isInlineRowFormOffered}). Measured in + * the same pin's `MasterDetailForm.tsx`, it is offered when it adds something: + * always when the collection's form factor is `form` (the row form IS the + * editor there), and otherwise only when the form has more fields than the + * grid has columns. A thin grid whose columns already cover every field shows + * no expand control. */ /** Default-visible column budget of a derived inline grid; the rest are `defaultHidden`. */ @@ -209,3 +239,60 @@ export function deriveInlineGridColumns( } return candidates.map(({ name }) => (visible.has(name) ? { name } : { name, defaultHidden: true })); } + +/** + * Field types the per-row expand form leaves out: the computed, server-derived + * values nobody types. Narrower than {@link INLINE_GRID_NON_EDITABLE_TYPES} — + * the form has room for the rich inputs a cell cannot hold. As there, the + * names that are not `FieldType` members are the renderer's legacy tolerances. + */ +const INLINE_ROW_FORM_NON_INPUT_TYPES: ReadonlySet = new Set([ + 'formula', 'summary', 'rollup', 'autonumber', 'auto_number', +]); + +/** + * Derive the fields of an inline master-detail grid's per-row expand form + * from the child object's definition (or any bare record shaped like one: + * `{ fields }` with the field map the spec declares). The rule is in the + * module note; the renderer offers the form only when + * {@link isInlineRowFormOffered} says so. + * + * `relationshipField` is the child's field back to the parent — excluded, as + * in {@link deriveInlineGridColumns}. `exclude` drops further names. + * + * Returns `[]` when the definition carries no field map. + */ +export function deriveInlineRowFormFields( + def: unknown, + opts: { relationshipField?: string; exclude?: readonly string[] } = {}, +): string[] { + const fields = prop(def, 'fields'); + if (!fields || typeof fields !== 'object') return []; + const exclude = new Set([...(opts.exclude ?? []), ...(opts.relationshipField ? [opts.relationshipField] : [])]); + + const out: string[] = []; + 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, 'hidden')) continue; + if (INLINE_ROW_FORM_NON_INPUT_TYPES.has(prop(field, 'type'))) continue; + out.push(name); + } + return out; +} + +/** + * Whether an inline child collection offers its per-row expand form: always + * when its form factor is `form`, else only when the form has more fields + * than the grid has columns. + * + * `inlineMode` is the collection's RESOLVED form factor (`grid` / `form`), as + * the renderer resolved it. `formFields` and `columns` are the lists the + * collection draws, authored or derived; only their lengths are read. + */ +export function isInlineRowFormOffered(opts: { + inlineMode?: 'grid' | 'form'; + formFields?: readonly unknown[]; + columns?: readonly unknown[]; +}): boolean { + return opts.inlineMode === 'form' || (opts.formFields?.length ?? 0) > (opts.columns?.length ?? 0); +}