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
12 changes: 12 additions & 0 deletions .changeset/20929-field-consumers-inline-grid-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/lint': patch
---

`field-no-consumers` no longer calls a field inert when an inline grid column names it

`os validate`, `os build` and `os lint` warned that a child object's field was inert ("no site of any kind names it") when the only thing naming it was an inline master-detail grid column, such as `{ name: 'quantity' }`. The warning told an author to delete a field the grid draws. A column's `name` is now read as a reference to the child object's field, on both carriers of the column:

- a relationship field's `inlineColumns`. The field sits on the child object and its `reference` names the parent, so the column names a field of the object that declares the relationship field. The grid is drawn only when that field sets `inlineEdit`. Without it, the columns draw nothing, and the field is reported `carrier-only` with the column listed as a site a removal must clean.
- a form view's `subforms[].columns`, on the view's `form` and on every `formViews` entry. The column names a field of the entry's `childObject`, not of the object the view is bound to.

`name` anywhere else is still a literal and never a field reference. The rule id, the `warning` severity and the finding's shape are unchanged. The message now also lists an inline grid column among the consumers, and an `inlineColumns` entry on a field without `inlineEdit` among the carriers.
80 changes: 80 additions & 0 deletions packages/lint/src/validate-field-consumers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,3 +589,83 @@ describe('[#19289] validateFieldConsumers — a `user` field displays a field on
expect(run).toThrow(/`reference` is an object/);
});
});

/**
* [#20929] An inline grid column's `name` names a field of the CHILD object.
*
* `name` is a `LITERAL_KEYS` literal, so the general walk never read a column's
* `name`, and the recommended identity-only column (`{ name: 'qty' }`) says
* nothing else. `os validate` then warned that a field the grid draws was
* inert. The fix reads `name` at that one position, against the child object
* each carrier resolves. It does not drop `name` from the literals.
*
* One fixture carries every assertion. `inv` is the parent and `line` the
* child, related by `line.invoice`. On `line`, `qty` is named only by the grid
* column under test, and `memo` is named nowhere: `memo` is the control, still
* reported. The parent declares a `qty` of its own that nothing reads, so it is
* reported too. A column credited to the wrong object shows up as `inv.qty`
* going quiet while `line.qty` stays reported.
*/
describe('[#20929] validateFieldConsumers — an inline grid column names a field of the CHILD object', () => {
const data = { provider: 'object', object: 'inv' };
const columns = [{ name: 'qty' }];

const stack = (relationship: AnyRec, view: AnyRec = {}, extra: AnyRec = {}): AnyRec => ({
objects: [
{ name: 'inv', fields: { name: { type: 'text' }, qty: { type: 'number' } } },
{
name: 'line',
fields: {
name: { type: 'text' },
invoice: { type: 'master_detail', reference: 'inv', ...relationship },
qty: { type: 'number' },
memo: { type: 'text' },
},
},
],
views: [{ list: { type: 'grid', data, columns: [{ field: 'name' }] }, ...view }],
...extra,
});

/** `object.field` → verdict, for every field the rule reports. */
const verdicts = (s: AnyRec): Record<string, string> =>
Object.fromEntries(validateFieldConsumers(s).map((f) => [`${f.object}.${f.field}`, f.verdict]));

/** The child's `qty` credited; the parent's `qty` and the control still reported. */
const CREDITED = { 'inv.qty': 'inert', 'line.memo': 'inert' };

it('baseline: with no grid anywhere, all three fields are reported inert', () => {
expect(verdicts(stack({}))).toEqual({ 'inv.qty': 'inert', 'line.qty': 'inert', 'line.memo': 'inert' });
});

it("a relationship field's `inlineColumns`: the child is the object that DECLARES the field, not the related one", () => {
expect(verdicts(stack({ inlineEdit: 'grid', inlineColumns: columns }))).toEqual(CREDITED);
});

it("a form view's `subforms[].columns`: the child is the entry's `childObject`, not the view's object", () => {
const form = { type: 'simple', data, subforms: [{ childObject: 'line', columns }] };
expect(verdicts(stack({}, { form }))).toEqual(CREDITED);
});

it("each `formViews` entry's `subforms[].columns`, the same way", () => {
const edit = { type: 'simple', data, subforms: [{ childObject: 'line', columns }] };
expect(verdicts(stack({}, { formViews: { edit } }))).toEqual(CREDITED);
});

it('`inlineColumns` on a field that does not set `inlineEdit` draws no grid: a carrier, listed for removal', () => {
const findings = validateFieldConsumers(stack({ inlineColumns: columns }));
expect(Object.fromEntries(findings.map((f) => [`${f.object}.${f.field}`, f.verdict]))).toEqual({
'inv.qty': 'inert',
'line.qty': 'carrier-only',
'line.memo': 'inert',
});
expect(findings.find((f) => f.object === 'line' && f.field === 'qty')?.carriers).toEqual([
'objects[1].fields.invoice.inlineColumns[0].name',
]);
});

it('`name` anywhere else stays a literal: a dataset measure named like the field credits nothing', () => {
const datasets = [{ name: 'line_stats', object: 'line', measures: [{ name: 'qty', aggregate: 'count' }] }];
expect(verdicts(stack({}, {}, { datasets }))).toEqual({ 'inv.qty': 'inert', 'line.qty': 'inert', 'line.memo': 'inert' });
});
});
87 changes: 83 additions & 4 deletions packages/lint/src/validate-field-consumers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
* hook or action body, a dataset dimension or measure, a widget filter, a
* sharing-rule condition.
* - **display** — the field is drawn: a view column, a form section, a page
* binding, `highlightFields`, `searchableFields`, an index.
* binding, an inline grid column ({@link creditInlineGridColumns}),
* `highlightFields`, `searchableFields`, an index.
* - **carrier** — the field is merely carried along: a translation label, a
* seed value, an import-mapping column, a field-level permission grant, a
* flow's WRITE of the field, prose that names it. These are what a REMOVAL
Expand Down Expand Up @@ -271,13 +272,27 @@ const WRITE_KEYS: ReadonlySet<string> = new Set([
'fields', 'values', 'set', 'record', 'data', 'input', 'defaults', 'records',
]);

