Skip to content

Commit 0dd37c1

Browse files
committed
wip(spec,lint): deriveInlineGridColumns + field-no-consumers per-key child reads and derived grids
Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude <noreply@anthropic.com>
1 parent bb2eccf commit 0dd37c1

5 files changed

Lines changed: 673 additions & 5 deletions

File tree

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

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,3 +669,169 @@ describe('[#20929] validateFieldConsumers — an inline grid column names a fiel
669669
expect(verdicts(stack({}, {}, { datasets }))).toEqual({ 'inv.qty': 'inert', 'line.qty': 'inert', 'line.memo': 'inert' });
670670
});
671671
});
672+
673+
/**
674+
* [#20951] Two more positions where an inline child collection names a CHILD
675+
* field without the walk crediting it there.
676+
*
677+
* Site 1 — a `subforms` entry's child-field keys, read per key. `amountField`
678+
* ("Numeric child column summed for the running total") and
679+
* `relationshipField` ("FK on the child pointing back to the parent") name
680+
* fields of the entry's `childObject`; `totalField` ("Parent field to receive
681+
* the rolled-up sum") names a field of the PARENT. The walk carried the
682+
* parent's context into the entry, so the child's amount column read as inert
683+
* while a same-named parent field was credited in its place.
684+
*
685+
* Site 2 — a DERIVED inline grid. A relationship field with `inlineEdit` and
686+
* no `inlineColumns`, or a `subforms` entry with no `columns`, draws the
687+
* columns `deriveInlineGridColumns` (`@objectstack/spec/data`) derives from
688+
* the child object. The rule credits exactly those, and nothing the derivation
689+
* leaves out.
690+
*
691+
* `inv` is the parent, `line` the child. Each declares a field the other's
692+
* key names, so a key read against the wrong object shows up as the wrong
693+
* one of the pair going quiet.
694+
*/
695+
describe('[#20951] validateFieldConsumers — a child collection credits its child fields per key, and a derived grid its derived columns', () => {
696+
const data = { provider: 'object', object: 'inv' };
697+
const MASTER_DETAIL = { type: 'master_detail', reference: 'inv' };
698+
699+
const stack = (lineFields: AnyRec, view: AnyRec = {}, invoice: AnyRec = MASTER_DETAIL): AnyRec => ({
700+
objects: [
701+
{ name: 'inv', fields: { name: { type: 'text' }, total: { type: 'number' }, line_total: { type: 'number' } } },
702+
{ name: 'line', fields: { name: { type: 'text' }, invoice, ...lineFields } },
703+
],
704+
views: [{ list: { type: 'grid', data, columns: [{ field: 'name' }] }, ...view }],
705+
});
706+
707+
/** `object.field` → verdict, for every field the rule reports. */
708+
const verdicts = (s: AnyRec): Record<string, string> =>
709+
Object.fromEntries(validateFieldConsumers(s).map((f) => [`${f.object}.${f.field}`, f.verdict]));
710+
711+
describe('site 1: `amountField` and `relationshipField` on the child, `totalField` on the parent', () => {
712+
const lineFields = { qty: { type: 'number' }, line_total: { type: 'number' }, total: { type: 'number' }, memo: { type: 'text' } };
713+
const entry = (extra: AnyRec): AnyRec => ({ childObject: 'line', columns: [{ name: 'qty' }], ...extra });
714+
715+
it('baseline: with neither key, every field but the grid column is reported', () => {
716+
const form = { type: 'simple', data, subforms: [entry({})] };
717+
expect(verdicts(stack(lineFields, { form }))).toEqual({
718+
'inv.total': 'inert',
719+
'inv.line_total': 'inert',
720+
'line.line_total': 'inert',
721+
'line.total': 'inert',
722+
'line.memo': 'inert',
723+
});
724+
});
725+
726+
it("`amountField` credits the CHILD's field, `totalField` the PARENT's — each same-named counterpart stays reported", () => {
727+
const form = { type: 'simple', data, subforms: [entry({ amountField: 'line_total', totalField: 'total' })] };
728+
expect(verdicts(stack(lineFields, { form }))).toEqual({
729+
'inv.line_total': 'inert',
730+
'line.total': 'inert',
731+
'line.memo': 'inert',
732+
});
733+
});
734+
735+
it('each `formViews` entry, the same way', () => {
736+
const edit = { type: 'simple', data, subforms: [entry({ amountField: 'line_total', totalField: 'total' })] };
737+
expect(verdicts(stack(lineFields, { formViews: { edit } }))).toEqual({
738+
'inv.line_total': 'inert',
739+
'line.total': 'inert',
740+
'line.memo': 'inert',
741+
});
742+
});
743+
744+
it('`relationshipField` credits the child FK the rows are loaded and saved by', () => {
745+
const link = { type: 'lookup', reference: 'inv' };
746+
const without = { type: 'simple', data, subforms: [entry({ amountField: 'line_total', totalField: 'total' })] };
747+
const withKey = { type: 'simple', data, subforms: [entry({ amountField: 'line_total', totalField: 'total', relationshipField: 'link' })] };
748+
expect(verdicts(stack({ ...lineFields, link }, { form: without }))['line.link']).toBe('inert');
749+
expect(verdicts(stack({ ...lineFields, link }, { form: withKey }))['line.link']).toBeUndefined();
750+
});
751+
752+
it('control: each key is read against its own object, so swapped keys credit the swapped pair', () => {
753+
const form = { type: 'simple', data, subforms: [entry({ amountField: 'total', totalField: 'line_total' })] };
754+
// `amountField: 'total'` names the CHILD's `total`, `totalField:
755+
// 'line_total'` the PARENT's `line_total` — what the keys literally say.
756+
expect(verdicts(stack(lineFields, { form }))).toEqual({
757+
'inv.total': 'inert',
758+
'line.line_total': 'inert',
759+
'line.memo': 'inert',
760+
});
761+
});
762+
});
763+
764+
describe('site 2: a grid with no authored columns draws the derived ones', () => {
765+
/** Three the derivation draws, and three it leaves out: JSON, `hidden`, `readonly`. */
766+
const lineFields = {
767+
qty: { type: 'number' },
768+
line_total: { type: 'currency' },
769+
memo: { type: 'text' },
770+
blob: { type: 'json' },
771+
secret: { type: 'text', hidden: true },
772+
frozen: { type: 'number', readonly: true },
773+
};
774+
/** The parent's two fields (nothing reads them) and the three the derivation leaves out. */
775+
const DERIVED = {
776+
'inv.total': 'inert',
777+
'inv.line_total': 'inert',
778+
'line.blob': 'inert',
779+
'line.secret': 'inert',
780+
'line.frozen': 'inert',
781+
};
782+
783+
it('baseline: without `inlineEdit` no grid is drawn, and every child field is reported', () => {
784+
expect(verdicts(stack(lineFields))).toEqual({
785+
...DERIVED,
786+
'line.qty': 'inert',
787+
'line.line_total': 'inert',
788+
'line.memo': 'inert',
789+
});
790+
});
791+
792+
it.each([['grid'], ['form'], [true]])('`inlineEdit: %s` with no `inlineColumns` credits the derived columns, and only them', (inlineEdit) => {
793+
expect(verdicts(stack(lineFields, {}, { ...MASTER_DETAIL, inlineEdit }))).toEqual(DERIVED);
794+
});
795+
796+
it('an empty `inlineColumns` is no authored list: the grid is derived', () => {
797+
expect(verdicts(stack(lineFields, {}, { ...MASTER_DETAIL, inlineEdit: 'grid', inlineColumns: [] }))).toEqual(DERIVED);
798+
});
799+
800+
it('an authored `inlineColumns` replaces the derivation: only the named column is credited', () => {
801+
expect(verdicts(stack(lineFields, {}, { ...MASTER_DETAIL, inlineEdit: 'grid', inlineColumns: [{ name: 'qty' }] }))).toEqual({
802+
...DERIVED,
803+
'line.line_total': 'inert',
804+
'line.memo': 'inert',
805+
});
806+
});
807+
808+
it('`inlineEdit` on a field that is not a relationship draws no grid', () => {
809+
expect(verdicts(stack({ ...lineFields, tag: { type: 'text', inlineEdit: 'grid' } }))).toEqual({
810+
...DERIVED,
811+
'line.qty': 'inert',
812+
'line.line_total': 'inert',
813+
'line.memo': 'inert',
814+
'line.tag': 'inert',
815+
});
816+
});
817+
818+
it('a column the budget collapses into the chooser is credited too: it is drawn on demand, never dropped', () => {
819+
const wide = Object.fromEntries(Array.from({ length: 9 }, (_, i) => [`f${i}`, { type: 'text' }]));
820+
const findings = verdicts(stack(wide, {}, { ...MASTER_DETAIL, inlineEdit: 'grid' }));
821+
expect(findings).toEqual({ 'inv.total': 'inert', 'inv.line_total': 'inert' });
822+
});
823+
824+
it('a `subforms` entry with no `columns` credits the derived columns of its `childObject`', () => {
825+
const form = { type: 'simple', data, subforms: [{ childObject: 'line' }] };
826+
expect(verdicts(stack(lineFields, { form }))).toEqual(DERIVED);
827+
});
828+
829+
it("a `subforms` entry's derivation excludes the `relationshipField` it names, which that key credits instead", () => {
830+
const link = { type: 'lookup', reference: 'inv' };
831+
const form = { type: 'simple', data, subforms: [{ childObject: 'line', relationshipField: 'link' }] };
832+
const findings = validateFieldConsumers(stack({ ...lineFields, link }, { form }));
833+
expect(findings.find((f) => f.object === 'line' && f.field === 'link')).toBeUndefined();
834+
expect(Object.fromEntries(findings.map((f) => [`${f.object}.${f.field}`, f.verdict]))).toEqual(DERIVED);
835+
});
836+
});
837+
});

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

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,11 @@
4848
* must clean up; none of them is evidence that anything reads the field.
4949
* A seeded value nothing reads is precisely the shape being hunted.
5050
*
51-
* ## Two consumers that name the field nowhere
51+
* ## Three consumers that name the field nowhere
5252
*
5353
* A metadata reference is not the only way a field is read, and the first real
5454
* app to take this rule reported 12 fields that were all on screen or
55-
* load-bearing. Both paths are read off the SPEC, not off a hand-kept list:
55+
* load-bearing. All three paths are read off the SPEC, not off a hand-kept list:
5656
*
5757
* - **The synthesized layout** ({@link deriveFieldGroupLayout}, ADR-0085 §5).
5858
* An object's `fieldGroups` plus a field's `group` membership are what the
@@ -75,6 +75,14 @@
7575
* precisely so no real row can acquire one, and a consumer anywhere else
7676
* would defeat it. Nothing here exempts `hidden` as such — a `hidden` field
7777
* that no upsert matches on and nothing reads is still reported.
78+
* - **A derived inline grid** ({@link creditDerivedInlineGrid},
79+
* `deriveInlineGridColumns`). A relationship field that sets `inlineEdit`
80+
* with no `inlineColumns`, and a `subforms` entry with no `columns`, both
81+
* draw a grid of the child object whose columns are derived from the
82+
* child's fields. The derivation is the spec's, and it is the renderer's
83+
* rule reproduced exactly, so the rule credits exactly the columns it
84+
* returns — the hidden, readonly, system and non-editable fields it leaves
85+
* out stay uncredited.
7886
*
7987
* A field with at least one behaviour OR display site is consumed and gets no
8088
* finding — a field that is only drawn is the ordinary state of most fields
@@ -120,7 +128,7 @@
120128
* maintainer's call, not this rule's.
121129
*/
122130

