Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/21091-inline-row-form-join-key.md
Original file line number Diff line number Diff line change
@@ -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.
353 changes: 346 additions & 7 deletions packages/lint/src/validate-field-consumers.test.ts

Large diffs are not rendered by default.

249 changes: 236 additions & 13 deletions packages/lint/src/validate-field-consumers.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,7 @@
"defineSeed (function)",
"deriveFieldGroupLayout (function)",
"deriveInlineGridColumns (function)",
"deriveInlineRowFormFields (function)",
"deriveRecordFlowSurface (function)",
"deriveRecordSurface (function)",
"describeManagedApiMethodConflicts (function)",
Expand Down Expand Up @@ -852,6 +853,7 @@
"isGlobalUnique (function)",
"isIncoherentAggregate (function)",
"isInjectedColumnDefinition (function)",
"isInlineRowFormOffered (function)",
"isKnownFilterToken (function)",
"isLegacyApiMethod (function)",
"isMaskedOnReadFieldType (function)",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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)",
Expand Down
6 changes: 4 additions & 2 deletions packages/spec/src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 126 additions & 1 deletion packages/spec/src/data/inline-grid-columns.test.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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);
});
});
89 changes: 88 additions & 1 deletion packages/spec/src/data/inline-grid-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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`. */
Expand Down Expand Up @@ -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<unknown> = 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<string>([...(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);
}
Loading