Skip to content

Commit 405e80f

Browse files
committed
fix(lint): field-no-consumers reads an inline grid column's name as the child object's field
`name` stays a LITERAL_KEYS literal in general. At an inline grid column it is read as a reference, against the child object its carrier resolves: - a relationship field's `inlineColumns`: the object that declares the field (the child; its `reference` is the parent), and only as a display site when the field sets `inlineEdit`, otherwise as a carrier; - a form view's `subforms[].columns` (on `form` and every `formViews` entry): the entry's `childObject`. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1571aed commit 405e80f

1 file changed

Lines changed: 83 additions & 4 deletions

File tree

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

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
* hook or action body, a dataset dimension or measure, a widget filter, a
4141
* sharing-rule condition.
4242
* - **display** — the field is drawn: a view column, a form section, a page
43-
* binding, `highlightFields`, `searchableFields`, an index.
43+
* binding, an inline grid column ({@link creditInlineGridColumns}),
44+
* `highlightFields`, `searchableFields`, an index.
4445
* - **carrier** — the field is merely carried along: a translation label, a
4546
* seed value, an import-mapping column, a field-level permission grant, a
4647
* flow's WRITE of the field, prose that names it. These are what a REMOVAL
@@ -271,13 +272,27 @@ const WRITE_KEYS: ReadonlySet<string> = new Set([
271272
'fields', 'values', 'set', 'record', 'data', 'input', 'defaults', 'records',
272273
]);
273274