123-
import { deriveFieldGroupLayout, resolveDisplayField } from '@objectstack/spec/data';
131+
import { deriveFieldGroupLayout, deriveInlineGridColumns, resolveDisplayField } from '@objectstack/spec/data';
124132
import type { DisplayNameObjectMeta } from '@objectstack/spec/data';
125133
import { referenceTargetOf } from '@objectstack/spec/data';
126134
import { collectionEntries } from './collection-entries.js';
@@ -281,6 +289,18 @@ const WRITE_KEYS: ReadonlySet<string> = new Set([
281289
*/
282290
const CHILD_COLLECTION_KEYS: ReadonlySet<string> = new Set(['subforms']);
283291

292+
/**
293+
* [#20951] Keys of a child collection entry whose value names a field of the
294+
* CHILD object, read per key against the entry's `childObject`. On a
295+
* `subforms` entry, `amountField` is "Numeric child column summed for the
296+
* running total" and `relationshipField` is "FK on the child pointing back to
297+
* the parent" (`FormViewSchema.subforms`). `totalField` is deliberately absent:
298+
* it is "Parent field to receive the rolled-up sum", so it stays on the object
299+
* the enclosing view is bound to, which is the context the walk already
300+
* carries. The read is per key, never a context switch for the whole entry.
301+
*/
302+
const CHILD_ENTRY_FIELD_KEYS: ReadonlySet<string> = new Set(['amountField', 'relationshipField']);
303+
284304
/**
285305
* Keys whose value is a literal from some other vocabulary, never a field
286306
* name. Without this list `type: 'summary'` on a roll-up reads as a reference
@@ -359,6 +379,8 @@ class ConsumerLedger {
359379
readonly objectsByField = new Map<string, Set<string>>();
360380
/** dataset name → the object it reads */
361381
readonly datasetObject = new Map<string, string>();
382+
/** object → its declared field map, the shape the spec's derivations read */
383+
readonly fieldMapByObject = new Map<string, Record<string, AnyRec>>();
362384
/** `object.field` → sites */
363385
readonly sites = new Map<string, Site[]>();
364386
/** Tokens that looked like a field but resolved to no object — counted, never dropped. */
@@ -515,6 +537,72 @@ function creditInlineGridColumns(
515537
});
516538
}
517539

