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__/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 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 83dafcb816..9d9190fd16 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 @@ -890,9 +892,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: 11, // 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: 0 (object-grid keyboardNavigation, booked by objectui#11438 ruling A″, struck by that card's build) + unpublishedKeys: 1, // 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, objectui#11536: 0 and objectui#11068: 0 (each struck by its landing; objectui#11536 declared all ten record:line_items keys, objectui#11068's build published object-grid keyboardNavigation) 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; /** @@ -1535,26 +1537,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. // objectui#11068's one entry — row 1 of the 17.6.0 bump, // `object-grid.keyboardNavigation`, booked by objectui#11438 ruling A″ @@ -3327,6 +3318,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).', @@ -3526,18 +3533,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. }; /** @@ -4363,8 +4364,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. @@ -5992,7 +6001,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': 0, }); }); 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.', + }, ], });