Skip to content

Commit e3f28a1

Browse files
committed
feat(spec)!: record:line_items gets its ComponentPropsMap row; its columns are the inline grid column contract
The type leaves the string-arm registration ledger, which is now empty. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6843f37 commit e3f28a1

4 files changed

Lines changed: 331 additions & 21 deletions

File tree

‎packages/spec/src/inline-grid-column-carriers.test.ts‎

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* #20901 — an inline grid column is judged on BOTH of its carriers, and by the
55
* type it renders as. #20928 brought the THIRD carrier, an
66
* `object-master-detail-form` page block's `details[].columns`, to the same
7-
* contract (its own section at the bottom of this file).
7+
* contract (its own section near the bottom of this file), and #21142 the
8+
* FOURTH, a `record:line_items` page block's `columns` (the last section).
89
*
910
* The objectui master-detail grid reads one column shape from two carriers: a
1011
* relationship field's `inlineColumns` and a form view's `subforms[].columns`.
@@ -595,3 +596,158 @@ describe('#20928 — defineStack judges an identity-only detail column by the ty
595596
expect(() => build(stackWithMasterDetailColumns([{ name: 'amount' }]))).not.toThrow();
596597
});
597598
});
599+
600+
// ---------------------------------------------------------------------------
601+
// #21142 — the fourth carrier: a `record:line_items` block's `columns`.
602+
// ---------------------------------------------------------------------------
603+
604+
/**
605+
* The page block `record:line_items` draws ONE inline grid of the record's
606+
* child rows, through the same objectui grid as the other three carriers. It
607+
* had no `ComponentPropsMap` row, so the component-props gate skipped it and
608+
* the showcase project page's five `field`-keyed columns published green over
609+
* a grid of empty cells.
610+
*
611+
* One difference from the master-detail block is pinned on purpose: this
612+
* panel hands `columns` to the grid as authored, with no hydration from the
613+
* child object's field. So an identity-only column has no resolved type for
614+
* `defineStack` to judge, and the cross-reference check does not reach this
615+
* block — the last test below is that control.
616+
*/
617+
const LINE_ITEMS_PROPS = ComponentPropsMap['record:line_items'];
618+
619+
/** Every key objectui's `LineItemsPanel` reads off its schema (the `.objectui-sha` pin `31971ff1e28f`). */
620+
const FULL_LINE_ITEMS = {
621+
childObject: 'crm_invoice_line',
622+
relationshipField: 'invoice',
623+
columns: [{ name: 'quantity', label: 'Qty', type: 'number' }],
624+
parentObject: 'crm_invoice',
625+
parentId: 'inv_1',
626+
recordId: 'inv_1',
627+
amountField: 'amount',
628+
totalField: 'total',
629+
title: 'Lines',
630+
readonly: false,
631+
minRows: 1,
632+
maxRows: 20,
633+
filter: [{ field: 'quantity', operator: 'greater_than', value: 0 }],
634+
sort: [{ field: 'quantity', order: 'asc' }],
635+
limit: 100,
636+
} as const;
637+
638+
const parseLineItems = (props: unknown): Result => LINE_ITEMS_PROPS.safeParse(props) as Result;
639+
640+
const parseLineItemColumns = (columns: unknown[]): Result =>
641+
parseLineItems({ childObject: 'crm_invoice_line', relationshipField: 'invoice', columns });
642+
643+
describe('#21142 — the line-items block is strict, and its columns are the column contract', () => {
644+
it('its column element IS InlineGridColumnSchema — one contract, not a copy', () => {
645+
const shape = (LINE_ITEMS_PROPS as unknown as { shape: Record<string, { element: unknown }> }).shape;
646+
expect(shape.columns.element).toBe(InlineGridColumnSchema);
647+
});
648+
649+
it('refuses the card\'s `field`-keyed column by name, with the prescription naming `name`', () => {
650+
const result = parseLineItemColumns([{ field: 'title', label: 'Title', type: 'text' }]);
651+
expect(result.success).toBe(false);
652+
const unknown = result.error!.issues.find((i) => i.code === 'unrecognized_keys');
653+
expect(unknown?.path).toEqual(['columns', 0]);
654+
expect(unknown?.message).toContain('`field` → `name`');
655+
// The column's identity is missing too — the grid would bind nothing.
656+
expect(result.error!.issues.some((i) => i.code === 'invalid_type' && i.path.join('.') === 'columns.0.name')).toBe(true);
657+
});
658+
659+
it('refuses the typed currency column carrying `scale`, at the column\'s `scale`, with the ruled first sentence', () => {
660+
const result = parseLineItemColumns([{ name: 'quantity' }, TYPED_CURRENCY_WITH_SCALE]);
661+
expect(result.success).toBe(false);
662+
expect(result.error!.issues).toHaveLength(1);
663+
const [issue] = result.error!.issues;
664+
expect(issue.code).toBe('custom');
665+
expect(issue.path).toEqual(['columns', 1, 'scale']);
666+
expect(issue.message.startsWith(COLUMN_FIRST_SENTENCE)).toBe(true);
667+
});
668+
669+
it('refuses a bogus-key column: the key is named, and the missing `name` is required', () => {
670+
const result = parseLineItemColumns([BOGUS_KEY_ONLY]);
671+
expect(result.success).toBe(false);
672+
const unknown = result.error!.issues.find((i) => i.code === 'unrecognized_keys');
673+
expect(unknown?.path).toEqual(['columns', 0]);
674+
expect(unknown?.message).toContain('`zzz_not_a_key`');
675+
});
676+
677+
it('requires `relationshipField` and at least one column — nothing on this panel derives either', () => {
678+
const noFk = parseLineItems({ childObject: 'crm_invoice_line', columns: [{ name: 'quantity' }] });
679+
expect(noFk.success).toBe(false);
680+
expect(noFk.error!.issues.some((i) => i.code === 'invalid_type' && i.path.join('.') === 'relationshipField')).toBe(true);
681+
682+
const noColumns = parseLineItems({ childObject: 'crm_invoice_line', relationshipField: 'invoice' });
683+
expect(noColumns.success).toBe(false);
684+
expect(noColumns.error!.issues.some((i) => i.code === 'invalid_type' && i.path.join('.') === 'columns')).toBe(true);
685+
686+
const emptyColumns = parseLineItemColumns([]);
687+
expect(emptyColumns.success).toBe(false);
688+
expect(emptyColumns.error!.issues.some((i) => i.code === 'too_small' && i.path.join('.') === 'columns')).toBe(true);
689+
});
690+
691+
it('refuses a block key the renderer does not read, a near-miss with its rename, and a master-detail entry key with its reason', () => {
692+
const bogus = parseLineItems({ ...FULL_LINE_ITEMS, zzz_not_a_key: 1 });
693+
expect(bogus.success).toBe(false);
694+
const unknown = bogus.error!.issues.find((i) => i.code === 'unrecognized_keys');
695+
expect(unknown?.path).toEqual([]);
696+
expect(unknown?.message).toContain('`zzz_not_a_key`');
697+
698+
const alias = parseLineItems({ childObject: 'crm_invoice_line', foreignKey: 'invoice', columns: [{ name: 'quantity' }] });
699+
expect(alias.success).toBe(false);
700+
expect(alias.error!.issues.find((i) => i.code === 'unrecognized_keys')?.message).toContain('`foreignKey` → `relationshipField`');
701+
702+
const entryKey = parseLineItems({ ...FULL_LINE_ITEMS, addLabel: 'Add line' });
703+
expect(entryKey.success).toBe(false);
704+
const refused = entryKey.error!.issues.find((i) => i.code === 'unrecognized_keys');
705+
expect(refused?.message).toContain('`addLabel`');
706+
expect(refused?.message).toContain('object-master-detail-form');
707+
});
708+
709+
it('CONTROLS — the showcase\'s five name-keyed columns, and a bag carrying every key the renderer reads, parse and keep their keys', () => {
710+
// The showcase project page's block, copied (a spec test does not import an example app).
711+
const showcase = {
712+
childObject: 'showcase_task',
713+
relationshipField: 'project',
714+
amountField: 'estimate_hours',
715+
title: 'Tasks',
716+
columns: [
717+
{ name: 'title', label: 'Title', type: 'text', required: true },
718+
{ name: 'status', label: 'Status', type: 'select', options: [{ label: 'Backlog', value: 'backlog' }] },
719+
{ name: 'priority', label: 'Priority', type: 'select', options: [{ label: 'Low', value: 'low' }] },
720+
{ name: 'estimate_hours', label: 'Estimate (h)', type: 'number' },
721+
{ name: 'due_date', label: 'Due Date', type: 'date' },
722+
],
723+
};
724+
for (const props of [showcase, FULL_LINE_ITEMS]) {
725+
const result = parseLineItems(props);
726+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
727+
expect(result.data).toEqual(props);
728+
}
729+
expect(Object.keys(FULL_LINE_ITEMS)).toHaveLength(15);
730+
// `childObject` may come from the component-level `dataSource` binding instead.
731+
const viaBinding = Object.fromEntries(Object.entries(FULL_LINE_ITEMS).filter(([key]) => key !== 'childObject'));
732+
expect(parseLineItems(viaBinding).success).toBe(true);
733+
});
734+
735+
it('CONTROL — defineStack does not judge an identity-only line-items column: the panel draws it unhydrated', () => {
736+
const page = {
737+
name: 'crm_invoice_record',
738+
label: 'Invoice',
739+
type: 'record' as const,
740+
object: 'crm_invoice',
741+
regions: [{
742+
name: 'main',
743+
components: [{
744+
type: 'record:line_items',
745+
properties: { childObject: 'crm_invoice_line', relationshipField: 'invoice', columns: [IDENTITY_ONLY_WITH_SCALE] },
746+
}],
747+
}],
748+
};
749+
expect(() => build({ manifest, objects: [PARENT, childObject()], pages: [page] })).not.toThrow();
750+
// The same column under the master-detail block IS judged — the reach is the carrier's.
751+
expect(() => build(stackWithMasterDetailColumns([IDENTITY_ONLY_WITH_SCALE]))).toThrow();
752+
});
753+
});