/**
* [#20929] Keys whose array entries are INLINE CHILD COLLECTIONS: each entry
* names its child object in `childObject` and the child's grid in `columns`.
* Today that is a form view's `subforms` (`FormViewSchema.subforms`, on a view
* container's `form` and on every `formViews` entry), whose `columns` is the
* same `InlineGridColumnSchema` a relationship field's `inlineColumns` takes.
*/
const CHILD_COLLECTION_KEYS: ReadonlySet<string> = new Set(['subforms']);

/**
* 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
* to a field named `summary`, and `accept: ['image/png']` as one to `image`.
* `source` is deliberately ABSENT: it is the text of a CEL envelope
* (`{ language: 'cel', source: 'record.quantity * record.unit_price' }`), and
* skipping it read every tagged-template formula as reading nothing.
*
* `name` is a literal here and stays one: it is the identity of nearly every
* record in a stack. The one position where it names a field is an inline
* grid column, and that position is read on its own, against the child object,
* by {@link creditInlineGridColumns}, never by dropping `name` from this set.
*/
const LITERAL_KEYS: ReadonlySet<string> = new Set([
'type', 'reference', 'accept', 'provider', 'dialect', 'operator', 'aggregate', 'mode',
Expand Down Expand Up @@ -457,6 +472,49 @@ function scanText(
}
}

/**
* [#20929] Credit the fields an inline grid's columns name, on the CHILD object.
*
* A column's `name` is the child field the grid reads and writes on every row
* (`InlineGridColumnSchema.name`), and the recommended entry is identity-only
* (`{ name: 'quantity' }`), so the name is often all a column says. The
* general walk skips `name` as a {@link LITERAL_KEYS} literal, which made every
* field a grid draws read as inert. This is the one position where `name` is
* read as a reference, and only against the child object its carrier resolves:
*
* - a relationship field's `inlineColumns`: the field sits ON the child and
* its `reference` names the PARENT, whose form draws the grid. So the child
* is the object that DECLARES the field, not the related one. objectui's
* `attachInlineSubforms` builds `{ childObject: <declaring object>,
* columns: inlineColumns }`, and `collectHydratedInlineColumnErrors` in
* `stack.zod.ts` resolves the carrier the same way.
* - a child collection's `columns` ({@link CHILD_COLLECTION_KEYS}): the child
* is the entry's `childObject`. The object the enclosing view is bound to
* is the parent, so the context the walk carries is the wrong one here.
*
* A name the child does not declare is counted unresolved, like every other
* token that looks like a field and lands on no object.
*/
function creditInlineGridColumns(
ledger: ConsumerLedger,
columns: unknown,
childObject: string | undefined,
kind: SiteKind,
root: string,
columnsPath: string,
): void {
if (!Array.isArray(columns)) return;
columns.forEach((column: unknown, i: number) => {
const field = isRec(column) ? strName(column.name) : undefined;
if (field === undefined || !ledger.objectsByField.has(field)) return;
if (ledger.declares(childObject, field)) {
ledger.record(childObject, field, { root, path: `${columnsPath}[${i}].name`, kind });
} else {
ledger.unresolved += 1;
}
});
}

/** The object context a record establishes for its own subtree, if any. */
function contextOf(ledger: ConsumerLedger, rec: AnyRec, ctx: string | undefined): string | undefined {
const named = (v: unknown): string | undefined => (ledger.isObject(v) ? v : undefined);
Expand Down Expand Up @@ -513,6 +571,11 @@ function walk(
}
const rec = node as AnyRec;
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.
if (CHILD_COLLECTION_KEYS.has(leafKey)) {
creditInlineGridColumns(ledger, rec.columns, strName(rec.childObject), 'display', root, `${path}.columns`);
}
for (const [key, value] of Object.entries(rec)) {
const childPath = `${path}.${key}`;
const childSegments = [...segments, key];
Expand Down Expand Up @@ -574,6 +637,20 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj
if (reference && displayField && ledger.declares(reference, displayField)) {
ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' });
}
// [#20929] The grid's columns name fields of THIS object, the child. The
// grid exists only where the field sets `inlineEdit`: the spec's help text
// says `inlineColumns` is "used only when this field sets inlineEdit", and
// objectui's `attachInlineSubforms` skips the field otherwise. Without it
// the columns name the field and draw nothing, so they are a carrier a
// removal must clean, not a consumer.
creditInlineGridColumns(
ledger,
field.inlineColumns,
objectName,
field.inlineEdit ? 'display' : 'carrier',
'objects',
`${fieldPath}.inlineColumns`,
);
for (const [key, value] of Object.entries(field)) {
if (FIELD_SELF_KEYS.has(key) || key === 'displayField') continue;
walk(ledger, value, objectName, 'objects', `${fieldPath}.${key}`, [key], key);
Expand Down Expand Up @@ -720,10 +797,12 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] {
path,
message:
`field "${field}" on object "${object}" is declared but nothing in this stack reads or displays ` +
`it: no view column, form section, page binding, flow node, dataset, widget, formula, validation, ` +
`hook or action names it, no declared field group places it on the synthesized layout, and no ` +
`it: no view column, inline grid column, form section, page binding, flow node, dataset, widget, ` +
`formula, validation, hook or action names it, no declared field group places it on the ` +
`synthesized layout, and no ` +
`seed or import mapping matches on it. A translation label, a seed value, an import-mapping ` +
`target, a permission grant or a flow that only WRITES it is a carrier, not a consumer. ` +
`target, a permission grant, a flow that only WRITES it, or an \`inlineColumns\` entry on a ` +
`relationship field that does not set \`inlineEdit\` (no grid is drawn) is a carrier, not a consumer. ` +
`${verdictClause}${sharedClause}`,
hint:
`Give "${field}" a consumer — a view column, a form section, a page binding, a formula, a ` +
Expand Down
Loading