Skip to content

Commit dcc5ef4

Browse files
fix(lint,spec): field-no-consumers reads an inline relationship's join key, its per-row expand form, a detail entry's formFields and a record:line_items block against the child (#21091) (#21256)
Fixes #21091 Clause-②: yes ## What changes `field-no-consumers` (`packages/lint/src/validate-field-consumers.ts`) called several kinds of in-use child field "inert". This PR corrects them. The per-row expand form goes through a new derivation the spec owns, as `deriveInlineGridColumns` (PR #21089) did for the grid. 1. **Position 1: a `lookup`'s inline-grid join key.** A `lookup` or `master_detail` field that sets `inlineEdit` (with a resolvable `reference`) is now recorded as a behaviour read at its `inlineEdit`, whether the grid's columns are authored or derived. The renderer loads the child rows filtered on it and stamps it on save (objectui `MasterDetailForm.tsx` 1321 and 552, at the `.objectui-sha` pin `31971ff1e28f`). `master_detail` was already exempt; `lookup` now reads the same. 2. **Position 2: the derived per-row expand form.** Two new `@objectstack/spec/data` exports live in `packages/spec/src/data/inline-grid-columns.ts`. They sit in the same module as `deriveInlineGridColumns` because they share its system-name and sort-name sets. - `deriveInlineRowFormFields(def, { relationshipField?, exclude? }): string[]` is objectui's `deriveFormFields` stated as the spec's rule. It skips the same names as the grid, plus the relationship field, `exclude`, `system` / `hidden` fields and the computed types (`formula`, `summary`, `rollup`, `autonumber`, `auto_number`). It keeps `readonly` fields and every type a cell cannot edit. - `isInlineRowFormOffered({ inlineMode?, formFields?, columns? }): boolean` is the renderer's offer condition at `MasterDetailForm.tsx:847`: `inlineMode === 'form'`, or more form fields than grid columns. - The lint credits the derived row form wherever it credits the derived grid: an inline relationship field with no authored `inlineColumns`, or a `subforms` / `details` entry with no `columns`. A `details` entry is excluded when it authors `formFields`, because an authored list replaces the derived one. No copy of objectui's rule lives in the lint. 3. **Position 3 (pointer `5936875973`): a detail entry's authored `formFields`.** These names are read against the entry's `childObject`; the general walk no longer reads them against the parent. `isInlineRowFormOffered` decides whether the list is drawn, and a list the form is never offered for is a carrier. The renderer resolves an entry one of two ways, and the lint feeds the predicate what each way feeds the expand control (round 2, F1): - **Kept as authored:** the entry names both `relationshipField` and at least one column (`MasterDetailForm.tsx` 967, 1048–1052). Nothing is derived. The form factor is the declared `inlineMode`, or none at all, so the predicate decides exactly. With an omitted `inlineMode`, the form is offered only when the list is longer than the grid. - **Derived:** anything else (1055–1066). A declared `inlineMode` is kept. An omitted one is resolved from the relationship's `inlineEdit`, else from the child's shape. The lint does not reproduce that resolution, so with an omitted mode the list is credited as drawn. With a declared mode, the predicate decides whenever the grid can be counted. 4. **Position 4 (pointer `5940763140`): a `record:line_items` block.** Its raw `properties` are read as one child entry: authored `columns[].name`, `relationshipField` and `amountField` against `childObject`, with `totalField` left on the parent. objectui `LineItemsPanel.tsx` at the pin reads these keys this way. It derives no grid and offers no row form. `RecordLineItemsProps` is not imported. **Round 2, flag B:** the block's `sort` and `filter` are now walked in the `childObject`'s context. `LineItemsPanel` applies them to the child query (366–379, 516–521). Since PR #21244 landed `RecordLineItemsProps`, the contract declares `filter` as the ViewFilterRule array. The panel's lowering also takes the field-keyed map, and the lint reads whichever is authored. Both forms are pinned. **Fixture triage (round 1).** Six tests in the `[#20951]` site-2 block pinned that a derived carrier leaves the `json` and `readonly` child fields inert. The derived row form now draws them, so their expected sets were re-judged: `DERIVED` keeps only the `hidden` field, and `NO_ROW_FORM` keeps the old set for the three cases that draw no derived row form. ## Round 2: the contract review `5942628181` (FAIL) and what this head does about it - **F1, fixed.** The round-1 lint credited an authored `formFields` list as drawn whenever `inlineMode` was omitted. On the kept-as-authored path that is false: the renderer leaves the mode undefined, and line 847's count decides. The lint now decides that path with `isInlineRowFormOffered({ inlineMode: undefined, formFields, columns })`. The docblock and test titles state both paths. The test's own fixture (`relationshipField` and two columns, one form field) now pins `itm.notes` as `carrier-only`. - **Flag B, measured and closed.** See position 4. The probe confirmed it: the three child fields read only by a block's `sort` / `filter` were inert, and the same-named parent fields were credited in their place. It is pinned with two enumeration rows (`sort[].field`, `filter[].field`) and three unit tests. - **Flag A, measured; not closed on this surface.** Reading below. ### Flag A: a row form opened with no field list This happens when an authored grid is in the `form` factor and has no `formFields`. That covers authored `inlineColumns` with `inlineEdit: 'form'`, or with `inlineEdit: true` and a child the smart default sends to `form`, and a detail entry kept as authored with `inlineMode: 'form'`. The renderer then opens the child's `ObjectForm` with no `fields` (`MasterDetailForm.tsx` 1821). That form draws the child's generated field set (`ObjectForm.tsx` 961) through `filterSystemFields` (`autoLayout.ts` 231): every field except the server-owned names, `hidden` fields and `readonly` fields, laid out by `fieldGroups` when the child declares any. **Probe reading (all three heads below):** `pg_line.note_g`, `ph_line.body_h`, `ph_line.note_h` and `pi_line.note_i` are reported inert, and the renderer draws them. `pg_line.ro_g` (`readonly`) and `pg_line.hid_g` (`hidden`) are reported inert, and the renderer does not draw them either. The reach is confirmed. **Why it does not close here:** 1. Crediting it needs a spec-owned statement of the default object form's field set: `ObjectForm`'s generated set, the server-owned roster from objectui `sanitize.ts`, the `hidden` and `readonly` filters, and the `fieldGroups` layout. That is a new cross-repo contract with its own differential and its own objectui consumer. 2. The `inlineEdit: true` arm also needs the smart default (`resolveInlineMode`: the form-only types, the two-rich-field threshold and the eight-field threshold) promoted to the spec. 3. It meets this rule's documented posture. The default layout is never a site (`creditFieldGroupLayout`: only a KEYED section counts), because the platform's default form draws every visible field of every object. The probe's own control `pa_order.buyer` is drawn by `pa_order`'s default form and reported by design. Crediting the same form when a parent opens it as a row editor makes the verdict depend on which door opens it. That is a decision about the rule's contract, not an omission in this diff. So the module note and a pinned boundary test state the position: an authored grid in the `form` factor with no `formFields` keeps those child fields reported. The enumeration pin's sentence now reads "the form the spec derives, and an authored `formFields` list the form is offered for". The position goes to a point card the seat files. The report carries the options. ## The spec functions against objectui's rule (round 1, unchanged) The differential ran the spec functions against `deriveFormFields` and line 847's expression, both read from the pinned files (`deriveMasterDetail.ts` blob `90aa44c9`, `MasterDetailForm.tsx` blob `7a96a130`). The offer expression was evaluated from the source text. - **`deriveInlineRowFormFields`: 100,004 cases, 0 mismatches.** The cases were objectui's 4 fixtures plus 100,000 random definitions: null and string field definitions, array-shaped `fields`, non-spec type names, truthy and falsy flags, prototype-ish names, and random `relationshipField` / `exclude`. - **`isInlineRowFormOffered`: 300,012 cases, 0 mismatches.** - **Subset property: 0 violations.** The derived grid is always a subset of the derived form. - **Lit control: 648 of 2,000 mismatches.** The same harness was run against a function that is not the rule, so the harness can fail. ## Evidence **The door: `os validate --json` on a `defineStack` probe stack.** Three heads were measured, each built from source: - `a7d9768e`, the card's base, in a separate worktree; - `1d1258a5`, the round-1 head; - `a87f03e1`, this head. All three were run with the same probe file (its `filter` blocks in the rule-array form). All three exit 0 with `valid: true`. `field-no-consumers` findings: 32, 17, 17. | field | a7d9768 | 1d1258a | a87f03e | position | |:--|:--|:--|:--|:--| | `pa_order_note.order` / `pa_ticket_line.ticket` / `pb_case_comment.case_ref` / `ph_line.header` (`lookup` + `inlineEdit`) | inert | — | — | 1 | | `pb_invoice_line.notes` / `.config` / `.frozen`, `pb_case_comment.body`, `pb_memo_line.long_note` | inert | — | — | 2 | | `pc_line.memo` (detail `formFields`, `inlineMode: 'form'`) | inert | — | — | 3 | | `pc_header.memo` (parent twin) | — | inert | inert | 3: was credited in the child's place | | `pd_line.memo2` (declared `grid`, 1 field vs 2 columns) | inert | carrier-only | carrier-only | 3 | | `pf_line.memo_f` (kept as authored, no `inlineMode`, 1 field vs 2 columns) | inert | — | carrier-only | 3, F1 | | `pe_line.qty_e` / `.note_e` / `.header` / `.amt` (`record:line_items` columns and keys) | inert | — | — | 4 | | `pe_header.amt` (parent twin) | — | inert | inert | 4 | | `pk_line.srt_k` / `.flt_k` / `.flt2_k` (block `sort`, two blocks' `filter`) | inert | inert | — | 4, flag B | | `pk_header.srt_k` / `.flt_k` (parent twins) | — | — | inert | 4, flag B: were credited in the child's place | | `pg_line.note_g`, `ph_line.body_h` / `.note_h`, `pi_line.note_i` (default form) | inert | inert | inert | flag A: not credited, see above | | `pg_line.ro_g` / `.hid_g` (`readonly` / `hidden`) | inert | inert | inert | flag A: not drawn either | | `pc_line.position` (detail `sortField`) | inert | inert | inert | no lint read; see notes | | `pa_order.buyer`, `pb_invoice_line.secret`, `pe_line.unused_e`, `pk_line.unused_k` | inert | inert | inert | controls | (— means not reported.) **A real producer: `examples/app-showcase`.** There are 52 findings at `a7d9768e` and 52 at `a87f03e1`, with identical verdict sets. PR #21244 changed its `record:line_items` page in between, and that block has no `sort` or `filter`. **Tests at `a87f03e1`** (the head of this PR): - `pnpm --filter @objectstack/lint exec vitest run`: 119 files, 5,572 tests passed. The `validate-field-consumers.test.ts` file has 126 tests, including the `[#21091]` block: positions 1 to 4, the flag-A boundary, and the enumeration pin's 13 rows, each paired with a control. - `pnpm --filter @objectstack/spec exec vitest run --project local`: 597 files, 17,483 passed and 1 todo. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 243 files, 3,439 passed, with the CLI closure built with declarations. The integration tier is declared to CI. - `pnpm --filter @objectstack/spec --filter @objectstack/lint run typecheck`: both exit 0, and `check:test-typecheck` is OK for both. - Filter direction: `@objectstack/spec`, `@objectstack/lint`, and the downstream lint consumer `@objectstack/cli`. **Reverse verification and ablations.** Each was committed first, made through `scripts/ablation-replace.mjs` or a blob restore, and restored to the HEAD blob with `git diff HEAD` empty. All were predicted red, and all were red. - Round 1: the lint source restored to the base blob `3efd1236` failed 29 of 115 tests. The spec row form made to drop `readonly` failed 2 of 20. - Round 2, at `a87f03e1`, flag B: the panel's `sort` / `filter` read switched off failed exactly the 5 flag-B tests (3 tests and 2 pin rows). - Round 2, at `a87f03e1`, F1: the kept-as-authored decision switched off failed exactly the F1 carrier test. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 8 paths and 86 commands at `a87f03e1`. Every one was run. `--ran` reports "86 derived, 86 run, 0 NOT-MEASURED, 0 UNRUN", and all 86 exited 0. `check:generated`: all 15 artefacts are up to date. The two spec shards gain exactly the two names each. **Base.** `origin/main` moved under generated files three times and was merged each time through `scripts/pm/os-regen-merge.sh`: at `1d1258a5`, `ee505255` and `6084ce01`. The last merge brought PR #21244's `RecordLineItemsProps`. No merge owed a regeneration, and the delta against `origin/main` is exactly this PR's 8 paths. Since then, `origin/main` has moved by 4 commits, none of which touches a generated artefact or one of the 8 paths. ## Acceptance notes - **Exports.** There are two new names, both functions: `deriveInlineRowFormFields` and `isInlineRowFormOffered`. No schema accepts or refuses anything new. - **For the objectui ④ child:** - `deriveFormFields(childSchema, opts)` equals `deriveInlineRowFormFields(childSchema, opts)` on every measured input. - Line 847's expression equals `isInlineRowFormOffered({ inlineMode: d.inlineMode, formFields: d.formFields, columns: d.columns })`. - The verdicts are above. - **`sortField` (pointer position 3), probe reading.** `pc_line.position` is inert at all three heads. At the pin the renderer only stamps it (`GridField.tsx:735`). It loads rows with `$filter` and `$top` and no ordering, so it never reads the field. objectui `0a3e5409f` retired the authored key after the pin, and no lint read was added. The general walk still reads `details[].sortField` against the parent. That reading leaves with the key at the next `.objectui-sha` bump. - **Flag A** goes to a point card the seat files. The pin sentence and a boundary test state what this PR covers. - **Kept as stated:** an omitted `inlineMode` on the DERIVED path (the renderer's smart default), and a derived grid with no `relationshipField`, both credit an authored list as drawn. - **"Not in this card"** stays out: the explicit `form.subforms` override, and a `subforms` entry with no `relationshipField`. - **Changeset.** `@objectstack/spec: minor`, because `Clause-②: yes` takes at least minor. `@objectstack/lint: patch` follows PR #21089 and PR #21215. The lint bullets now state the round-2 reads. The rule's message and hint text are unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4b09689 commit dcc5ef4

8 files changed

Lines changed: 824 additions & 24 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': patch
4+
---
5+
6+
`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).
7+
8+
Clause-②: yes (widening)
9+
10+
- **`@objectstack/spec`.** Two new exports from `@objectstack/spec/data`, beside `deriveInlineGridColumns`:
11+
- `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.
12+
- `isInlineRowFormOffered({ inlineMode?, formFields?, columns? })` is `true` when the form factor is `form`, or when the form has more fields than the grid has columns.
13+
- Both are the renderer's current rule, reproduced exactly. No schema accepts anything new or refuses anything new.
14+
- **`@objectstack/lint`.** `os validate` no longer warns that these fields are inert:
15+
- 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;
16+
- a field a derived inline grid's per-row expand form draws, through `deriveInlineRowFormFields`, such as a `readonly`, `richtext` or `json` child field;
17+
- 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;
18+
- a field named in a `record:line_items` block's `columns`, `relationshipField`, `amountField`, `sort` or `filter`, now read against the block's `childObject`.
19+
20+
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.

‎packages/lint/src/validate-field-consumers.test.ts‎

Lines changed: 346 additions & 7 deletions
Large diffs are not rendered by default.

‎packages/lint/src/validate-field-consumers.ts‎

Lines changed: 236 additions & 13 deletions
Large diffs are not rendered by default.

‎packages/spec/api-surface/data.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,7 @@
803803
"defineSeed (function)",
804804
"deriveFieldGroupLayout (function)",
805805
"deriveInlineGridColumns (function)",
806+
"deriveInlineRowFormFields (function)",
806807
"deriveRecordFlowSurface (function)",
807808
"deriveRecordSurface (function)",
808809
"describeManagedApiMethodConflicts (function)",
@@ -852,6 +853,7 @@
852853
"isGlobalUnique (function)",
853854
"isIncoherentAggregate (function)",
854855
"isInjectedColumnDefinition (function)",
856+
"isInlineRowFormOffered (function)",
855857
"isKnownFilterToken (function)",
856858
"isLegacyApiMethod (function)",
857859
"isMaskedOnReadFieldType (function)",

‎packages/spec/export-origins/data.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,7 @@
790790
"defineSeed": "src/data/seed.zod.ts#defineSeed (function)",
791791
"deriveFieldGroupLayout": "src/data/field-group-layout.ts#deriveFieldGroupLayout (function)",
792792
"deriveInlineGridColumns": "src/data/inline-grid-columns.ts#deriveInlineGridColumns (function)",
793+
"deriveInlineRowFormFields": "src/data/inline-grid-columns.ts#deriveInlineRowFormFields (function)",
793794
"deriveRecordFlowSurface": "src/data/record-surface.ts#deriveRecordFlowSurface (function)",
794795
"deriveRecordSurface": "src/data/record-surface.ts#deriveRecordSurface (function)",
795796
"describeManagedApiMethodConflicts": "src/data/managed-api-affordance.ts#describeManagedApiMethodConflicts (function)",
@@ -839,6 +840,7 @@
839840
"isGlobalUnique": "src/data/field.zod.ts#isGlobalUnique (function)",
840841
"isIncoherentAggregate": "src/data/aggregation-policy.ts#isIncoherentAggregate (function)",
841842
"isInjectedColumnDefinition": "src/data/injected-system-column-provenance.ts#isInjectedColumnDefinition (function)",
843+
"isInlineRowFormOffered": "src/data/inline-grid-columns.ts#isInlineRowFormOffered (function)",
842844
"isKnownFilterToken": "src/data/context-tokens.zod.ts#isKnownFilterToken (function)",
843845
"isLegacyApiMethod": "src/data/api-derivation.ts#isLegacyApiMethod (function)",
844846
"isMaskedOnReadFieldType": "src/data/masked-field-types.ts#isMaskedOnReadFieldType (function)",

‎packages/spec/src/data/index.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,8 +306,10 @@ export * from './search-fields';
306306
export * from './field-group-layout';
307307

308308
// Default inline-grid columns — the single source of which child fields an
309-
// inline master-detail grid draws when its author listed none. Consumed by the
310-
// renderer and credited by lint's `field-no-consumers`, so the two agree.
309+
// inline master-detail grid draws when its author listed none, and of which
310+
// fields its per-row expand form draws (and when that form is offered).
311+
// Consumed by the renderer and credited by lint's `field-no-consumers`, so the
312+
// two agree.
311313
export * from './inline-grid-columns';
312314

313315
// record-surface derivation (ADR-0085 §5) — the single source for how a record's

‎packages/spec/src/data/inline-grid-columns.test.ts‎

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { DEFAULT_MAX_INLINE_GRID_COLUMNS, deriveInlineGridColumns } from './inline-grid-columns';
4+
import {
5+
DEFAULT_MAX_INLINE_GRID_COLUMNS,
6+
deriveInlineGridColumns,
7+
deriveInlineRowFormFields,
8+
isInlineRowFormOffered,
9+
} from './inline-grid-columns';
510
import { InlineGridColumnSchema } from './field.zod';
611

712
/**
@@ -166,3 +171,123 @@ describe('deriveInlineGridColumns', () => {
166171
for (const col of cols) expect(InlineGridColumnSchema.parse(col)).toEqual(col);
167172
});
168173
});
174+
175+
/**
176+
* [#21091] The fields of an inline grid's per-row expand form. The first two
177+
* fixtures are the renderer's own `deriveFormFields` cases (objectui
178+
* `packages/plugin-form/src/deriveMasterDetail.test.ts` at the `.objectui-sha`
179+
* pin `31971ff1e28f`), asserted here as whole lists rather than as the
180+
* `toContain` probes they are there.
181+
*/
182+
describe('deriveInlineRowFormFields', () => {
183+
const taskSchema = {
184+
name: 'showcase_task',
185+
fields: {
186+
id: { type: 'text', system: true },
187+
title: { type: 'text', label: 'Title', required: true },
188+
status: { type: 'select', label: 'Status', options: [{ label: 'To Do', value: 'todo' }] },
189+
estimate_hours: { type: 'number', label: 'Estimate (h)' },
190+
budget: { type: 'currency', label: 'Budget' },
191+
due_date: { type: 'date', label: 'Due Date' },
192+
assignee: { type: 'lookup', label: 'Assignee', reference: 'user' },
193+
project: { type: 'master_detail', label: 'Project', reference: 'showcase_project', required: true },
194+
health: { type: 'formula', label: 'Health', expression: 'x' },
195+
created_at: { type: 'datetime' },
196+
},
197+
};
198+
199+
it('returns the business fields in field order, skipping system, audit, the relationship and computed types', () => {
200+
expect(deriveInlineRowFormFields(taskSchema, { relationshipField: 'project' })).toEqual([
201+
'title', 'status', 'estimate_hours', 'budget', 'due_date', 'assignee',
202+
]);
203+
});
204+
205+
it('keeps the rich input types the grid omits, and drops the computed ones', () => {
206+
const rich = {
207+
fields: {
208+
title: { type: 'text', required: true },
209+
parent: { type: 'master_detail', reference: 'p', required: true },
210+
notes: { type: 'textarea' },
211+
cover: { type: 'image' },
212+
attachment: { type: 'file' },
213+
total: { type: 'summary' },
214+
},
215+
};
216+
expect(deriveInlineRowFormFields(rich, { relationshipField: 'parent' })).toEqual(['title', 'notes', 'cover', 'attachment']);
217+
});
218+
219+
it('keeps `readonly` fields and every type a cell cannot edit; drops `system`, `hidden`, sort positions and `exclude`', () => {
220+
const def = {
221+
fields: {
222+
line_no: { type: 'number' },
223+
sort_order: { type: 'number' },
224+
frozen: { type: 'text', readonly: true },
225+
secret: { type: 'text', hidden: true },
226+
internal: { type: 'text', system: true },
227+
owner: { type: 'lookup', reference: 'sys_user' },
228+
body: { type: 'richtext' },
229+
meta: { type: 'json' },
230+
place: { type: 'location' },
231+
page: { type: 'html' },
232+
doc: { type: 'markdown' },
233+
seq: { type: 'autonumber' },
234+
roll: { type: 'rollup' },
235+
note: { type: 'text' },
236+
},
237+
};
238+
expect(deriveInlineRowFormFields(def, { exclude: ['note'] })).toEqual(['frozen', 'body', 'meta', 'place', 'page', 'doc']);
239+
});
240+
241+
it('without a relationship field, the relationship is an ordinary field', () => {
242+
expect(deriveInlineRowFormFields(taskSchema)).toContain('project');
243+
});
244+
245+
it('returns no fields for a definition with no field map', () => {
246+
expect(deriveInlineRowFormFields(undefined)).toEqual([]);
247+
expect(deriveInlineRowFormFields(null)).toEqual([]);
248+
expect(deriveInlineRowFormFields('line')).toEqual([]);
249+
expect(deriveInlineRowFormFields({ name: 'line' })).toEqual([]);
250+
});
251+
252+
it('the derived grid draws a subset of the derived form: the form has every column, in the same order', () => {
253+
const wide = {
254+
fields: {
255+
...taskSchema.fields,
256+
body: { type: 'richtext' },
257+
frozen: { type: 'number', readonly: true },
258+
...Object.fromEntries(Array.from({ length: 6 }, (_, i) => [`f${i}`, { type: 'text' }])),
259+
},
260+
};
261+
const opts = { relationshipField: 'project' };
262+
const form = deriveInlineRowFormFields(wide, opts);
263+
const columns = deriveInlineGridColumns(wide, opts).map((c) => c.name);
264+
expect(columns.length).toBeGreaterThan(0);
265+
expect(form.filter((name) => columns.includes(name))).toEqual(columns);
266+
expect(form.filter((name) => !columns.includes(name))).toEqual(['body', 'frozen']);
267+
});
268+
});
269+
270+
/**
271+
* [#21091] When an inline grid offers its per-row expand form — the condition
272+
* objectui's `MasterDetailForm` applies at the `.objectui-sha` pin
273+
* `31971ff1e28f` before it hands a row an expand control.
274+
*/
275+
describe('isInlineRowFormOffered', () => {
276+
it('always in the `form` factor: the row form IS the editor', () => {
277+
expect(isInlineRowFormOffered({ inlineMode: 'form', formFields: ['a'], columns: [{ name: 'a' }, { name: 'b' }] })).toBe(true);
278+
expect(isInlineRowFormOffered({ inlineMode: 'form' })).toBe(true);
279+
});
280+
281+
it('in the `grid` factor, only when the form has more fields than the grid has columns', () => {
282+
expect(isInlineRowFormOffered({ inlineMode: 'grid', formFields: ['a', 'b', 'c'], columns: [{ name: 'a' }, { name: 'b' }] })).toBe(true);
283+
expect(isInlineRowFormOffered({ inlineMode: 'grid', formFields: ['a', 'b'], columns: [{ name: 'a' }, { name: 'b' }] })).toBe(false);
284+
expect(isInlineRowFormOffered({ inlineMode: 'grid', formFields: ['a'], columns: [{ name: 'a' }, { name: 'b' }] })).toBe(false);
285+
});
286+
287+
it('with no form factor, the same count decides; an absent list counts as empty', () => {
288+
expect(isInlineRowFormOffered({ formFields: ['a'], columns: [] })).toBe(true);
289+
expect(isInlineRowFormOffered({ formFields: ['a'] })).toBe(true);
290+
expect(isInlineRowFormOffered({ columns: [{ name: 'a' }] })).toBe(false);
291+
expect(isInlineRowFormOffered({})).toBe(false);
292+
});
293+
});

‎packages/spec/src/data/inline-grid-columns.ts‎

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
/**
44
* Default inline-grid columns — the single source of WHICH child fields an
5-
* inline master-detail grid draws when its author listed no columns.
5+
* inline master-detail grid draws when its author listed no columns, and of
6+
* which fields its per-row expand form draws when its author listed none.
67
*
78
* Two carriers draw a grid of a child object's records inside the parent's
89
* form, and both say "derived from the child object when omitted":
@@ -54,6 +55,35 @@
5455
* renderer hydrates them from the child field, exactly as it hydrates an
5556
* identity-only column an author wrote. So the derived list is precisely the
5657
* `inlineColumns` an author could have written to draw the same grid.
58+
*
59+
* ## The per-row expand form ({@link deriveInlineRowFormFields})
60+
*
61+
* Each row of the grid can open a full form for that row, and that form draws
62+
* more than the grid: it has room for the rich inputs a cell cannot hold. Its
63+
* fields are derived from the child object too, by a broader rule — measured
64+
* against objectui at the `.objectui-sha` pin `31971ff1e28f`
65+
* (`packages/plugin-form/src/deriveMasterDetail.ts`, `deriveFormFields`).
66+
* Every child field, in the field map's own order, except:
67+
*
68+
* - a name in {@link INLINE_GRID_SYSTEM_FIELDS} or
69+
* {@link INLINE_GRID_SORT_FIELDS} — the same two sets the grid skips;
70+
* - the relationship field back to the parent, and any name in `exclude`;
71+
* - a field flagged `system` or `hidden` — NOT `readonly`: the form shows a
72+
* read-only value, where a cell would only waste the width;
73+
* - a field whose `type` is in {@link INLINE_ROW_FORM_NON_INPUT_TYPES}, the
74+
* computed types nobody types into. `richtext`, `json`, `markdown` and the
75+
* other types a cell cannot edit stay in.
76+
*
77+
* Every type the form skips the grid skips too, so with the same
78+
* `relationshipField` and `exclude`, the derived grid's columns are always a
79+
* subset of the derived form's fields.
80+
*
81+
* The form is not always offered ({@link isInlineRowFormOffered}). Measured in
82+
* the same pin's `MasterDetailForm.tsx`, it is offered when it adds something:
83+
* always when the collection's form factor is `form` (the row form IS the
84+
* editor there), and otherwise only when the form has more fields than the
85+
* grid has columns. A thin grid whose columns already cover every field shows
86+
* no expand control.
5787
*/
5888

5989
/** Default-visible column budget of a derived inline grid; the rest are `defaultHidden`. */
@@ -209,3 +239,60 @@ export function deriveInlineGridColumns(
209239
}
210240
return candidates.map(({ name }) => (visible.has(name) ? { name } : { name, defaultHidden: true }));
211241
}
242+
243+
/**
244+
* Field types the per-row expand form leaves out: the computed, server-derived
245+
* values nobody types. Narrower than {@link INLINE_GRID_NON_EDITABLE_TYPES} —
246+
* the form has room for the rich inputs a cell cannot hold. As there, the
247+
* names that are not `FieldType` members are the renderer's legacy tolerances.
248+
*/
249+
const INLINE_ROW_FORM_NON_INPUT_TYPES: ReadonlySet<unknown> = new Set([
250+
'formula', 'summary', 'rollup', 'autonumber', 'auto_number',
251+
]);
252+
253+
/**
254+
* Derive the fields of an inline master-detail grid's per-row expand form
255+
* from the child object's definition (or any bare record shaped like one:
256+
* `{ fields }` with the field map the spec declares). The rule is in the
257+
* module note; the renderer offers the form only when
258+
* {@link isInlineRowFormOffered} says so.
259+
*
260+
* `relationshipField` is the child's field back to the parent — excluded, as
261+
* in {@link deriveInlineGridColumns}. `exclude` drops further names.
262+
*
263+
* Returns `[]` when the definition carries no field map.
264+
*/
265+
export function deriveInlineRowFormFields(
266+
def: unknown,
267+
opts: { relationshipField?: string; exclude?: readonly string[] } = {},
268+
): string[] {
269+
const fields = prop(def, 'fields');
270+
if (!fields || typeof fields !== 'object') return [];
271+
const exclude = new Set<string>([...(opts.exclude ?? []), ...(opts.relationshipField ? [opts.relationshipField] : [])]);
272+
273+
const out: string[] = [];
274+
for (const [name, field] of Object.entries(fields as AnyRec)) {
275+
if (INLINE_GRID_SYSTEM_FIELDS.has(name) || exclude.has(name) || INLINE_GRID_SORT_FIELDS.has(name)) continue;
276+
if (prop(field, 'system') || prop(field, 'hidden')) continue;
277+
if (INLINE_ROW_FORM_NON_INPUT_TYPES.has(prop(field, 'type'))) continue;
278+
out.push(name);
279+
}
280+
return out;
281+
}
282+
283+
/**
284+
* Whether an inline child collection offers its per-row expand form: always
285+
* when its form factor is `form`, else only when the form has more fields
286+
* than the grid has columns.
287+
*
288+
* `inlineMode` is the collection's RESOLVED form factor (`grid` / `form`), as
289+
* the renderer resolved it. `formFields` and `columns` are the lists the
290+
* collection draws, authored or derived; only their lengths are read.
291+
*/
292+
export function isInlineRowFormOffered(opts: {
293+
inlineMode?: 'grid' | 'form';
294+
formFields?: readonly unknown[];
295+
columns?: readonly unknown[];
296+
}): boolean {
297+
return opts.inlineMode === 'form' || (opts.formFields?.length ?? 0) > (opts.columns?.length ?? 0);
298+
}

0 commit comments

Comments
 (0)