‎packages/spec/src/ui/component-type-vocabulary.test.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,9 @@ describe('STRING_ARM_REGISTERED_TYPES ledger discipline', () => {
137137
// type — a ledger row there is dead weight.
138138
expect(hasReservedComponentNamespace(entry), entry).toBe(true);
139139
// An enum member or a map row is already known — a ledger row for one is
140-
// a grandfather clause pretending to be an exemption. When
141-
// `record:line_items` is measured into the map, this assertion forces
142-
// its ledger row OUT in the same PR.
140+
// a grandfather clause pretending to be an exemption. This assertion
141+
// forced `record:line_items`'s ledger row OUT in the change that measured
142+
// it into the map (#21142).
143143
expect(enumSet.has(entry), entry).toBe(false);
144144
expect(mapKeys.has(entry), entry).toBe(false);
145145
}
@@ -150,6 +150,23 @@ describe('STRING_ARM_REGISTERED_TYPES ledger discipline', () => {
150150
expect(isKnownComponentType(entry), entry).toBe(true);
151151
}
152152
});
153+
154+
/**
155+
* #21142 — the ledger's one entry was measured into the map, and the
156+
* discipline above forced it out. `record:line_items` stays KNOWN (the
157+
* `component-type-unknown` rule still accepts it) through its row now, and
158+
* it is still not an enum member: it reaches the type union through the open
159+
* string arm, as `element:metadata_viewer` does. With the ledger empty the
160+
* two loops above hold vacuously, which is why this pin names the type.
161+
*/
162+
it('record:line_items is known through its ComponentPropsMap row, not the ledger', () => {
163+
expect(STRING_ARM_REGISTERED_TYPES).not.toContain('record:line_items');
164+
expect(Object.keys(ComponentPropsMap)).toContain('record:line_items');
165+
expect(PageComponentType.options).not.toContain('record:line_items');
166+
expect(hasReservedComponentNamespace('record:line_items')).toBe(true);
167+
expect(isKnownComponentType('record:line_items')).toBe(true);
168+
expect(KNOWN_COMPONENT_TYPE_CANDIDATES).toContain('record:line_items');
169+
});
153170
});
154171

