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
28 changes: 28 additions & 0 deletions .changeset/24190-line-items-hydrated-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/spec': minor
---

fix(spec)!: `defineStack` judges a `record:line_items` column that declares no `type` as the type it renders, as it already does on the master-detail carriers (#24190)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (already-registered ui-record-line-items-props-closed) The step-18 entry for this block's columns now carries the identity-only reach in its surface, replacement, reason and acceptance criteria, as the master-detail block's entry does. No key, stored shape or authorable spelling is added, removed or renamed, and nothing stored changes, so `objectstack migrate meta` rewrites exactly what it rewrote before. -->

**BREAKING** — an accept-set narrowing of what `defineStack` and `objectstack validate` publish, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The console's `record:line_items` panel fills a column that declares no `type` from the child object's field, with the same rule as the master-detail grid. `defineStack`'s cross-reference check judged that resolved type on the three master-detail carriers and not on this block, so the same column got two answers.

**`@objectstack/spec`**

- **`defineStack`'s cross-reference check** now reaches a `record:line_items` block in a page region's components, in a container's component children and in a slot. A block under a `page:tabs` item's `children` is not yet judged. It judges a column that declares no `type` as the type it renders, and reports the column schema's own refusal (`STACK_CROSS_REFERENCE_INVALID`, 422). Today that means `scale` on an identity-only column over a `currency` field of the child object. The child object is the one the panel lists: the object `dataSource` binding's `object` when the node carries one, else `childObject`. A child object the stack does not declare, a column naming no field of it, and a column the column schema refuses on its own (left to the component-props gate) are not judged.
- The step-18 migration entry `ui-record-line-items-props-closed` now says so, and no longer says that this block draws a column exactly as declared.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `{ type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'amount', scale: 2 }] } }` where `amount` is a `currency` field of `invoice_line` | the same block with `columns: [{ name: 'amount' }]` |

The one-line fix: delete `scale` from a `record:line_items` column that renders as a currency column, whether it declares `type: 'currency'` or takes it from a `currency` child field. Nothing replaces `scale` there: the currency's ISO 4217 minor unit decides the displayed decimals.

## Who is affected, measured

On `origin/main` `ec749fbb44`: two authored `record:line_items` blocks in the examples (the showcase project detail page, five typed columns, its block under a `page:tabs` item, so not yet judged; the showcase invoice document page, four `{ name, label }` columns, two of them over `currency` fields) and one in the published skills (four `{ name }` columns). None carries `scale`, so no example, skill or fixture outside this change's own pins changes verdict. Deployed metadata was not measured.
171 changes: 149 additions & 22 deletions packages/spec/src/inline-grid-column-carriers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,11 +613,11 @@ describe('defineStack judges an identity-only detail column by the type it rende
* the showcase project page's five `field`-keyed columns published green over
* a grid of empty cells.
*
* One difference from the master-detail block is pinned on purpose: this
* panel hands `columns` to the grid as authored, with no hydration from the
* child object's field. So an identity-only column has no resolved type for
* `defineStack` to judge, and the cross-reference check does not reach this
* block — the last test below is that control.
* The panel hydrates an identity-only column from the child object's field
* through the same `hydrateColumns` the master-detail block calls (#24140,
* objectstack-ai/objectui#12854), so `defineStack` judges its resolved type as
* it does on the other three carriers (#24190): the last section below, whose
* first test holds the two page blocks to the same answer for the same column.
*/
const LINE_ITEMS_PROPS = ComponentPropsMap['record:line_items'];

Expand Down Expand Up @@ -737,22 +737,149 @@ describe('the line-items block is strict, and its columns are the column contrac
expect(parseLineItems(viaBinding).success).toBe(true);
});

it('CONTROL — defineStack does not judge an identity-only line-items column: the panel draws it unhydrated', () => {
const page = {
name: 'crm_invoice_record',
label: 'Invoice',
type: 'record' as const,
object: 'crm_invoice',
regions: [{
name: 'main',
components: [{
type: 'record:line_items',
properties: { childObject: 'crm_invoice_line', relationshipField: 'invoice', columns: [IDENTITY_ONLY_WITH_SCALE] },
}],
}],
};
expect(() => build({ manifest, objects: [PARENT, childObject()], pages: [page] })).not.toThrow();
// The same column under the master-detail block IS judged — the reach is the carrier's.
expect(() => build(stackWithMasterDetailColumns([IDENTITY_ONLY_WITH_SCALE]))).toThrow();
});

// ---------------------------------------------------------------------------
// #24190 — `defineStack` judges the fourth carrier's identity-only columns.
// ---------------------------------------------------------------------------

type LineItemsBlockOptions = {
/** The block's `childObject`; `null` leaves the key out. */
childObject?: string | null;
/** The node-level `dataSource` binding, when the block carries one. */
dataSource?: Record<string, unknown>;
};

const lineItemsBlock = (
columns: unknown[],
{ childObject: child = 'crm_invoice_line', dataSource }: LineItemsBlockOptions = {},
) => ({
type: 'record:line_items',
...(dataSource ? { dataSource } : {}),
properties: { ...(child === null ? {} : { childObject: child }), relationshipField: 'invoice', columns },
});

/** A stack whose one record page carries `block` in the given position. */
const stackWithLineItemsBlock = (block: Record<string, unknown>, position: PagePosition = 'region') => {
const page = {
name: 'crm_invoice_record',
label: 'Invoice',
type: 'record' as const,
object: 'crm_invoice',
regions: [{
name: 'main',
components: position === 'region' ? [block]
: position === 'nested' ? [{ type: 'page:card', properties: { children: [block] } }]
: [],
}],
...(position === 'slot' ? { kind: 'slotted', slots: { details: [block] } } : {}),
};
return { manifest, objects: [PARENT, childObject()], pages: [page] };
};

const stackWithLineItemColumns = (columns: unknown[], position: PagePosition = 'region') =>
stackWithLineItemsBlock(lineItemsBlock(columns), position);

const LINE_ITEMS_AT = "Page 'crm_invoice_record' (regions.0.components.0) record:line_items columns";
const MASTER_DETAIL_AT = "Page 'crm_invoice_entry' (regions.0.components.0) object-master-detail-form details[0].columns";

/** A finding with its location cut off: what the column and its child field make of it. */
const findingPast = (refusal: Refusal, location: string): string => {
const finding = String(refusal.issues![0]);
expect(finding.startsWith(location), finding).toBe(true);
return finding.slice(location.length);
};

describe('defineStack judges an identity-only line-items column by the type it renders as', () => {
it('PARITY — the line-items block and the master-detail block give the same answer for the same column', () => {
for (const columns of [[IDENTITY_ONLY_WITH_SCALE], [{ name: 'quantity' }, IDENTITY_ONLY_WITH_SCALE]]) {
const lineItems = refusalOf(stackWithLineItemColumns(columns));
const masterDetail = refusalOf(stackWithMasterDetailColumns(columns));
for (const refusal of [lineItems, masterDetail]) {
expect(refusal.code).toBe('STACK_CROSS_REFERENCE_INVALID');
expect(refusal.status).toBe(422);
expect(refusal.issues).toHaveLength(1);
}
expect(findingPast(lineItems, LINE_ITEMS_AT)).toBe(findingPast(masterDetail, MASTER_DETAIL_AT));
}
// …and the same column without the refused key builds under both.
expect(() => build(stackWithLineItemColumns([{ name: 'amount' }]))).not.toThrow();
expect(() => build(stackWithMasterDetailColumns([{ name: 'amount' }]))).not.toThrow();
});

it('refuses the card\'s identity-only column over a currency child field, naming the column and the child field\'s type', () => {
expectHydratedCurrencyRefusal(
stackWithLineItemColumns([{ name: 'quantity' }, IDENTITY_ONLY_WITH_SCALE]),
`${LINE_ITEMS_AT}[1].scale`,
);
});

it('refuses it inside a container and under a slot too — the other positions the page walk visits', () => {
expectHydratedCurrencyRefusal(
stackWithLineItemColumns([IDENTITY_ONLY_WITH_SCALE], 'nested'),
"Page 'crm_invoice_record' (regions.0.components.0.properties.children.0) record:line_items columns[0].scale",
);
expectHydratedCurrencyRefusal(
stackWithLineItemColumns([IDENTITY_ONLY_WITH_SCALE], 'slot'),
"Page 'crm_invoice_record' (slots.details.0) record:line_items columns[0].scale",
);
});

it('judges the column against the child object the renderer lists: an object `dataSource` binding wins over `childObject`', () => {
const bound = { object: 'crm_invoice_line' };
// The binding alone supplies the object.
expectHydratedCurrencyRefusal(
stackWithLineItemsBlock(lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { childObject: null, dataSource: bound })),
`${LINE_ITEMS_AT}[0].scale`,
);
// The binding is written over an authored `childObject` this stack does not declare.
expectHydratedCurrencyRefusal(
stackWithLineItemsBlock(lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { childObject: 'ext_line', dataSource: bound })),
`${LINE_ITEMS_AT}[0].scale`,
);
// The other direction: the panel lists the bound object, which this stack
// does not declare, so the declared `childObject` is not the column's child.
expect(() => build(stackWithLineItemsBlock(
lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { dataSource: { object: 'ext_line' } }),
))).not.toThrow();
});

it('judges it against `childObject` under an API binding, which names no object', () => {
const api = { provider: 'api', read: { url: '/api/v1/cloud/home' } };
expectHydratedCurrencyRefusal(
stackWithLineItemsBlock(lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { dataSource: api })),
`${LINE_ITEMS_AT}[0].scale`,
);
});

it('leaves a column that is invalid on its own to the props half: no finding the resolved type does not bring', () => {
const column = { ...IDENTITY_ONLY_WITH_SCALE, zzz_not_a_key: 1 };
expect(parseLineItemColumns([column]).success).toBe(false);
expect(() => build(stackWithLineItemColumns([column]))).not.toThrow();
});

it('CONTROLS — valid columns build, and so do the typed and bogus-key columns, which only the props half reads', () => {
const columns = [
{ name: 'quantity', scale: 1 },
{ name: 'amount' },
{ name: 'amount', prefix: 'US$' },
{ name: 'amount', type: 'number', scale: 2 },
{ name: 'not_a_child_field', scale: 2 },
];
expect(() => build(stackWithLineItemColumns(columns))).not.toThrow();
expect(() => build(stackWithLineItemColumns([TYPED_CURRENCY_WITH_SCALE, BOGUS_KEY_ONLY]))).not.toThrow();
});

it('CONTROL — an unresolved child object is not judged: one this stack does not declare, or none at all', () => {
expect(() => build(stackWithLineItemsBlock(
lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { childObject: 'ext_line' }),
))).not.toThrow();
expect(() => build(stackWithLineItemsBlock(
lineItemsBlock([IDENTITY_ONLY_WITH_SCALE], { childObject: null }),
))).not.toThrow();
});

