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
14 changes: 13 additions & 1 deletion examples/app-showcase/src/ui/pages/invoice-document.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,19 @@ export const InvoiceDocumentPage = definePage({
properties: {
childObject: 'showcase_invoice_line',
relationshipField: 'invoice',
columns: [{ name: 'description' }, { name: 'quantity' }, { name: 'unit_price' }, { name: 'amount' }],
// Each column declares its heading. Unlike the master-detail grid
// (`showcase_invoice.lines.inlineColumns`), this block does not
// fill a column from the child field, so an identity-only
// `{ name }` column prints its raw field name. The labels are the
// invoice line fields' own. They read in English in every locale:
// a column `label` is a plain string, and no translation bundle
// key reaches a `record:line_items` column.
columns: [
{ name: 'description', label: 'Description' },
{ name: 'quantity', label: 'Qty' },
{ name: 'unit_price', label: 'Unit Price' },
{ name: 'amount', label: 'Amount' },
],
readonly: true,
},
},
Expand Down
62 changes: 62 additions & 0 deletions examples/app-showcase/test/invoice-document-line-items.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { RecordLineItemsProps } from '@objectstack/spec/ui';

import { InvoiceDocumentPage } from '../src/ui/pages/index.js';
import { InvoiceLine } from '../src/data/objects/invoice.object.js';

/**
* The invoice print page heads its line-items table with labels.
*
* `record:line_items` draws a column exactly as declared. Unlike the
* master-detail grid, it does not fill a column from the child object's
* field, so a column with no `label` is headed by its raw field name: the
* printed invoice read `description` / `quantity` / `unit_price` / `amount`.
*
* The pin holds each column to the label its own child field declares, and
* never to the field name. Deriving the expected heading from the field keeps
* the print in step with the master-detail grid, which hydrates the same
* labels for the same fields.
*/

type Column = Record<string, unknown>;
type Component = { type?: unknown; properties?: Record<string, unknown> };

const page = InvoiceDocumentPage as unknown as { regions?: { components?: Component[] }[] };

const lineItemBlocks = (page.regions ?? [])
.flatMap((region) => region.components ?? [])
.filter((component) => component.type === 'record:line_items');

const columnsOf = (block: Component | undefined): Column[] =>
(Array.isArray(block?.properties?.columns) ? block!.properties!.columns : []) as Column[];

const childFields = (InvoiceLine.fields ?? {}) as Record<string, { label?: unknown }>;

describe('Invoice print page — the line-items table is headed by labels, not field names', () => {
const [block, ...rest] = lineItemBlocks;

it('carries one line-items block over the invoice lines', () => {
expect(block, 'the invoice print page must carry its line-items table').toBeTruthy();
expect(rest).toHaveLength(0);
expect(block.properties?.childObject).toBe(InvoiceLine.name);
expect(columnsOf(block).map((c) => c.name)).toEqual(['description', 'quantity', 'unit_price', 'amount']);
});

it('heads every column with its child field\'s own label, never the field name', () => {
for (const column of columnsOf(block)) {
const name = column.name as string;
const fieldLabel = childFields[name]?.label;
expect(typeof fieldLabel, `showcase_invoice_line.${name} must declare a label`).toBe('string');
expect(column.label, `column '${name}' must declare its heading`).toBe(fieldLabel);
expect(column.label, `column '${name}' must not be headed by its field name`).not.toBe(name);
}
});

it('parses against the spec\'s `record:line_items` row with the labels intact', () => {
const result = RecordLineItemsProps.safeParse(block.properties);
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
expect(result.data).toEqual(block.properties);
});
});
Loading