|
40 | 40 | * hook or action body, a dataset dimension or measure, a widget filter, a |
41 | 41 | * sharing-rule condition. |
42 | 42 | * - **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. |
44 | 45 | * - **carrier** — the field is merely carried along: a translation label, a |
45 | 46 | * seed value, an import-mapping column, a field-level permission grant, a |
46 | 47 | * 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([ |
271 | 272 | 'fields', 'values', 'set', 'record', 'data', 'input', 'defaults', 'records', |
272 | 273 | ]); |
273 | 274 |
|
| 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 | + |
274 | 284 | /** |
275 | 285 | * Keys whose value is a literal from some other vocabulary, never a field |
276 | 286 | * name. Without this list `type: 'summary'` on a roll-up reads as a reference |
277 | 287 | * to a field named `summary`, and `accept: ['image/png']` as one to `image`. |
278 | 288 | * `source` is deliberately ABSENT: it is the text of a CEL envelope |
279 | 289 | * (`{ language: 'cel', source: 'record.quantity * record.unit_price' }`), and |
280 | 290 | * 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. |
281 | 296 | */ |
282 | 297 | const LITERAL_KEYS: ReadonlySet<string> = new Set([ |
283 | 298 | 'type', 'reference', 'accept', 'provider', 'dialect', 'operator', 'aggregate', 'mode', |
@@ -457,6 +472,49 @@ function scanText( |
457 | 472 | } |
458 | 473 | } |
459 | 474 |
|
| 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 | + |
460 | 518 | /** The object context a record establishes for its own subtree, if any. */ |
461 | 519 | function contextOf(ledger: ConsumerLedger, rec: AnyRec, ctx: string | undefined): string | undefined { |
462 | 520 | const named = (v: unknown): string | undefined => (ledger.isObject(v) ? v : undefined); |
@@ -513,6 +571,11 @@ function walk( |
513 | 571 | } |
514 | 572 | const rec = node as AnyRec; |
515 | 573 | 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 | + } |
516 | 579 | for (const [key, value] of Object.entries(rec)) { |
517 | 580 | const childPath = `${path}.${key}`; |
518 | 581 | const childSegments = [...segments, key]; |
@@ -574,6 +637,20 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj |
574 | 637 | if (reference && displayField && ledger.declares(reference, displayField)) { |
575 | 638 | ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' }); |
576 | 639 | } |
| 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 | + ); |
577 | 654 | for (const [key, value] of Object.entries(field)) { |
578 | 655 | if (FIELD_SELF_KEYS.has(key) || key === 'displayField') continue; |
579 | 656 | walk(ledger, value, objectName, 'objects', `${fieldPath}.${key}`, [key], key); |
@@ -720,10 +797,12 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] { |
720 | 797 | path, |
721 | 798 | message: |
722 | 799 | `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 ` + |
725 | 803 | `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. ` + |
727 | 806 | `${verdictClause}${sharedClause}`, |
728 | 807 | hint: |
729 | 808 | `Give "${field}" a consumer — a view column, a form section, a page binding, a formula, a ` + |
|
0 commit comments