From c9e2fe36d3f927ab39fb1f4c3e4330b7e5e3c45a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:14:56 +0000 Subject: [PATCH 1/2] feat(plugin-form): record:line_items publishes every key of its 17.6.0 spec row, each by measurement The ten keys the spec row adds over the registration (parentObject, parentId, recordId, title, readonly, minRows, maxRows, filter, sort, limit) are each authored in the spec's { type, properties } form through the real SchemaRenderer and this registration, beside a control without the key, and every one moves the panel, so every one is declared. The console parity gate's bookings for this block are struck: the ten unpublished-key entries and the two owed member pins. columns, dataSource, filter and sort get member pins in the new test file; the unpublishedKeys cap goes 12 -> 2, the memberPins cap and the member-pin exemption ceiling 2 -> 0. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .../11536-line-items-published-inputs.md | 29 + .../registry-inputs-spec-parity.test.ts | 87 +-- .../lineItemsDeclaredInputs-11536.test.tsx | 526 ++++++++++++++++++ packages/plugin-form/src/index.tsx | 74 +++ 4 files changed, 677 insertions(+), 39 deletions(-) create mode 100644 .changeset/11536-line-items-published-inputs.md create mode 100644 packages/plugin-form/src/__tests__/lineItemsDeclaredInputs-11536.test.tsx diff --git a/.changeset/11536-line-items-published-inputs.md b/.changeset/11536-line-items-published-inputs.md new file mode 100644 index 0000000000..7cac51eb59 --- /dev/null +++ b/.changeset/11536-line-items-published-inputs.md @@ -0,0 +1,29 @@ +--- +'@object-ui/plugin-form': minor +--- + +`record:line_items` now publishes every key of its `@objectstack/spec` 17.6.0 +row (objectui#11536). Each key was decided by measuring it through +`SchemaRenderer` and the block's registration, per the objectui#11111 ruling: +declare what the panel honours, leave out what it does not. All ten keys the +row added over the registration move the panel, so all ten are declared. + +Newly published, so the SDUI manifest, the JSX intrinsics and the page +validator accept them instead of reporting `unknown-prop`: + +- `parentId` and `recordId`: the parent record whose lines are loaded and + saved. `parentId` outranks `recordId`, and both outrank the record the page + shows. +- `parentObject`: the parent object the line total is written to on Save, + together with `totalField`. It outranks the object of the record the page + shows. +- `title`: the panel heading, a plain string. +- `readonly`: lines are shown without editing (no Save, no row actions). +- `minRows` / `maxRows`: Remove row is disabled at the floor; Add line and + Duplicate row are disabled at the cap. +- `filter`, `sort` and `limit`: the additional criteria (AND-combined behind + the parent relationship, never replacing it), the load order and the row + cap (default 500), each read as a top-level key as well as through a + `dataSource` binding. + +Nothing changes at runtime: the panel already read every one of these keys. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 1c88395bfd..7c27951554 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -648,8 +648,9 @@ const specCarries175LoadedBlocks = MINOR_17_5_LOADED_BLOCKS.every((type) => type * map went from 55 entries to 56 when `record:line_items` gained its row * (objectstack `24c554da`, #21142 / #21244). `@object-ui/plugin-form` * registers it eagerly with `inputs`, so this file JUDGES it from that pin on. - * Its differences are booked to objectui#11536 under objectui#11438 ruling A′ - * (see `OWED_TO` above), not declared in the bump. + * Its differences were booked to objectui#11536 under objectui#11438 ruling A′ + * (see `OWED_TO` above), not declared in the bump; that card's landing declared + * the ten keys and pinned the member shapes, and struck every entry. * * Same shape as `MINOR_17_5_BLOCKS`: this repo registered the block with * `inputs` before the spec described it, so what moved at the bump is the @@ -750,7 +751,8 @@ function isRetiredUpstream(type: string): boolean { * * - objectui#11536 — every entry on `record:line_items`: the keys its spec * row declares that `inputs` does not publish, and the member pins its - * array/object-armed inputs owe. + * array/object-armed inputs owe. STRUCK by that card's landing, which + * declared all ten keys and registered the pins. * * A fifth owner, under the same bump: objectui#11438 ruling A″ (record * 5968177777, the maintainer's order to land 17.6.0 first) books a row whose @@ -889,9 +891,9 @@ const owedIdsOf = (ledger: Record): string[] => const OBJECTUI_11111_LEDGER_CAPS = { unjudgedBlocks: 0, // objectui#11168 loaded and judged all four: slice 3 object-map and object-tree, slice 4 object-gantt, slice 5 object-timeline offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name - unpublishedKeys: 12, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing); objectui#11536: 10 (record:line_items, booked by objectui#11438 ruling A′); objectui#11068: 1 (object-grid keyboardNavigation, booked by objectui#11438 ruling A″) + unpublishedKeys: 2, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0, objectui#8649: 0 and objectui#11536: 0 (each struck by its landing; objectui#11536 declared all ten record:line_items keys); objectui#11068: 1 (object-grid keyboardNavigation, booked by objectui#11438 ruling A″) refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout - memberPins: 2, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536: 2 (record:line_items columns and dataSource, booked by objectui#11438 ruling A′) + memberPins: 0, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536 pinned record:line_items columns and dataSource } as const; /** @@ -1534,26 +1536,15 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // types and describe text, so each entry went stale and `carries no stale // unpublished-key exemption` would refuse it. - /* - * ⚠️ THE 17.6.0 BOOKINGS — objectui#11438 ruling A′ (record 5965062249), - * which applies objectui#11111 decision 3 = B to the 17.6.0 bump. - * - * The spec keys `record:line_items` declares from 17.6.0 on that its - * `inputs` do not publish. The same bar and the same ruling as the 17.5.0 - * bookings above: the bump declares nothing, and objectui#11536 measures - * each key against the line-items renderer, declares what is honoured and - * leaves the rest out. Capped with the 17.5.0 entries at - * `OBJECTUI_11111_LEDGER_CAPS.unpublishedKeys`. - */ - ...owedEntries( - 'record:line_items', - [ - 'parentObject', 'parentId', 'recordId', 'title', 'readonly', 'minRows', 'maxRows', 'filter', - 'sort', 'limit', - ], - 'objectui#11536', - 'A SPEC KEY NOT PUBLISHED: `record:line_items` entered `covered` with 17.6.0 and its `inputs` omit this key its spec row declares.', - ), + // ⚠️ THE 17.6.0 BOOKINGS — objectui#11438 ruling A′ (record 5965062249). + // objectui#11536's ten entries — `parentObject`, `parentId`, `recordId`, + // `title`, `readonly`, `minRows`, `maxRows`, `filter`, `sort` and `limit` on + // `record:line_items` — are STRUCK: that card measured each key through the + // real `SchemaRenderer` and this block's registration, found every one moving + // the panel, and DECLARED all ten on the block's `inputs` + // (`packages/plugin-form/src/__tests__/lineItemsDeclaredInputs-11536.test.tsx`), + // so each entry went stale and `carries no stale unpublished-key exemption` + // would refuse it. /* * ⚠️ ROW 1 OF THE 17.6.0 BUMP — objectui#11438 ruling A″ (record 5968177777), @@ -3343,6 +3334,22 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-detail/src/renderers/__tests__/record-blocks.requiredPermissions-gate.test.tsx', pins: 'Members are ADR-0066 CAPABILITY names and the block needs ALL of them, driven through the real renderer under a REAL stock `MePermissionsProvider` (not a mocked `usePermissions`, which cannot discriminate the two reading paths): an unheld capability and an unrecognised one each put the insufficient-permissions notice in place of the block, a held one renders it, an EMPTY array and an ABSENT key are no gate at all, and the discriminating row is a PARTIAL grant on a two-entry array — gated only when the members are read as `.every` over the WHOLE array. Two discriminators pin that a member is a capability and not an object action (`allowRead` on the object does not open the gate; the enum member `manage` is gated rather than resolved to the read bit), and the role-based `PermissionProvider` pins the unreported-capabilities fail-open. One file runs every row against all three record blocks (objectui#10155); promoted to a member pin by objectui#8649, which published the key.', }, + 'record:line_items.columns': { + file: 'packages/plugin-form/src/__tests__/lineItemsDeclaredInputs-11536.test.tsx', + pins: 'Members are inline grid column objects, driven through the real `SchemaRenderer` in the `{ type, properties }` form and asserted on the drawn grid and on the batch Save sends. Each member is one column in AUTHORED order, headed by its `label` or, without one, by its `name`. A member draws exactly what it declares: the panel hands `columns` to the grid through the field-security pass only, with no hydration from the child object, so the same value is a `number` cell under `type: \'number\'` and a text cell under a typeless member. `defaultHidden` keeps a member out of the drawn columns while `required` outranks it and marks the header. `name` is the field the cell edits, and Save writes the edit under it. The spec row is `InlineGridColumnSchema`; the registration declares a bare array, so the read site is the whole member contract (objectui#11536).', + }, + 'record:line_items.dataSource': { + file: 'packages/plugin-form/src/__tests__/lineItemsDeclaredInputs-11536.test.tsx', + pins: 'The per-element binding\'s members as this block reads them through `RECORD_LINE_ITEMS_DATA_SOURCE`, asserted at the query the adapter receives. `object` lands on `childObject` and OUTRANKS an authored one. A named `view` contributes its filter (ANDed BEHIND the parent scope), its sort and its page size; an unresolvable `view` REPORTS and loads nothing. The panel\'s own `sort` / `limit` outrank the view\'s, the binding\'s own outrank the panel\'s, and the binding\'s `filter` is ANDed with the panel\'s with the parent scope first. A view\'s `columns` are NOT read: the grid keeps the authored columns. The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`) and is absent from `ComponentPropsMap[\'record:line_items\']`, so the per-block half is what this pin carries; `LineItemsPanel.elementDataSource.test.tsx` in the same package holds the older top-level-form rows (objectui#11536).', + }, + 'record:line_items.filter': { + file: 'packages/plugin-form/src/__tests__/lineItemsDeclaredInputs-11536.test.tsx', + pins: 'Members are spec `ViewFilterRule` objects `{ field, operator, value }`, authored in the node\'s `properties` bag and asserted at the query: they reach `$filter` in order as `[field, operator, value]` triples in ONE group ANDed BEHIND the parent relationship condition, with the spec operator word unchanged (the adapter lowers it). A member naming the relationship field itself does not replace the parent scope, and an empty list is no criterion (the query stays the parent scope object). The registration declares `of: \'object\'`, the contract\'s single member kind (objectui#11536).', + }, + 'record:line_items.sort': { + file: 'packages/plugin-form/src/__tests__/lineItemsDeclaredInputs-11536.test.tsx', + pins: 'Members are `{ field, order }` objects, authored in the node\'s `properties` bag and asserted at the query: they reach `$orderby` in AUTHORED order; a member with no `field` is dropped while its siblings survive, an `order` other than `desc` reads ascending, and a list with no usable member sends no `$orderby` at all. The registration declares `of: \'object\'`, the contract\'s single member kind (objectui#11536).', + }, 'record:path.stages': { file: 'packages/plugin-detail/src/renderers/__tests__/recordPathStagesMembers-8071.test.tsx', pins: 'The stage member set `{ value, label, terminal? }`, read through the real renderer. `value` is stage IDENTITY — compared against the record\'s `statusField` to choose the current stage, controlled by moving the RECORD rather than the array, by an unmatched value leaving NO stage current, and by a record carrying some stage\'s LABEL still matching nothing. `label` is the rendered text and the `value` behind it never appears. `terminal` OUTRANKS the `WON_TOKENS`/`LOST_TOKENS` heuristic, pinned only on fixtures where the member CONTRADICTS the heuristic and each paired with the identical stage minus `terminal` to show which way the heuristic was pointing, plus an unclassified control. Deliberately the DESKTOP row only: `record-path.crossRowClassification.test.tsx` owns the cross-row agreement invariant, which is a different claim (objectui#8071 slice 6). The `label` member is an `I18nLabel`: its locale-map arm, resolved before the stages are classified or rendered, is pinned through the real `SchemaRenderer` under `zh` and `en` by `record-path.stageLabelI18nLabel-10993.test.tsx` (objectui#10993).', @@ -3542,18 +3549,12 @@ const MEMBER_PIN_EXEMPTIONS: Record = { // `fields`, `filter` and `sort` by slice 2, each registered in // `MEMBER_PINS`. Header kept as a landmark for a future grep. - // ⚠️ 17.6.0 BOOKINGS — objectui#11438 ruling A′ (record 5965062249), which - // applies objectui#11111 decision 3 = B to the 17.6.0 bump. The - // array/object-armed inputs of `record:line_items`, the block 17.6.0 newly - // judges, owed a member pin by objectui#11536. Counted into - // `MEMBER_PIN_EXEMPTION_CEILING` (0 -> 2) and capped at these two; that - // card's pins strike them. - ...owedEntries( - 'record:line_items', - ['columns', 'dataSource'], - 'objectui#11536', - 'A MEMBER PIN OWED: this `record:line_items` input is array/object-armed on a block 17.6.0 newly judges, and no pin states its members.', - ), + // ⚠️ 17.6.0 BOOKINGS — objectui#11438 ruling A′ (record 5965062249). The + // two array/object-armed inputs of `record:line_items` owed a member pin by + // objectui#11536, `columns` and `dataSource`, stood here. That card pinned + // both, and the two array keys it declared (`filter`, `sort`) with them, + // each registered in `MEMBER_PINS`, so no booking is left. Header kept as a + // landmark for a future grep. }; /** @@ -4379,8 +4380,16 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS: string[] = []; * = 2, the exact count, and `the objectui#11111 ledger is capped at exactly the * entries it lists` caps the two and routes each to objectui#11536. * objectui#11536's pins take this back to 0. + * + * ## 2 -> 0 (objectui#11536) — the 17.6.0 ruling paid off + * + * The card registered member pins for `record:line_items.columns` and + * `record:line_items.dataSource`, measured through the real renderer, and + * deleted their two booked entries. The two array keys it declared beside them + * (`filter`, `sort`) entered the population already pinned, so nothing stands + * in the list and the ceiling follows it back to 0. */ -const MEMBER_PIN_EXEMPTION_CEILING = 2; +const MEMBER_PIN_EXEMPTION_CEILING = 0; /** * Every test file a member pin can live in, as LAZY `?raw` loaders. @@ -6010,7 +6019,7 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) 'objectui#11168': 1, 'objectui#8652': 0, 'objectui#8649': 0, - 'objectui#11536': 12, + 'objectui#11536': 0, 'objectui#11068': 1, }); }); diff --git a/packages/plugin-form/src/__tests__/lineItemsDeclaredInputs-11536.test.tsx b/packages/plugin-form/src/__tests__/lineItemsDeclaredInputs-11536.test.tsx new file mode 100644 index 0000000000..9c7a740077 --- /dev/null +++ b/packages/plugin-form/src/__tests__/lineItemsDeclaredInputs-11536.test.tsx @@ -0,0 +1,526 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11536 — `record:line_items` declares every key of its + * `@objectstack/spec` 17.6.0 `ComponentPropsMap` row, and this file is the + * measurement each declaration rests on. + * + * The rule the card was ruled under: declare what the renderer honours, leave + * out what it does not, and never declare a key the renderer does not read. A + * source read alone cannot settle "honours": `SchemaRenderer` hoists the + * `properties` bag onto the node and the block wraps the panel in + * `ElementDataSourceGate`, so a key can be read, rewritten or dropped between + * the authored node and `LineItemsPanel`. So every key is authored here the + * way the spec authors it, in the node's `properties` bag, rendered through + * the real `SchemaRenderer` and this package's own registration, and asserted + * at what it does: the query the adapter receives, the batch Save sends, or + * the DOM the user sees. Every row has a control beside it that differs only + * by that key, so a key the panel ignored would read the same twice and fail. + * + * The first describe block is the ten keys the card declared (`parentObject`, + * `parentId`, `recordId`, `title`, `readonly`, `minRows`, `maxRows`, `filter`, + * `sort`, `limit`). All ten move the panel, so all ten are declared, and the + * row's other five (`childObject`, `relationshipField`, `columns`, + * `amountField`, `totalField`) were declared before it. `filter` / `sort` / + * `limit` are measured as TOP-LEVEL keys with no binding on the node: the gate + * hands such a schema through untouched, so the panel reads them itself. + * + * The other four describe blocks are the member pins the console's repo-wide + * parity gate registers for this block's array/object-armed inputs: + * `record:line_items.columns`, `record:line_items.dataSource`, + * `record:line_items.filter` and `record:line_items.sort`. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, waitFor, act, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { SchemaRenderer, SchemaRendererProvider, RecordContextProvider } from '@object-ui/react'; +// Registers `record:line_items` through this package's own entry. +import '../index'; + +const COLUMNS = [ + { name: 'label', label: 'Line', type: 'text' }, + { name: 'amount', label: 'Amount', type: 'number' }, +]; + +type Row = Record; + +const TWO_LINES: Row[] = [ + { id: 'l1', label: 'first', amount: 5 }, + { id: 'l2', label: 'second', amount: 7 }, +]; + +/** + * A recording adapter. The child schema is answered with none, so a saved + * line is written as the grid holds it (no field-list strip in between). + */ +function makeAdapter(rows: Row[] = TWO_LINES, listViews: Record = {}) { + return { + find: vi.fn().mockResolvedValue({ data: rows }), + getObjectSchema: vi.fn(async () => + Object.keys(listViews).length > 0 ? { name: 'po_line', fields: {}, listViews } : null, + ), + batchTransaction: vi.fn().mockResolvedValue({ results: [] }), + }; +} +type Adapter = ReturnType; + +/** A `record:line_items` node in the spec's `{ type, properties }` form. */ +const node = (properties: Row, nodeLevel: Row = {}) => ({ + type: 'record:line_items', + ...nodeLevel, + properties: { childObject: 'po_line', relationshipField: 'po', columns: COLUMNS, ...properties }, +}); + +interface PageRecord { + objectName: string; + recordId: string; +} + +function mount(adapter: Adapter, schema: Row, record?: PageRecord) { + const block = ; + return render( + + {record ? ( + + {block} + + ) : ( + block + )} + , + ); +} + +/** The first query the panel sent, once it has sent one. */ +async function firstQuery(adapter: Adapter): Promise<[string, Row]> { + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + return adapter.find.mock.calls[0] as [string, Row]; +} + +const lineInputs = () => + Array.from(document.body.querySelectorAll('input[aria-label="Line"]')) as HTMLInputElement[]; +const buttonNamed = (name: string) => + Array.from(document.body.querySelectorAll('button')).find( + (b) => (b.textContent ?? '').trim() === name || b.getAttribute('aria-label') === name, + ) as HTMLButtonElement | undefined; +const buttonsLabelled = (label: string) => + Array.from(document.body.querySelectorAll(`button[aria-label="${label}"]`)) as HTMLButtonElement[]; +/** The panel's heading: the card title (`CardTitle`, whose own class is `tracking-tight`). */ +const heading = () => document.body.querySelector('.tracking-tight')?.textContent; +/** The grid's column headers, without the line-number and row-action columns. */ +const columnHeaders = () => + Array.from(document.body.querySelectorAll('thead th')) + .map((th) => (th.textContent ?? '').trim()) + .filter((text) => text !== '' && text !== '#'); + +/** Waits for both lines to be drawn, so an absence below is not an unfinished load. */ +async function linesDrawn() { + await waitFor(() => expect(lineInputs().map((i) => i.value)).toEqual(expect.arrayContaining(['first', 'second']))); +} + +/** Edits the first line, clicks Save, and returns the batch Save sent. */ +async function editAndSave(adapter: Adapter): Promise { + await waitFor(() => expect(lineInputs().length).toBeGreaterThan(0)); + await act(async () => { + fireEvent.change(lineInputs()[0], { target: { value: 'edited' } }); + }); + await waitFor(() => expect(buttonNamed('Save')?.disabled).toBe(false)); + await act(async () => { + fireEvent.click(buttonNamed('Save')!); + }); + await waitFor(() => expect(adapter.batchTransaction).toHaveBeenCalledTimes(1)); + return adapter.batchTransaction.mock.calls[0][0] as Row[]; +} + +afterEach(() => { + vi.restoreAllMocks(); + cleanup(); +}); + +describe('record:line_items: each of the ten declared keys moves the panel (objectui#11536)', () => { + it('parentId scopes the load to that parent; CONTROL: with no parent id nothing is loaded', async () => { + const scoped = makeAdapter(); + mount(scoped, node({ parentId: 'p1' })); + expect(await firstQuery(scoped)).toEqual(['po_line', { $filter: { po: 'p1' }, $top: 500 }]); + cleanup(); + + const unscoped = makeAdapter(); + const view = mount(unscoped, node({})); + await waitFor(() => expect(view.container.textContent).toContain('Save the record first to add line items.')); + expect(unscoped.find).not.toHaveBeenCalled(); + }); + + it('parentId saves a new line under that parent', async () => { + const adapter = makeAdapter([]); + mount(adapter, node({ parentId: 'p1' })); + const ops = await editAndSave(adapter); + expect(ops).toEqual([{ object: 'po_line', action: 'create', data: { label: 'edited', amount: null, po: 'p1' } }]); + }); + + it('recordId scopes the load when parentId is unset, and outranks the record the page shows', async () => { + const alone = makeAdapter(); + mount(alone, node({ recordId: 'r9' })); + expect((await firstQuery(alone))[1]).toEqual({ $filter: { po: 'r9' }, $top: 500 }); + cleanup(); + + const overPage = makeAdapter(); + mount(overPage, node({ recordId: 'r9' }), { objectName: 'po', recordId: 'page-1' }); + expect((await firstQuery(overPage))[1]).toEqual({ $filter: { po: 'r9' }, $top: 500 }); + cleanup(); + + // CONTROL: the record the page shows is the parent when neither key is set. + const pageOnly = makeAdapter(); + mount(pageOnly, node({}), { objectName: 'po', recordId: 'page-1' }); + expect((await firstQuery(pageOnly))[1]).toEqual({ $filter: { po: 'page-1' }, $top: 500 }); + }); + + it('parentId outranks recordId', async () => { + const adapter = makeAdapter(); + mount(adapter, node({ parentId: 'p1', recordId: 'r9' })); + expect((await firstQuery(adapter))[1]).toEqual({ $filter: { po: 'p1' }, $top: 500 }); + }); + + it('parentObject names the parent leg Save writes the line total to; CONTROL: without it there is no parent leg', async () => { + const named = makeAdapter([{ id: 'l1', label: 'first', amount: 5 }]); + mount(named, node({ parentId: 'p1', parentObject: 'purchase_order', totalField: 'total_amount', amountField: 'amount' })); + expect(await editAndSave(named)).toEqual([ + { object: 'purchase_order', action: 'update', id: 'p1', data: { total_amount: 5 } }, + { object: 'po_line', action: 'update', id: 'l1', data: { label: 'edited', po: 'p1' } }, + ]); + cleanup(); + + const unnamed = makeAdapter([{ id: 'l1', label: 'first', amount: 5 }]); + mount(unnamed, node({ parentId: 'p1', totalField: 'total_amount', amountField: 'amount' })); + expect(await editAndSave(unnamed)).toEqual([ + { object: 'po_line', action: 'update', id: 'l1', data: { label: 'edited', po: 'p1' } }, + ]); + }); + + it('parentObject outranks the object of the record the page shows', async () => { + const authored = makeAdapter([{ id: 'l1', label: 'first', amount: 5 }]); + mount( + authored, + node({ parentObject: 'purchase_order', totalField: 'total_amount', amountField: 'amount' }), + { objectName: 'po', recordId: 'page-1' }, + ); + expect((await editAndSave(authored))[0]).toEqual({ + object: 'purchase_order', + action: 'update', + id: 'page-1', + data: { total_amount: 5 }, + }); + cleanup(); + + // CONTROL: the page's object is the parent object when the key is unset. + const fromPage = makeAdapter([{ id: 'l1', label: 'first', amount: 5 }]); + mount(fromPage, node({ totalField: 'total_amount', amountField: 'amount' }), { objectName: 'po', recordId: 'page-1' }); + expect((await editAndSave(fromPage))[0]).toEqual({ object: 'po', action: 'update', id: 'page-1', data: { total_amount: 5 } }); + }); + + it('title is the heading; CONTROL: without it the heading is the default', async () => { + const titled = makeAdapter(); + mount(titled, node({ parentId: 'p1', title: 'Order lines' })); + await linesDrawn(); + expect(heading()).toBe('Order lines'); + cleanup(); + + const untitled = makeAdapter(); + mount(untitled, node({ parentId: 'p1' })); + await linesDrawn(); + expect(heading()).toBe('Line Items'); + }); + + it('readonly draws a read-only table with no Save and no row actions; CONTROL: without it the grid is editable', async () => { + const editable = makeAdapter(); + mount(editable, node({ parentId: 'p1' })); + await linesDrawn(); + expect(buttonNamed('Save')).toBeDefined(); + expect(buttonNamed('Add line')).toBeDefined(); + expect(buttonsLabelled('Remove row')).toHaveLength(2); + expect(document.body.querySelector('[data-testid="line-items-readonly"]')).toBeNull(); + cleanup(); + + const readonly = makeAdapter(); + mount(readonly, node({ parentId: 'p1', readonly: true })); + await waitFor(() => expect(document.body.querySelector('[data-testid="line-items-readonly"]')).not.toBeNull()); + expect(document.body.textContent).toContain('first'); + expect(lineInputs()).toHaveLength(0); + expect(buttonNamed('Save')).toBeUndefined(); + expect(buttonNamed('Add line')).toBeUndefined(); + expect(buttonsLabelled('Remove row')).toHaveLength(0); + expect(buttonsLabelled('Duplicate row')).toHaveLength(0); + }); + + it('minRows disables Remove row while the grid holds that many lines; CONTROL: without it Remove row is enabled', async () => { + const free = makeAdapter(); + mount(free, node({ parentId: 'p1' })); + await linesDrawn(); + expect(buttonsLabelled('Remove row').map((b) => b.disabled)).toEqual([false, false]); + cleanup(); + + const floored = makeAdapter(); + mount(floored, node({ parentId: 'p1', minRows: 2 })); + await linesDrawn(); + expect(buttonsLabelled('Remove row').map((b) => b.disabled)).toEqual([true, true]); + // It adds no blank lines to reach the number: two lines plus the entry row. + expect(lineInputs()).toHaveLength(3); + }); + + it('maxRows disables Add line and Duplicate row and drops the entry row at that many lines; CONTROL: without it they stay', async () => { + const open = makeAdapter(); + mount(open, node({ parentId: 'p1' })); + await linesDrawn(); + expect(buttonNamed('Add line')?.disabled).toBe(false); + expect(buttonsLabelled('Duplicate row').map((b) => b.disabled)).toEqual([false, false]); + expect(lineInputs()).toHaveLength(3); + cleanup(); + + const capped = makeAdapter(); + mount(capped, node({ parentId: 'p1', maxRows: 2 })); + await linesDrawn(); + expect(buttonNamed('Add line')?.disabled).toBe(true); + expect(buttonsLabelled('Duplicate row').map((b) => b.disabled)).toEqual([true, true]); + expect(lineInputs()).toHaveLength(2); + }); + + it('filter narrows the load behind the parent scope; CONTROL: without it the query is the parent scope alone', async () => { + const filtered = makeAdapter(); + mount(filtered, node({ parentId: 'p1', filter: [{ field: 'billable', operator: 'equals', value: true }] })); + expect((await firstQuery(filtered))[1]).toEqual({ + $filter: ['and', ['po', '=', 'p1'], [['billable', 'equals', true]]], + $top: 500, + }); + cleanup(); + + const unfiltered = makeAdapter(); + mount(unfiltered, node({ parentId: 'p1' })); + expect((await firstQuery(unfiltered))[1]).toEqual({ $filter: { po: 'p1' }, $top: 500 }); + }); + + it('sort orders the load; CONTROL: without it the query carries no ordering', async () => { + const sorted = makeAdapter(); + mount(sorted, node({ parentId: 'p1', sort: [{ field: 'amount', order: 'desc' }] })); + expect((await firstQuery(sorted))[1]).toEqual({ $filter: { po: 'p1' }, $orderby: { amount: 'desc' }, $top: 500 }); + cleanup(); + + const unsorted = makeAdapter(); + mount(unsorted, node({ parentId: 'p1' })); + expect((await firstQuery(unsorted))[1]).not.toHaveProperty('$orderby'); + }); + + it('limit caps the load; CONTROL: without it the cap is 500', async () => { + const capped = makeAdapter(); + mount(capped, node({ parentId: 'p1', limit: 25 })); + expect((await firstQuery(capped))[1]).toEqual({ $filter: { po: 'p1' }, $top: 25 }); + cleanup(); + + const uncapped = makeAdapter(); + mount(uncapped, node({ parentId: 'p1' })); + expect((await firstQuery(uncapped))[1]).toEqual({ $filter: { po: 'p1' }, $top: 500 }); + }); +}); + +describe('record:line_items.columns: the members the grid draws (objectui#11536)', () => { + it('each member is one column in AUTHORED order, headed by its label, or by its name when it has none', async () => { + const adapter = makeAdapter([{ id: 'l1', label: 'first', amount: 5, qty: 2 }]); + mount( + adapter, + node({ + parentId: 'p1', + columns: [ + { name: 'qty', label: 'Quantity', type: 'number' }, + { name: 'label' }, + { name: 'amount', label: 'Amount', type: 'number' }, + ], + }), + ); + await waitFor(() => expect(columnHeaders()).toEqual(['Quantity', 'label', 'Amount'])); + }); + + it('a member draws exactly what it declares: its type picks the cell control, and a typeless member is a text cell', async () => { + const adapter = makeAdapter([{ id: 'l1', label: 'first', amount: 5 }]); + mount( + adapter, + node({ parentId: 'p1', columns: [{ name: 'amount', label: 'Typed', type: 'number' }, { name: 'amount', label: 'Untyped' }] }), + ); + await waitFor(() => expect(document.body.querySelectorAll('input[aria-label="Typed"]').length).toBeGreaterThan(0)); + // The panel does not hydrate a column from the child object's fields: the + // same `amount` value is a number cell only where the member says so. + expect((document.body.querySelector('input[aria-label="Typed"]') as HTMLInputElement).type).toBe('number'); + expect((document.body.querySelector('input[aria-label="Untyped"]') as HTMLInputElement).type).toBe('text'); + }); + + it('defaultHidden keeps a member out of the drawn columns; CONTROL: required outranks it, and marks its header', async () => { + const adapter = makeAdapter([{ id: 'l1', label: 'first', amount: 5 }]); + mount( + adapter, + node({ + parentId: 'p1', + columns: [ + { name: 'label', label: 'Line', type: 'text' }, + { name: 'amount', label: 'Amount', type: 'number', defaultHidden: true }, + { name: 'note', label: 'Note', type: 'text', defaultHidden: true, required: true }, + ], + }), + ); + await waitFor(() => expect(columnHeaders()).toEqual(['Line', 'Note *'])); + }); + + it('a member names the field its cell edits, and Save writes the edit under that name', async () => { + const adapter = makeAdapter([{ id: 'l1', label: 'first', amount: 5 }]); + mount(adapter, node({ parentId: 'p1' })); + expect(await editAndSave(adapter)).toEqual([ + { object: 'po_line', action: 'update', id: 'l1', data: { label: 'edited', po: 'p1' } }, + ]); + }); +}); + +describe('record:line_items.filter: the members the load reads (objectui#11536)', () => { + it('ViewFilterRule members reach the query in order, as one group ANDed BEHIND the parent scope', async () => { + const adapter = makeAdapter(); + mount( + adapter, + node({ + parentId: 'p1', + filter: [ + { field: 'billable', operator: 'equals', value: true }, + { field: 'amount', operator: 'greater_than', value: 0 }, + ], + }), + ); + expect((await firstQuery(adapter))[1].$filter).toEqual([ + 'and', + ['po', '=', 'p1'], + [ + ['billable', 'equals', true], + ['amount', 'greater_than', 0], + ], + ]); + }); + + it('an empty list is no criterion: the query stays the parent scope object', async () => { + const adapter = makeAdapter(); + mount(adapter, node({ parentId: 'p1', filter: [] })); + expect((await firstQuery(adapter))[1].$filter).toEqual({ po: 'p1' }); + }); + + it('a member never replaces the parent scope, even when it names the relationship field', async () => { + const adapter = makeAdapter(); + mount(adapter, node({ parentId: 'p1', filter: [{ field: 'po', operator: 'equals', value: 'p2' }] })); + expect((await firstQuery(adapter))[1].$filter).toEqual(['and', ['po', '=', 'p1'], [['po', 'equals', 'p2']]]); + }); +}); + +describe('record:line_items.sort: the members the load reads (objectui#11536)', () => { + it('{ field, order } members reach the query in authored order', async () => { + const adapter = makeAdapter(); + mount( + adapter, + node({ parentId: 'p1', sort: [{ field: 'amount', order: 'desc' }, { field: 'label', order: 'asc' }] }), + ); + const orderBy = (await firstQuery(adapter))[1].$orderby as Record; + expect(orderBy).toEqual({ amount: 'desc', label: 'asc' }); + expect(Object.keys(orderBy)).toEqual(['amount', 'label']); + }); + + it('a member with no field is dropped while its siblings survive, and an order other than desc reads ascending', async () => { + const adapter = makeAdapter(); + mount(adapter, node({ parentId: 'p1', sort: [{ order: 'desc' }, { field: 'label', order: 'up' }] })); + expect((await firstQuery(adapter))[1].$orderby).toEqual({ label: 'asc' }); + }); + + it('a list with no usable member sends no ordering at all', async () => { + const adapter = makeAdapter(); + mount(adapter, node({ parentId: 'p1', sort: [{ order: 'desc' }] })); + expect((await firstQuery(adapter))[1]).not.toHaveProperty('$orderby'); + }); +}); + +describe('record:line_items.dataSource: the binding members the block reads (objectui#11536)', () => { + const HOT_VIEW = { + name: 'hot', + label: 'Billable lines', + columns: ['amount'], + filter: [{ field: 'billable', operator: 'equals', value: true }], + sort: [{ field: 'amount', order: 'desc' }], + pagination: { pageSize: 7 }, + }; + + it('object lands on childObject and OUTRANKS an authored childObject', async () => { + const adapter = makeAdapter(); + mount(adapter, node({ parentId: 'p1', childObject: 'po_line' }, { dataSource: { object: 'invoice_line' } })); + expect((await firstQuery(adapter))[0]).toBe('invoice_line'); + }); + + it('view contributes its filter behind the parent scope, its sort and its page size', async () => { + const adapter = makeAdapter(TWO_LINES, { hot: HOT_VIEW }); + mount(adapter, node({ parentId: 'p1' }, { dataSource: { object: 'po_line', view: 'hot' } })); + expect((await firstQuery(adapter))[1]).toEqual({ + $filter: ['and', ['po', '=', 'p1'], [['billable', 'equals', true]]], + $orderby: { amount: 'desc' }, + $top: 7, + }); + }); + + it('an unresolvable view reports instead of loading', async () => { + const adapter = makeAdapter(TWO_LINES, { hot: HOT_VIEW }); + const view = mount(adapter, node({ parentId: 'p1' }, { dataSource: { object: 'po_line', view: 'nope' } })); + await waitFor(() => + expect(view.container.querySelector('[data-testid="record-line-items-datasource-error"]')).not.toBeNull(), + ); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('the panel’s own sort and limit outrank the view’s', async () => { + const adapter = makeAdapter(TWO_LINES, { hot: HOT_VIEW }); + mount( + adapter, + node({ parentId: 'p1', sort: [{ field: 'label', order: 'asc' }], limit: 25 }, { dataSource: { object: 'po_line', view: 'hot' } }), + ); + const query = (await firstQuery(adapter))[1]; + expect(query.$orderby).toEqual({ label: 'asc' }); + expect(query.$top).toBe(25); + }); + + it('the binding’s own sort and limit outrank the panel’s', async () => { + const adapter = makeAdapter(TWO_LINES, { hot: HOT_VIEW }); + mount( + adapter, + node( + { parentId: 'p1', sort: [{ field: 'label', order: 'asc' }], limit: 25 }, + { dataSource: { object: 'po_line', view: 'hot', sort: [{ field: 'amount', order: 'asc' }], limit: 3 } }, + ), + ); + const query = (await firstQuery(adapter))[1]; + expect(query.$orderby).toEqual({ amount: 'asc' }); + expect(query.$top).toBe(3); + }); + + it('the binding’s filter is ANDed with the panel’s, and the parent scope survives both', async () => { + const adapter = makeAdapter(); + mount( + adapter, + node( + { parentId: 'p1', filter: [{ field: 'void', operator: 'equals', value: false }] }, + { dataSource: { object: 'po_line', filter: [{ field: 'amount', operator: 'greater_than', value: 0 }] } }, + ), + ); + expect((await firstQuery(adapter))[1].$filter).toEqual([ + 'and', + ['po', '=', 'p1'], + ['and', [['void', 'equals', false]], [['amount', 'greater_than', 0]]], + ]); + }); + + it('a view’s columns are NOT read: the grid keeps the authored columns', async () => { + const adapter = makeAdapter(TWO_LINES, { hot: HOT_VIEW }); + mount(adapter, node({ parentId: 'p1' }, { dataSource: { object: 'po_line', view: 'hot' } })); + await waitFor(() => expect(columnHeaders()).toEqual(['Line', 'Amount'])); + }); +}); diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index ffefd5a159..ef2d497e8b 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -588,11 +588,85 @@ ComponentRegistry.register('line_items', LineItemsPanelRenderer, { skipFallback: true, label: 'Line Items', category: 'record', + // Mirrors `@objectstack/spec` `RecordLineItemsProps` (17.6.0), key for key. + // + // The ten keys after `amountField` were DECLARED by objectui#11536, each by + // measurement rather than by copying the row: every one is authored in the + // spec's `{ type, properties }` form through the real `SchemaRenderer` and + // this registration, beside a control without it, and every one moves the + // panel (`__tests__/lineItemsDeclaredInputs-11536.test.tsx`). `filter` / + // `sort` / `limit` are read as TOP-LEVEL keys, not only as `dataSource` + // members: with no binding the gate hands the schema through untouched and + // `LineItemsPanel` reads them itself; with one, the gate composes them with + // the binding before the panel reads them. A key the panel does not read is + // not declared here, and none of the row's fifteen is such a key. inputs: [ { name: 'childObject', type: 'string', required: true }, { name: 'relationshipField', type: 'string', required: true }, { name: 'columns', type: 'array', required: true }, { name: 'totalField', type: 'string' }, { name: 'amountField', type: 'string' }, + { + name: 'parentObject', + type: 'string', + description: + 'The PARENT object the line total is written to on Save, together with `totalField`. Defaults to the object of the record the page shows, and outranks it when set. With neither, Save writes the lines alone and no parent field.', + }, + { + name: 'parentId', + type: 'string', + description: + 'The parent record whose lines the panel lists and saves: it loads `childObject` rows whose `relationshipField` equals this id, and a line added in the grid is saved with this id in `relationshipField`. Outranks `recordId` and the record the page shows; leave it unset on a record page, where the record shown is the parent. With no parent id at all the panel asks the user to save the record first and loads nothing.', + }, + { + name: 'recordId', + type: 'string', + description: + 'The parent record id, read only when `parentId` is unset. It outranks the record the page shows.', + }, + { + name: 'title', + type: 'string', + description: + 'Heading of the panel. Defaults to "Line Items" in the active UI language. A plain string, drawn as authored.', + }, + { + name: 'readonly', + type: 'boolean', + description: + 'Shows the lines without editing: no Save button, no Add line / Duplicate row / Remove row actions, and the grid draws a read-only table.', + }, + { + name: 'minRows', + type: 'number', + description: + 'Fewest lines the user can leave: every line\'s Remove row action is disabled while the grid holds this many lines or fewer. It adds no blank lines to reach the number.', + }, + { + name: 'maxRows', + type: 'number', + description: + 'Most lines the user can hold: at this many lines Add line and Duplicate row are disabled and the trailing blank entry row is not drawn.', + }, + { + name: 'filter', + type: 'array', + of: 'object', + description: + 'Additional criteria for the lines, as spec `ViewFilterRule` entries (`[{ field, operator, value }]`). AND-combined with the parent relationship condition, never a replacement for it: it can only narrow this record\'s lines. Also the key a per-element `dataSource` binding\'s composed filter lands on.', + }, + { + name: 'sort', + type: 'array', + of: 'object', + description: + 'Load order for the lines, as `[{ field, order }]` entries applied in list order; `order` is `asc` or `desc`. Without it the lines arrive in storage order.', + }, + { + name: 'limit', + type: 'number', + description: + 'Most lines loaded (default 500). The grid has no pagination: every loaded line is editable and saved as one batch, and lines past the cap are not loaded. Must be a positive integer: any other value is ignored, with a console warning.', + }, ], }); From 390a65fa622e04c265c241111983e3b50e0c138b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:30:16 +0000 Subject: [PATCH 2/2] test(console): the record-reach probe leaves record:line_items' parent overrides unset record:line_items now declares parentId, recordId and parentObject, and the probe fills every declared input. Each of the three outranks the record the page shows, so a sampled value detached the panel from the bound record and the probe reported "the bound record does not reach its output" for a block that reaches it. The renderer default is the key absent, and that is what the sampler restates, beside the six it already restates for the same reason. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .../__tests__/record-block-record-reach.test.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/console/src/__tests__/record-block-record-reach.test.tsx b/apps/console/src/__tests__/record-block-record-reach.test.tsx index 5c7b2ff125..df8a0b7b51 100644 --- a/apps/console/src/__tests__/record-block-record-reach.test.tsx +++ b/apps/console/src/__tests__/record-block-record-reach.test.tsx @@ -286,6 +286,15 @@ const SAMPLE_BY_INPUT: Readonly> = { align: 'end', variant: 'default', size: 'sm', + // `record:line_items` declares three keys that OUTRANK the bound record + // (objectui#11536): `parentId` and `recordId` name the parent record and + // `parentObject` the parent object, each in place of the record the page + // shows. Any value here detaches the panel from that record, and the probe + // could not ask its question. The renderer default is the key ABSENT, so + // that is what is restated. + parentId: undefined, + recordId: undefined, + parentObject: undefined, }; /** Fill one declared input. */ @@ -549,7 +558,10 @@ const NO_RECORD_REACH: Readonly> = { * the repo are on `view:detail` and `detail-view`, neither of which is in the * curated public set). That is the same result objectstack#4472's direction (d) * hit — a slice proposed from the declarations, unavailable once you look at - * what is actually declared. + * what is actually declared. (Read when written. objectui#11536 has since + * declared `recordId` and `parentId` on `record:line_items`, but as OVERRIDES + * of the bound record rather than a binding to it, so this probe leaves both + * unset: see `SAMPLE_BY_INPUT`.) * * What IS available is stronger anyway: two blocks whose REQUIRED * `relationshipField` / `childObject` must land in the query, mechanically