it('following the remedy builds: the refused column with `scale` deleted and nothing added', () => {
expect(() => build(stackWithLineItemColumns([{ name: 'amount' }]))).not.toThrow();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,26 @@ import type { SemanticMigration } from '../../types.js';
// never refused at load (its next save meets the component-props gate at the
// metadata save door) and there is no load-path refusal for a conversion to
// pre-empt; the one `field`-keyed producer the census found is respelled in the
// same change.
// same change. #24190 — once the panel hydrated an identity-only column from the
// child field, `defineStack`'s identity-only check reached this carrier too, the
// reach `inline-grid-column-identity-only-currency-scale-refused` records for
// the relationship field and the form view.
export const entry: SemanticMigration = {
id: 'ui-record-line-items-props-closed',
surface: 'page `record:line_items` components — `properties` (which used to accept any key) and '
+ '`properties.columns[]` (its inline grid columns)',
+ '`properties.columns[]` (its inline grid columns), including `scale` on a column that '
+ 'declares no `type` and whose `name` is a `currency` field of the block\'s child object',
replacement: 'the declared shape the renderer reads: `{ childObject?, relationshipField, columns, '
+ 'parentObject?, parentId?, recordId?, amountField?, totalField?, title?, readonly?, minRows?, '
+ 'maxRows?, filter?, sort?, limit? }`, with `filter` the ViewFilterRule array, `sort` the SortItem '
+ 'array and `limit` a positive integer; `childObject` may come from the component-level '
+ '`dataSource` binding instead. `columns` is required and holds at least one column, each the '
+ 'strict, name-keyed inline grid column a relationship field\'s `inlineColumns` takes — '
+ '`{ name, label?, type?, options?, … }`. Write `name` where a column said `field` (or '
+ '`fieldName`, `key`); declare `label`, `type` and `options` on the column, because this block '
+ 'draws a column exactly as declared and hydrates nothing from the child object\'s field; '
+ 'delete `scale` from a column declaring `type: \'currency\'`; delete `addLabel`, `formFields` '
+ '`{ name, label?, type?, options?, … }`, where `{ name }` alone hydrates the rest from the '
+ 'child object\'s field. Write `name` where a column said `field` (or `fieldName`, `key`); '
+ 'delete `scale` from a column that renders as a currency column, whether it declares '
+ '`type: \'currency\'` or takes it from a `currency` child field — nothing replaces it, the '
+ 'currency\'s ISO 4217 minor unit decides; delete `addLabel`, `formFields` '
+ 'and `inlineMode`, which belong to an `object-master-detail-form` detail entry and are not read '
+ 'here, `sortField`, which no block takes (the detail entry derives the line-position field from '
+ 'the child object), and any other key the shape does not declare.',
Expand All @@ -43,19 +48,32 @@ export const entry: SemanticMigration = {
+ 'component-props gate reports a failing key or column as a '
+ '`component-props-unknown-key` / `component-props-invalid` error on `objectstack validate`, '
+ '`objectstack build` and `objectstack lint`, and a stored page still loads, because a '
+ 'page component\'s `properties` is not parsed on the metadata load path, and the next save of the page meets the component-props gate at the metadata save door. `defineStack`\'s '
+ 'identity-only column check does not reach this block: the panel hands its columns to the grid '
+ 'as authored, so there is no hydrated type to judge. No conversion is registered: nothing on the '
+ 'load path refuses the shape, and the one `field`-keyed producer was respelled in the same '
+ 'change. Population measured at the change, on origin/main 1ecb871beb: one authored block in '
+ 'the examples (the showcase project detail page, five `field`-keyed columns, respelled `name`), '
+ 'zero in the documentation, against eight authored `record:*` blocks of other types through '
+ 'the same matcher as the control. '
+ 'Deployed metadata NOT MEASURED.',
+ 'page component\'s `properties` is not parsed on the metadata load path, and the next save of the page meets the component-props gate at the metadata save door. The '
+ 'identity-only half is `defineStack`\'s cross-reference check: the panel hydrates a column '
+ 'that declares no `type` from the child object\'s field, with the same rule the other carriers '
+ 'use, so the check reaches the block in a page region\'s components, in a container\'s '
+ 'component children and in a slot, and refuses an identity-only column over a `currency` '
+ 'child field that carries `scale`, with the column schema\'s own message; reach: a block under '
+ 'a `page:tabs` item\'s `children` is not yet judged, the child object — the object '
+ '`dataSource` binding\'s `object` when the node carries one, else `childObject` — must be '
+ 'declared in the same stack, and a column the column schema refuses on its own is left to the '
+ 'component-props gate. No conversion is registered: '
+ 'nothing on the load path refuses the shape, and the one `field`-keyed producer was respelled '
+ 'in the same change. Population measured at the change, on origin/main 1ecb871beb: one '
+ 'authored block in the examples (the showcase project detail page, five `field`-keyed columns, '
+ 'respelled `name`), zero in the documentation, against eight authored `record:*` blocks of '
+ 'other types through the same matcher as the control. Population for the identity-only reach, '
+ 'measured on origin/main ec749fbb44: two authored blocks in the examples (the showcase project '
+ 'detail page, five typed columns, its block under a `page:tabs` item; the showcase invoice '
+ 'document page, four `{ name, label }` '
+ 'columns, two of them over `currency` fields) and one in the published skills, none carrying '
+ '`scale`. Deployed metadata NOT MEASURED.',
acceptanceCriteria: 'Every `record:line_items` node validates: `objectstack validate` reports no '
+ '`component-props-unknown-key` / `component-props-invalid` finding on its `properties` path. '
+ '`component-props-unknown-key` / `component-props-invalid` finding on its `properties` path '
+ 'and no cross-reference finding on a `columns[].scale` path. '
+ 'Every node carries `relationshipField` and at least one column, every column is an object '
+ 'carrying `name`, no column carries `field`, `fieldName` or `key`, and no key outside the '
+ 'carrying `name`, no column carries `field`, `fieldName` or `key`, no column that renders as a '
+ 'currency column carries `scale`, and no key outside the '
+ 'declared shape is present. The showcase project detail page\'s block parses with its five '
+ '`name`-keyed columns, and its grid renders a value — not a blank cell — in each column for a '
+ 'row that has one.',
Expand Down
Loading
Loading