275+
/**
276+
* [#20929] Keys whose array entries are INLINE CHILD COLLECTIONS: each entry
277+
* names its child object in `childObject` and the child's grid in `columns`.
278+
* Today that is a form view's `subforms` (`FormViewSchema.subforms`, on a view
279+
* container's `form` and on every `formViews` entry), whose `columns` is the
280+
* same `InlineGridColumnSchema` a relationship field's `inlineColumns` takes.
281+
*/
282+
const CHILD_COLLECTION_KEYS: ReadonlySet<string> = new Set(['subforms']);
283+
274284
/**
275285
* Keys whose value is a literal from some other vocabulary, never a field
276286
* name. Without this list `type: 'summary'` on a roll-up reads as a reference
277287
* to a field named `summary`, and `accept: ['image/png']` as one to `image`.
278288
* `source` is deliberately ABSENT: it is the text of a CEL envelope
279289
* (`{ language: 'cel', source: 'record.quantity * record.unit_price' }`), and
280290
* skipping it read every tagged-template formula as reading nothing.
291+
*
292+
* `name` is a literal here and stays one: it is the identity of nearly every
293+
* record in a stack. The one position where it names a field is an inline
294+
* grid column, and that position is read on its own, against the child object,
295+
* by {@link creditInlineGridColumns}, never by dropping `name` from this set.
281296
*/
282297
const LITERAL_KEYS: ReadonlySet<string> = new Set([
283298
'type', 'reference', 'accept', 'provider', 'dialect', 'operator', 'aggregate', 'mode',
@@ -457,6 +472,49 @@ function scanText(
457472
}
458473
}
459474

475+
/**
476+
* [#20929] Credit the fields an inline grid's columns name, on the CHILD object.
477+
*
478+
* A column's `name` is the child field the grid reads and writes on every row
479+
* (`InlineGridColumnSchema.name`), and the recommended entry is identity-only
480+
* (`{ name: 'quantity' }`), so the name is often all a column says. The
481+
* general walk skips `name` as a {@link LITERAL_KEYS} literal, which made every
482+
* field a grid draws read as inert. This is the one position where `name` is
483+
* read as a reference, and only against the child object its carrier resolves:
484+
*
485+
* - a relationship field's `inlineColumns`: the field sits ON the child and
486+
* its `reference` names the PARENT, whose form draws the grid. So the child
487+
* is the object that DECLARES the field, not the related one. objectui's
488+
* `attachInlineSubforms` builds `{ childObject: <declaring object>,
489+
* columns: inlineColumns }`, and `collectHydratedInlineColumnErrors` in
490+
* `stack.zod.ts` resolves the carrier the same way.
491+
* - a child collection's `columns` ({@link CHILD_COLLECTION_KEYS}): the child
492+
* is the entry's `childObject`. The object the enclosing view is bound to
493+
* is the parent, so the context the walk carries is the wrong one here.
494+
*
495+
* A name the child does not declare is counted unresolved, like every other
496+
* token that looks like a field and lands on no object.
497+
*/
498+
function creditInlineGridColumns(
499+
ledger: ConsumerLedger,
500+
columns: unknown,
501+
childObject: string | undefined,
502+
kind: SiteKind,
503+
root: string,
504+
columnsPath: string,
505+
): void {
506+
if (!Array.isArray(columns)) return;
507+
columns.forEach((column: unknown, i: number) => {
508+
const field = isRec(column) ? strName(column.name) : undefined;
509+
if (field === undefined || !ledger.objectsByField.has(field)) return;
510+
if (ledger.declares(childObject, field)) {
511+
ledger.record(childObject, field, { root, path: `${columnsPath}[${i}].name`, kind });
512+
} else {
513+
ledger.unresolved += 1;
514+
}
515+
});
516+
}
517+
460518
/** The object context a record establishes for its own subtree, if any. */
461519
function contextOf(ledger: ConsumerLedger, rec: AnyRec, ctx: string | undefined): string | undefined {
462520
const named = (v: unknown): string | undefined => (ledger.isObject(v) ? v : undefined);
@@ -513,6 +571,11 @@ function walk(
513571
}
514572
const rec = node as AnyRec;
515573
const inner = contextOf(ledger, rec, ctx);
574+
// [#20929] An entry of a child collection: its grid draws `childObject`'s
575+
// 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`);
578+
}
516579
for (const [key, value] of Object.entries(rec)) {
517580
const childPath = `${path}.${key}`;
518581
const childSegments = [...segments, key];
@@ -574,6 +637,20 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj
574637
if (reference && displayField && ledger.declares(reference, displayField)) {
575638
ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' });
576639
}
640+
// [#20929] The grid's columns name fields of THIS object, the child. The
641+
// grid exists only where the field sets `inlineEdit`: the spec's help text
642+
// says `inlineColumns` is "used only when this field sets inlineEdit", and
643+
// objectui's `attachInlineSubforms` skips the field otherwise. Without it
644+
// the columns name the field and draw nothing, so they are a carrier a
645+
// removal must clean, not a consumer.
646+
creditInlineGridColumns(
647+
ledger,
648+
field.inlineColumns,
649+
objectName,
650+
field.inlineEdit ? 'display' : 'carrier',
651+
'objects',
652+
`${fieldPath}.inlineColumns`,
653+
);
577654
for (const [key, value] of Object.entries(field)) {
578655
if (FIELD_SELF_KEYS.has(key) || key === 'displayField') continue;
579656
walk(ledger, value, objectName, 'objects', `${fieldPath}.${key}`, [key], key);
@@ -720,10 +797,12 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] {
720797
path,
721798
message:
722799
`field "${field}" on object "${object}" is declared but nothing in this stack reads or displays ` +
723-
`it: no view column, form section, page binding, flow node, dataset, widget, formula, validation, ` +
724-
`hook or action names it, no declared field group places it on the synthesized layout, and no ` +
800+
`it: no view column, inline grid column, form section, page binding, flow node, dataset, widget, ` +
801+
`formula, validation, hook or action names it, no declared field group places it on the ` +
802+
`synthesized layout, and no ` +
725803
`seed or import mapping matches on it. A translation label, a seed value, an import-mapping ` +
726-
`target, a permission grant or a flow that only WRITES it is a carrier, not a consumer. ` +
804+
`target, a permission grant, a flow that only WRITES it, or an \`inlineColumns\` entry on a ` +
805+
`relationship field that does not set \`inlineEdit\` (no grid is drawn) is a carrier, not a consumer. ` +
727806
`${verdictClause}${sharedClause}`,
728807
hint:
729808
`Give "${field}" a consumer — a view column, a form section, a page binding, a formula, a ` +

0 commit comments

Comments
 (0)