540+
/**
541+
* Whether a carrier's authored columns are the grid's columns. The renderer
542+
* draws an authored list only when it has at least one entry; an absent or
543+
* empty list is derived from the child object instead.
544+
*/
545+
function hasAuthoredColumns(columns: unknown): boolean {
546+
return Array.isArray(columns) && columns.length > 0;
547+
}
548+
549+
/**
550+
* [#20951] Credit the fields a DERIVED inline grid draws, on the child object.
551+
*
552+
* An inline grid with no authored columns still draws columns: the ones
553+
* `deriveInlineGridColumns` (`@objectstack/spec/data`) derives from the child
554+
* object's declared fields. That function is the platform's own rule — the
555+
* renderer's column derivation, reproduced exactly — so this credits exactly
556+
* what it returns, the way
557+
* {@link creditFieldGroupLayout} credits `deriveFieldGroupLayout`. A column the
558+
* budget marks `defaultHidden` is credited too: it is collapsed into the
559+
* grid's column chooser, never dropped.
560+
*
561+
* `relationshipField` is the child's field back to the parent, which the grid
562+
* fills in and does not draw. A `subforms` entry that names none leaves the
563+
* renderer to detect it; this rule does not carry a second copy of that
564+
* detection, so the derived list it credits includes the relationship field.
565+
* That relationship is read either way: it is the key the child rows are
566+
* loaded and saved by.
567+
*/
568+
function creditDerivedInlineGrid(
569+
ledger: ConsumerLedger,
570+
childObject: string | undefined,
571+
relationshipField: string | undefined,
572+
root: string,
573+
path: string,
574+
): void {
575+
const fields = childObject === undefined ? undefined : ledger.fieldMapByObject.get(childObject);
576+
if (childObject === undefined || fields === undefined) return;
577+
for (const { name } of deriveInlineGridColumns({ fields }, { relationshipField })) {
578+
if (ledger.declares(childObject, name)) ledger.record(childObject, name, { root, path, kind: 'display' });
579+
}
580+
}
581+
582+
/**
583+
* [#20951] Credit one {@link CHILD_ENTRY_FIELD_KEYS} value of a child
584+
* collection entry, against the entry's `childObject`. A name the child does
585+
* not declare is counted unresolved, like every other field-shaped token that
586+
* lands on no object.
587+
*/
588+
function creditChildEntryField(
589+
ledger: ConsumerLedger,
590+
value: unknown,
591+
childObject: string | undefined,
592+
root: string,
593+
path: string,
594+
segments: readonly string[],
595+
key: string,
596+
): void {
597+
const field = strName(value);
598+
if (field === undefined || !ledger.objectsByField.has(field)) return;
599+
if (ledger.declares(childObject, field)) {
600+
ledger.record(childObject, field, { root, path, kind: bucketFor(root, segments, key) });
601+
} else {
602+
ledger.unresolved += 1;
603+
}
604+
}
605+
518606
/** The object context a record establishes for its own subtree, if any. */
519607
function contextOf(ledger: ConsumerLedger, rec: AnyRec, ctx: string | undefined): string | undefined {
520608
const named = (v: unknown): string | undefined => (ledger.isObject(v) ? v : undefined);
@@ -573,10 +661,22 @@ function walk(
573661
const inner = contextOf(ledger, rec, ctx);
574662
// [#20929] An entry of a child collection: its grid draws `childObject`'s
575663
// fields, whatever object the enclosing view is bound to.
576-
if (CHILD_COLLECTION_KEYS.has(leafKey)) {
577-
creditInlineGridColumns(ledger, rec.columns, strName(rec.childObject), 'display', root, `${path}.columns`);
664+
const childEntry = CHILD_COLLECTION_KEYS.has(leafKey);
665+
if (childEntry) {
666+
const childObject = strName(rec.childObject);
667+
creditInlineGridColumns(ledger, rec.columns, childObject, 'display', root, `${path}.columns`);
668+
// [#20951] With no authored columns, the grid draws the derived ones.
669+
if (!hasAuthoredColumns(rec.columns)) {
670+
creditDerivedInlineGrid(ledger, childObject, strName(rec.relationshipField), root, `${path}.childObject`);
671+
}
672+
// [#20951] The child-field keys, each read against the child — and only
673+
// there, which is why the loop below skips them.
674+
for (const key of CHILD_ENTRY_FIELD_KEYS) {
675+
creditChildEntryField(ledger, rec[key], childObject, root, `${path}.${key}`, [...segments, key], key);
676+
}
578677
}
579678
for (const [key, value] of Object.entries(rec)) {
679+
if (childEntry && CHILD_ENTRY_FIELD_KEYS.has(key)) continue;
580680
const childPath = `${path}.${key}`;
581681
const childSegments = [...segments, key];
582682
// A predicate map spells the field as its KEY (`{ is_active: true }`); a
@@ -651,6 +751,21 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj
651751
'objects',
652752
`${fieldPath}.inlineColumns`,
653753
);
754+
// [#20951] With `inlineEdit` and no authored `inlineColumns`, the parent's
755+
// form still draws a grid of THIS object: the derived columns. The grid
756+
// exists for a relationship to a parent, which is what objectui's
757+
// `attachInlineSubforms` requires before it builds one: a `master_detail`
758+
// or `lookup` field whose target resolves.
759+
const fieldName = strName(field.name);
760+
if (
761+
fieldName !== undefined &&
762+
field.inlineEdit &&
763+
(field.type === 'master_detail' || field.type === 'lookup') &&
764+
reference !== undefined &&
765+
!hasAuthoredColumns(field.inlineColumns)
766+
) {
767+
creditDerivedInlineGrid(ledger, objectName, fieldName, 'objects', `${fieldPath}.inlineEdit`);
768+
}
654769
for (const [key, value] of Object.entries(field)) {
655770
if (FIELD_SELF_KEYS.has(key) || key === 'displayField') continue;
656771
walk(ledger, value, objectName, 'objects', `${fieldPath}.${key}`, [key], key);
@@ -746,6 +861,9 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] {
746861
const fieldName = strName(field.name);
747862
if (!fieldName) continue;
748863
ledger.declare(objectName, fieldName);
864+
let fieldMap = ledger.fieldMapByObject.get(objectName);
865+
if (!fieldMap) ledger.fieldMapByObject.set(objectName, (fieldMap = {}));
866+
fieldMap[fieldName] = field;
749867
const exempt = injected.has(fieldName) || fieldName === titleField || field.type === 'master_detail';
750868
declared.push({ object: objectName, field: fieldName, path: fieldPath, exempt });
751869
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,11 @@ export * from './search-fields';
305305
// grouping semantics every renderer (form, detail, drawer, designer) applies.
306306
export * from './field-group-layout';
307307

308+
// 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.
311+
export * from './inline-grid-columns';
312+
308313
// record-surface derivation (ADR-0085 §5) — the single source for how a record's
309314
// create/edit/detail opens by default (full page vs drawer/modal overlay).
310315
export * from './record-surface';

0 commit comments

Comments
 (0)