|
4 | 4 | * #20901 — an inline grid column is judged on BOTH of its carriers, and by the |
5 | 5 | * type it renders as. #20928 brought the THIRD carrier, an |
6 | 6 | * `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). |
8 | 9 | * |
9 | 10 | * The objectui master-detail grid reads one column shape from two carriers: a |
10 | 11 | * 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 |
595 | 596 | expect(() => build(stackWithMasterDetailColumns([{ name: 'amount' }]))).not.toThrow(); |
596 | 597 | }); |
597 | 598 | }); |
| 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 | +}); |
0 commit comments