|
48 | 48 | * must clean up; none of them is evidence that anything reads the field. |
49 | 49 | * A seeded value nothing reads is precisely the shape being hunted. |
50 | 50 | * |
51 | | - * ## Two consumers that name the field nowhere |
| 51 | + * ## Three consumers that name the field nowhere |
52 | 52 | * |
53 | 53 | * A metadata reference is not the only way a field is read, and the first real |
54 | 54 | * 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: |
56 | 56 | * |
57 | 57 | * - **The synthesized layout** ({@link deriveFieldGroupLayout}, ADR-0085 §5). |
58 | 58 | * An object's `fieldGroups` plus a field's `group` membership are what the |
|
75 | 75 | * precisely so no real row can acquire one, and a consumer anywhere else |
76 | 76 | * would defeat it. Nothing here exempts `hidden` as such — a `hidden` field |
77 | 77 | * 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. |
78 | 86 | * |
79 | 87 | * A field with at least one behaviour OR display site is consumed and gets no |
80 | 88 | * finding — a field that is only drawn is the ordinary state of most fields |
|
120 | 128 | * maintainer's call, not this rule's. |
121 | 129 | */ |
122 | 130 |
|
123 | | -import { deriveFieldGroupLayout, resolveDisplayField } from '@objectstack/spec/data'; |
| 131 | +import { deriveFieldGroupLayout, deriveInlineGridColumns, resolveDisplayField } from '@objectstack/spec/data'; |
124 | 132 | import type { DisplayNameObjectMeta } from '@objectstack/spec/data'; |
125 | 133 | import { referenceTargetOf } from '@objectstack/spec/data'; |
126 | 134 | import { collectionEntries } from './collection-entries.js'; |
@@ -281,6 +289,18 @@ const WRITE_KEYS: ReadonlySet<string> = new Set([ |
281 | 289 | */ |
282 | 290 | const CHILD_COLLECTION_KEYS: ReadonlySet<string> = new Set(['subforms']); |
283 | 291 |
|
| 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 | + |
284 | 304 | /** |
285 | 305 | * Keys whose value is a literal from some other vocabulary, never a field |
286 | 306 | * name. Without this list `type: 'summary'` on a roll-up reads as a reference |
@@ -359,6 +379,8 @@ class ConsumerLedger { |
359 | 379 | readonly objectsByField = new Map<string, Set<string>>(); |
360 | 380 | /** dataset name → the object it reads */ |
361 | 381 | 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>>(); |
362 | 384 | /** `object.field` → sites */ |
363 | 385 | readonly sites = new Map<string, Site[]>(); |
364 | 386 | /** Tokens that looked like a field but resolved to no object — counted, never dropped. */ |
@@ -515,6 +537,72 @@ function creditInlineGridColumns( |
515 | 537 | }); |
516 | 538 | } |
517 | 539 |
|
| 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 | + |
518 | 606 | /** The object context a record establishes for its own subtree, if any. */ |
519 | 607 | function contextOf(ledger: ConsumerLedger, rec: AnyRec, ctx: string | undefined): string | undefined { |
520 | 608 | const named = (v: unknown): string | undefined => (ledger.isObject(v) ? v : undefined); |
@@ -573,10 +661,22 @@ function walk( |
573 | 661 | const inner = contextOf(ledger, rec, ctx); |
574 | 662 | // [#20929] An entry of a child collection: its grid draws `childObject`'s |
575 | 663 | // 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 | + } |
578 | 677 | } |
579 | 678 | for (const [key, value] of Object.entries(rec)) { |
| 679 | + if (childEntry && CHILD_ENTRY_FIELD_KEYS.has(key)) continue; |
580 | 680 | const childPath = `${path}.${key}`; |
581 | 681 | const childSegments = [...segments, key]; |
582 | 682 | // 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 |
651 | 751 | 'objects', |
652 | 752 | `${fieldPath}.inlineColumns`, |
653 | 753 | ); |
| 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 | + } |
654 | 769 | for (const [key, value] of Object.entries(field)) { |
655 | 770 | if (FIELD_SELF_KEYS.has(key) || key === 'displayField') continue; |
656 | 771 | walk(ledger, value, objectName, 'objects', `${fieldPath}.${key}`, [key], key); |
@@ -746,6 +861,9 @@ export function validateFieldConsumers(stack: AnyRec): FieldConsumerFinding[] { |
746 | 861 | const fieldName = strName(field.name); |
747 | 862 | if (!fieldName) continue; |
748 | 863 | ledger.declare(objectName, fieldName); |
| 864 | + let fieldMap = ledger.fieldMapByObject.get(objectName); |
| 865 | + if (!fieldMap) ledger.fieldMapByObject.set(objectName, (fieldMap = {})); |
| 866 | + fieldMap[fieldName] = field; |
749 | 867 | const exempt = injected.has(fieldName) || fieldName === titleField || field.type === 'master_detail'; |
750 | 868 | declared.push({ object: objectName, field: fieldName, path: fieldPath, exempt }); |
751 | 869 | } |
|
0 commit comments