155172
describe('the namespace predicate leaves the open arm open', () => {

‎packages/spec/src/ui/component-type-vocabulary.ts‎

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,23 +49,21 @@ import { ComponentPropsMap } from './component.zod';
4949
* `REACT_OVERLAY_SHADOWS` house shape: an exemption is deliberate, evidenced,
5050
* and written down next to itself, or it does not exist.
5151
*
52-
* - `record:line_items` — objectui registers it (`plugin-form/src/index.tsx`,
53-
* the inline-editable child grid of objectui ADR-0001) and lists it as a
54-
* public block (`core/src/registry/public-blocks.ts`); the showcase authors
55-
* it (`examples/app-showcase/src/ui/pages/project-detail.page.ts`). Its
56-
* row-lessness in `ComponentPropsMap` is pinned deliberately in
57-
* `validate-component-props.test.ts` — a registered-but-unmeasured gap of the
58-
* record-blocks class, to be measured into the map by the renderer-read-point
59-
* method, not silently grandfathered by this file. When that row lands, the
60-
* vocabulary test beside this file forces this entry OUT (a ledger row for a
61-
* type the map declares is dead weight).
52+
* EMPTY since #21142. Its one entry, `record:line_items` (objectui's
53+
* inline-editable child grid, registered in `plugin-form/src/index.tsx`,
54+
* listed as a public block, authored by the showcase project page), was
55+
* measured into `ComponentPropsMap` by the renderer-read-point method, and a
56+
* ledger row for a type the map declares is dead weight — the vocabulary test
57+
* beside this file forced it OUT in the same change. The row-less state it
58+
* ledgered was not harmless: the props gate skipped the type as unregistered,
59+
* and the showcase's `field`-keyed columns published green over an empty grid.
6260
*
6361
* Growing this list is a contract decision, not a convenience: every entry is a
64-
* type the spec's own namespace claim cannot see, so each one needs the same
65-
* three-part evidence face as the entry above (registration, publication,
66-
* authorship) written into its comment.
62+
* type the spec's own namespace claim cannot see, so each one needs a
63+
* three-part evidence face (registration, publication, authorship) written
64+
* into its comment — and a gap measured into the map is the better answer.
6765
*/
68-
export const STRING_ARM_REGISTERED_TYPES: readonly string[] = ['record:line_items'];
66+
export const STRING_ARM_REGISTERED_TYPES: readonly string[] = [];
6967

7068
/**
7169
* The namespaces the enum itself populates — DERIVED, never restated, so a new
@@ -81,9 +79,9 @@ export const RESERVED_COMPONENT_TYPE_NAMESPACES: ReadonlySet<string> = new Set(
8179
* Every type string the spec answers for: the enum vocabulary, every
8280
* `ComponentPropsMap` row (which is a superset of the enum by exactly the
8381
* measured string-arm registrations that DID get a row — `element:metadata_viewer`,
84-
* the plugin console widgets, the `object-*` blocks — plus every type the
85-
* vocabulary RETIRED by name, whose row is kept on purpose so the readers that
86-
* dispatch on it keep recognising the name: `user:profile`, and the
82+
* `record:line_items`, the plugin console widgets, the `object-*` blocks — plus
83+
* every type the vocabulary RETIRED by name, whose row is kept on purpose so the
84+
* readers that dispatch on it keep recognising the name: `user:profile`, and the
8785
* retired-with-tombstones `element:filter` / `element:form`), and the
8886
* string-arm ledger above.
8987
*

0 commit comments

Comments
 (0)