From a09c7f607c1e5b09fb8c07213427e3c9ed09427b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:16:47 +0000 Subject: [PATCH 1/6] fix(plugin-form): the line-items panel neither draws nor saves the lines it holds for another parent `LineItemsPanel` now records which parent its held rows belong to, in the same commit as the rows. While that parent is not the current `parentId` (a load for the new parent failed or declined), no held line is drawn, the Save button is off and `save` returns before it builds a batch. A load for the new parent that succeeds, and a same-parent re-load that fails, behave as before. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...0740-line-items-held-for-another-parent.md | 22 ++ ...sPanel.heldForAnotherParent-10740.test.tsx | 365 ++++++++++++++++++ packages/plugin-form/src/LineItemsPanel.tsx | 49 ++- 3 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 .changeset/10740-line-items-held-for-another-parent.md create mode 100644 packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx diff --git a/.changeset/10740-line-items-held-for-another-parent.md b/.changeset/10740-line-items-held-for-another-parent.md new file mode 100644 index 0000000000..c34f15b9e0 --- /dev/null +++ b/.changeset/10740-line-items-held-for-another-parent.md @@ -0,0 +1,22 @@ +--- +'@object-ui/plugin-form': patch +--- + +fix(plugin-form): the line-items panel neither draws nor saves the lines it holds for another parent record (objectui#10740) + +The `record:line_items` panel (`LineItemsPanel`) holds one set of rows, replaced +when a load commits. When the host moved the panel to another parent record +without a remount and that parent's load failed, the previous parent's lines +stayed on screen, edits included, drawn editable with Save enabled; Save then +wrote them under the new parent's id, moving another record's lines to it. A load +that declined for the new parent (a refused filter) left the same Save enabled +over the same rows. + +Now the panel records which parent its held rows belong to, in the same commit +as the rows. While that parent is not the current one, no held line is drawn (a +placeholder stands where the grid would be, under the load failure's banner), +the Save button is off, and a save sends nothing. A load for the current parent +that commits takes the grid back. Unchanged: a load for the new parent that +succeeds replaces the rows as before, and a re-load of the same parent that +fails keeps the author's unsaved edits drawn, editable and saveable under that +parent. diff --git a/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx b/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx new file mode 100644 index 0000000000..24a62299e5 --- /dev/null +++ b/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx @@ -0,0 +1,365 @@ +/** + * 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#10740 — the `record:line_items` panel (`LineItemsPanel`) never draws, + * and never saves, lines it holds for ANOTHER parent. + * + * The panel holds one set of `rows` / `original`, replaced when a load commits. + * Its load committed them only on success, and its `catch` left them as they + * were. So when the host moved the panel to another parent record (a new + * `parentId` without a remount) and that parent's load failed, the PREVIOUS + * parent's lines stayed on screen, edits included, drawn editable with Save + * enabled — and `save` built its batch from those rows under the CURRENT + * `parentId`. An edit batch's child rows carry the parent id directly, so the + * batch was `update l1 { …, po: 'p2' }`: it moved `p1`'s line to `p2`. + * + * Now the panel records which parent the held rows belong to, in the same + * commit as the rows. While that parent is not the current one, no held line is + * drawn (a placeholder stands where the grid would be), the Save button is off, + * and `save` itself returns before it builds a batch. The rule is on the ROWS, + * not on the load's failure, so a load that DECLINES for the new parent (a + * refused filter) is refused the same way — that path never reaches the + * load's `catch`, which is why clearing the rows there would not have closed + * it. + * + * Two things the rule must leave alone, pinned as controls: + * - a load for the new parent that SUCCEEDS replaces the rows as before, and + * saves that parent's lines only; + * - a RE-load of the SAME parent that fails (a transient failure on a refresh) + * keeps the author's unsaved edits drawn, editable and saveable — under + * that parent — exactly as it did before this card. + * + * Rendered through the real `SchemaRenderer` and this package's own + * registration of `record:line_items`. Every `find` and every `batchTransaction` + * returns a promise the test settles by hand, so each assertion is made in the + * state it names. The banner is read through the message the adapter threw; the + * placeholder through its test id, never its wording. + * + * The one row that reaches `save` while the Save button is off does so by + * clearing the button's `disabled` on the DOM node before clicking: that is a + * host re-enabling the control, or a future caller of `save` that is not this + * button. It pins the function's own guard, which no click through the rendered + * UI can otherwise reach while the button's guard holds. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers `record:line_items` through this package's own entry. +import './index'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +type LinesRead = Deferred & { parentId: unknown }; +type Save = Deferred & { ops: any[] }; + +/** + * Every row load (`find`) and every save (`batchTransaction`) returns a promise + * the test settles by hand. The child schema read is answered at once with no + * schema, so child payloads are persisted as the grid holds them. + */ +function makeLinesDataSource() { + const lineReads: LinesRead[] = []; + const saves: Save[] = []; + const dataSource = { + getObjectSchema: vi.fn(async () => null), + find: vi.fn((_objectName: string, query: any) => { + const d = deferred(); + lineReads.push({ ...d, parentId: query?.$filter?.po }); + return d.promise; + }), + batchTransaction: vi.fn((ops: any[]) => { + const d = deferred(); + saves.push({ ...d, ops }); + return d.promise; + }), + }; + return { dataSource, lineReads, saves }; +} + +/** A panel on an existing parent record, over one text column. */ +const linesBlock = (over: Record = {}) => ({ + type: 'record:line_items', + childObject: 'po_line', + relationshipField: 'po', + parentObject: 'po', + parentId: 'p1', + columns: [{ name: 'label', label: 'Line', type: 'text' }], + ...over, +}); + +function mount(dataSource: unknown, block: Record) { + const tree = (b: Record) => ( + + + + ); + const view = render(tree(block)); + return { rerender: (next: Record) => view.rerender(tree(next)) }; +} + +/** The `n`-th read or save (1-based) once it has been issued. */ +async function nth(items: T[], n: number): Promise { + await waitFor(() => { + if (items.length < n) throw new Error(`item ${n} not issued yet (${items.length} so far)`); + }); + return items[n - 1]; +} + +async function settle(fn: () => void) { + await act(async () => { + fn(); + await Promise.resolve(); + }); +} + +const answerLines = (read: LinesRead, ...lines: Array<{ id: string; label: string }>) => + settle(() => read.resolve({ data: lines })); +const fail = (read: Deferred, message: string) => settle(() => read.reject(new Error(message))); + +/** The grid's line inputs; the grid always trails one blank entry row. */ +const lineInputs = () => + Array.from(document.body.querySelectorAll('input[aria-label="Line"]')) as HTMLInputElement[]; +/** The lines the grid draws (its trailing blank row is not a line). */ +const shownLines = () => lineInputs().map((input) => input.value).filter((value) => value !== ''); +const saveButton = () => + Array.from(document.body.querySelectorAll('button')).find((b) => /^(Save|Saving…)$/.test(b.textContent ?? '')) as + | HTMLButtonElement + | undefined; +/** Whether the panel's banner reports `message` (the text the adapter threw). */ +const bannerShows = (message: string) => + Array.from(document.body.querySelectorAll('p')).some((p) => p.textContent === message); +const placeholder = () => document.body.querySelector('[data-testid="line-items-held-for-another-parent"]'); + +async function editFirstLine(to: string) { + await act(async () => { + fireEvent.change(lineInputs()[0], { target: { value: to } }); + }); +} + +async function clickSave() { + const button = saveButton(); + expect(button, 'no Save button').toBeTruthy(); + await act(async () => { + fireEvent.click(button!); + }); +} + +/** Mounts a panel on `p1`, lands `l1`, and edits it; Save is then enabled. */ +async function mountP1Edited() { + const ds = makeLinesDataSource(); + const view = mount(ds.dataSource, linesBlock()); + const first = await nth(ds.lineReads, 1); + expect(first.parentId).toBe('p1'); + await answerLines(first, { id: 'l1', label: 'p1 line' }); + await waitFor(() => expect(shownLines()).toEqual(['p1 line'])); + await editFirstLine('p1 line edited'); + await waitFor(() => expect(saveButton()?.disabled).toBe(false)); + return { ...ds, view }; +} + +/** `mountP1Edited`, then the swap to `p2` whose load fails. */ +async function mountP1EditedThenP2Failed() { + const held = await mountP1Edited(); + held.view.rerender(linesBlock({ parentId: 'p2' })); + const second = await nth(held.lineReads, 2); + expect(second.parentId).toBe('p2'); + await fail(second, 'p2 load failed'); + await waitFor(() => expect(bannerShows('p2 load failed')).toBe(true)); + return held; +} + +beforeEach(() => { + // Each failure below is logged by the panel; keep the run readable. + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); + cleanup(); +}); + +describe('LineItemsPanel refuses the lines it holds for another parent (objectui#10740)', () => { + it('p1 edited, then a swap to p2 whose load FAILS: no p1 line is drawn, Save is off, and no batch is sent', async () => { + const { dataSource } = await mountP1EditedThenP2Failed(); + + expect(shownLines(), "the previous parent's edited line stayed on screen").toEqual([]); + expect(lineInputs(), "the previous parent's lines were drawn editable").toHaveLength(0); + expect(placeholder()).toBeTruthy(); + expect(saveButton()?.disabled, 'Save stayed enabled over another parent’s lines').toBe(true); + + // A click on the disabled control reaches nothing. + await clickSave(); + await settle(() => {}); + expect(dataSource.batchTransaction).not.toHaveBeenCalled(); + expect(dataSource.find).toHaveBeenCalledTimes(2); + }); + + it('the same through a load that DECLINES: a swap to p2 with a refused filter leaves Save off and sends nothing', async () => { + const { dataSource, view } = await mountP1Edited(); + + // `$regex` has no lowering, so the panel's load declines before any fetch: + // this path never reaches the load's `catch`. + view.rerender(linesBlock({ parentId: 'p2', filter: { note: { $regex: 'a.c' } } })); + await waitFor(() => + expect(document.body.querySelector('[data-testid="line-items-malformed-filter"]')).toBeTruthy(), + ); + + expect(dataSource.find).toHaveBeenCalledTimes(1); + expect(shownLines()).toEqual([]); + expect(saveButton()?.disabled, 'Save stayed enabled after a declined load for another parent').toBe(true); + await clickSave(); + await settle(() => {}); + expect(dataSource.batchTransaction).not.toHaveBeenCalled(); + }); + + it('defence in depth: a click that reaches save while the held rows are another parent’s sends no batch', async () => { + const { dataSource } = await mountP1EditedThenP2Failed(); + const button = saveButton()!; + expect(button.disabled).toBe(true); + + // The button's own guard is bypassed on the DOM node, so the click reaches + // the panel's `save`. Its guard is what is measured here. + button.disabled = false; + await act(async () => { + fireEvent.click(button); + }); + await settle(() => {}); + + expect(dataSource.batchTransaction, 'save built a batch from another parent’s rows').not.toHaveBeenCalled(); + // `save` returned before it marked the panel saving. + expect(saveButton()?.textContent).toBe('Save'); + }); + + it('a line added under a FIRST load that failed, then a swap to p2 whose load fails: the added line is not drawn under p2 and Save is off', async () => { + const { dataSource, lineReads } = makeLinesDataSource(); + const view = mount(dataSource, linesBlock()); + await fail(await nth(lineReads, 1), 'p1 load failed'); + await waitFor(() => expect(bannerShows('p1 load failed')).toBe(true)); + // The first failure leaves the empty grid on screen (its entry row is + // there), as before this card. A line typed into it is p1's. + await waitFor(() => expect(lineInputs().length).toBeGreaterThan(0)); + await editFirstLine('new p1 line'); + await waitFor(() => expect(shownLines()).toEqual(['new p1 line'])); + expect(saveButton()?.disabled).toBe(false); + + view.rerender(linesBlock({ parentId: 'p2' })); + await fail(await nth(lineReads, 2), 'p2 load failed'); + await waitFor(() => expect(bannerShows('p2 load failed')).toBe(true)); + + expect(shownLines(), 'a line added under p1 was drawn under p2').toEqual([]); + expect(placeholder()).toBeTruthy(); + expect(saveButton()?.disabled).toBe(true); + await clickSave(); + await settle(() => {}); + expect(dataSource.batchTransaction).not.toHaveBeenCalled(); + }); +}); + +describe('what the rule leaves alone (objectui#10740 controls)', () => { + it('a swap to p2 whose load SUCCEEDS: p2’s lines replace p1’s, Save is off, and a save after editing p2’s line carries l2 under p2 and nothing of l1', async () => { + const { dataSource, lineReads, saves, view } = await mountP1Edited(); + + view.rerender(linesBlock({ parentId: 'p2' })); + const second = await nth(lineReads, 2); + expect(second.parentId).toBe('p2'); + await answerLines(second, { id: 'l2', label: 'p2 line' }); + await waitFor(() => expect(shownLines()).toEqual(['p2 line'])); + + expect(placeholder()).toBeNull(); + expect(saveButton()?.disabled, 'a clean panel offered Save').toBe(true); + + await editFirstLine('p2 line edited'); + await clickSave(); + const save = await nth(saves, 1); + expect(save.ops).toEqual([ + { object: 'po_line', action: 'update', id: 'l2', data: { label: 'p2 line edited', po: 'p2' } }, + ]); + expect(save.ops.some((op) => op.id === 'l1'), 'p1’s line rode in p2’s batch').toBe(false); + }); + + it('a RE-load of the SAME parent that fails keeps the held edits: the edited line is drawn editable, Save is on, and the save carries l1 under p1', async () => { + const { lineReads, saves, view } = await mountP1Edited(); + + // A load input other than the parent moves (the row cap), so the panel + // re-reads p1's lines while p1's edit is held; that read fails. + view.rerender(linesBlock({ limit: 200 })); + const reload = await nth(lineReads, 2); + expect(reload.parentId).toBe('p1'); + await fail(reload, 'p1 reload failed'); + await waitFor(() => expect(bannerShows('p1 reload failed')).toBe(true)); + + expect(shownLines(), 'a transient failure discarded the author’s unsaved edit').toEqual(['p1 line edited']); + expect(lineInputs().every((input) => !input.disabled)).toBe(true); + expect(placeholder()).toBeNull(); + expect(saveButton()?.disabled).toBe(false); + + await clickSave(); + const save = await nth(saves, 1); + expect(save.ops).toEqual([ + { object: 'po_line', action: 'update', id: 'l1', data: { label: 'p1 line edited', po: 'p1' } }, + ]); + }); + + it('after a refused state, a load for the CURRENT parent that commits takes the grid back', async () => { + const { lineReads, view } = await mountP1EditedThenP2Failed(); + expect(placeholder()).toBeTruthy(); + + // A load input other than the parent moves; p2's re-read succeeds. + view.rerender(linesBlock({ parentId: 'p2', limit: 200 })); + const third = await nth(lineReads, 3); + expect(third.parentId).toBe('p2'); + await answerLines(third, { id: 'l2', label: 'p2 line' }); + + await waitFor(() => expect(shownLines()).toEqual(['p2 line'])); + expect(placeholder()).toBeNull(); + expect(bannerShows('p2 load failed')).toBe(false); + expect(saveButton()?.disabled).toBe(true); + }); + + it('a swap BACK to p1 whose load fails again draws p1’s held edit again: the same-parent rule', async () => { + const { lineReads, view } = await mountP1EditedThenP2Failed(); + + view.rerender(linesBlock({ parentId: 'p1' })); + const third = await nth(lineReads, 3); + expect(third.parentId).toBe('p1'); + await fail(third, 'p1 load failed again'); + await waitFor(() => expect(bannerShows('p1 load failed again')).toBe(true)); + + expect(shownLines()).toEqual(['p1 line edited']); + expect(placeholder()).toBeNull(); + expect(saveButton()?.disabled).toBe(false); + }); + + it('a FIRST load that fails draws the banner and the empty grid, as before', async () => { + const { dataSource, lineReads } = makeLinesDataSource(); + mount(dataSource, linesBlock()); + await fail(await nth(lineReads, 1), 'p1 load failed'); + await waitFor(() => expect(bannerShows('p1 load failed')).toBe(true)); + + expect(placeholder()).toBeNull(); + // The grid is on screen: its entry row is drawn, no line is. + await waitFor(() => expect(lineInputs().length).toBeGreaterThan(0)); + expect(shownLines()).toEqual([]); + expect(saveButton()?.disabled, 'a clean panel offered Save').toBe(true); + }); +}); diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index 0c498ce007..47696a5819 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -201,6 +201,22 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch // raises it, and a run a newer one has superseded (another `parentId`, say, // while it was in flight) does neither. Held in a ref: nothing renders from it. const loadSeqRef = useRef(0); + // objectui#10740 — the parent the held `rows` / `original` belong to. Written + // in the same commit as the rows (a load that succeeds), and adopted by the + // CURRENT load that fails while nothing is held yet: the rows are then the + // initial empty set, and a line added in this parent's panel is this + // parent's. A load for ANOTHER parent that fails leaves it where it was, so + // the rows and this value keep agreeing after `parentId` has moved on. That + // disagreement is `heldForAnotherParent`: the grid is not drawn from those + // rows, the Save button is off, and `save` sends nothing. Before this card the + // failed load left the previous parent's edited lines drawn, editable and + // saveable, and Save wrote them under the CURRENT `parentId` (an edit batch's + // child rows carry it directly), moving another record's lines. The guard + // rather than a clear in the load's `catch`, because a load that DECLINES for + // the new parent (a refused filter, say) never reaches that `catch` and left + // the same Save enabled over the same rows. + const [rowsHeldFor, setRowsHeldFor] = useState(undefined); + const heldForAnotherParent = rowsHeldFor !== undefined && rowsHeldFor !== parentId; // Child object schema — used to strip computed / read-only columns from each // row before persisting (parity with the parent form's sanitize). Rows are @@ -351,13 +367,23 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch setRows(data.map((r) => ({ ...r }))); setOriginal(data.map((r) => ({ ...r }))); setDirty(false); + // The rows just committed are this run's parent's (objectui#10740). Same + // commit as the rows, so the two never disagree about whose they are. + setRowsHeldFor(parentId); // Once the CURRENT run commits, the rows on screen answer what the panel // asks for now, so no earlier failure describes it: not a failed load, // and not a failed save, whose edits these rows replace (objectui#10682, // the objectui#10578 rule: cleared on a commit, never when a load starts). if (isCurrent()) setError(null); } catch (e: any) { - if (isCurrent()) setError(e?.message || 'Failed to load line items'); + if (isCurrent()) { + setError(e?.message || 'Failed to load line items'); + // Nothing held yet: the empty rows on screen are this parent's from + // here on, so a line added under this failure is saved to it and to no + // parent the panel moves to later (objectui#10740). Rows already held + // for another parent stay that parent's: this failure adopts nothing. + setRowsHeldFor((held) => held ?? parentId); + } } finally { setLoading(false); } @@ -394,6 +420,11 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch // write contract is not the render tree's to keep: this is the same one-line // guard `load` takes, on the component's other data-layer entry point. if (!dataSource || !parentId || !schema.childObject) return; + // The held rows are another parent's (objectui#10740): the batch below + // would carry them under THIS `parentId` and move that parent's lines here. + // The Save button is off in this state; this is the write contract's own + // guard, on the function every caller of it reaches. + if (heldForAnotherParent) return; setSaving(true); setError(null); try { @@ -427,7 +458,7 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch } finally { setSaving(false); } - }, [dataSource, parentId, rows, original, schema, parentObject, load, childSchema]); + }, [dataSource, parentId, heldForAnotherParent, rows, original, schema, parentObject, load, childSchema]); const gridField = useMemo( () => @@ -456,7 +487,7 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch type="button" size="sm" onClick={save} - disabled={saving || loading || !dirty || !parentId} + disabled={saving || loading || !dirty || !parentId || heldForAnotherParent} > {saving ? 'Saving…' : 'Save'} @@ -514,6 +545,18 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch

Save the record first to add line items.

+ ) : heldForAnotherParent ? ( + /* The held rows were loaded for, or edited under, another parent + (objectui#10740): this parent's load failed or declined. They are + not drawn as this record's lines, editable or otherwise, and Save + is off above. The banner above this branch carries the failure; + a load for this parent that commits takes the grid back. */ +

+ This record’s line items have not been loaded. +

) : ( Date: Sun, 27 Sep 2026 04:22:05 +0000 Subject: [PATCH 2/6] test(plugin-form): drop an unused binding in the objectui#10740 pin Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../src/LineItemsPanel.heldForAnotherParent-10740.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx b/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx index 24a62299e5..741975d97b 100644 --- a/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx +++ b/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx @@ -277,7 +277,7 @@ describe('LineItemsPanel refuses the lines it holds for another parent (objectui describe('what the rule leaves alone (objectui#10740 controls)', () => { it('a swap to p2 whose load SUCCEEDS: p2’s lines replace p1’s, Save is off, and a save after editing p2’s line carries l2 under p2 and nothing of l1', async () => { - const { dataSource, lineReads, saves, view } = await mountP1Edited(); + const { lineReads, saves, view } = await mountP1Edited(); view.rerender(linesBlock({ parentId: 'p2' })); const second = await nth(lineReads, 2); From 11bf099f1182105637bed0c76acf5a44dcd286e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:32:39 +0000 Subject: [PATCH 3/6] test(plugin-form): the objectui#10740 pin drops the row that bypassed the Save button's disabled state React filters a click on a button whose props say disabled, whatever the DOM node says, so that row never reached `save` and passed for the wrong reason. The function's own guard is unreachable through the rendered UI while the button's guard holds; the header says so instead. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...sPanel.heldForAnotherParent-10740.test.tsx | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx b/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx index 741975d97b..f3afc414f9 100644 --- a/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx +++ b/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx @@ -41,11 +41,11 @@ * state it names. The banner is read through the message the adapter threw; the * placeholder through its test id, never its wording. * - * The one row that reaches `save` while the Save button is off does so by - * clearing the button's `disabled` on the DOM node before clicking: that is a - * host re-enabling the control, or a future caller of `save` that is not this - * button. It pins the function's own guard, which no click through the rendered - * UI can otherwise reach while the button's guard holds. + * The panel's `save` carries the same refusal as the Save button, on the + * function itself. No row here reaches it: the button and the function read the + * same render's predicate, so a click through the rendered UI cannot reach + * `save` while the button is off. It is the write contract's own guard, as the + * `childObject` guard on `save` is, and is not pinned by this file. */ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -232,24 +232,6 @@ describe('LineItemsPanel refuses the lines it holds for another parent (objectui expect(dataSource.batchTransaction).not.toHaveBeenCalled(); }); - it('defence in depth: a click that reaches save while the held rows are another parent’s sends no batch', async () => { - const { dataSource } = await mountP1EditedThenP2Failed(); - const button = saveButton()!; - expect(button.disabled).toBe(true); - - // The button's own guard is bypassed on the DOM node, so the click reaches - // the panel's `save`. Its guard is what is measured here. - button.disabled = false; - await act(async () => { - fireEvent.click(button); - }); - await settle(() => {}); - - expect(dataSource.batchTransaction, 'save built a batch from another parent’s rows').not.toHaveBeenCalled(); - // `save` returned before it marked the panel saving. - expect(saveButton()?.textContent).toBe('Save'); - }); - it('a line added under a FIRST load that failed, then a swap to p2 whose load fails: the added line is not drawn under p2 and Save is off', async () => { const { dataSource, lineReads } = makeLinesDataSource(); const view = mount(dataSource, linesBlock()); From 23f6bbc014b603a388f25b85330856452981889e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:11:23 +0000 Subject: [PATCH 4/6] fix(plugin-form): a line typed into the line-items grid before any load settled belongs to the parent on screen The panel's `onChange` now writes the rows' owner beside the rows when nothing is held yet, so a line typed under an adapter-less mount on p1 is not adopted by the first parent whose load later fails, and Save under p2 sends nothing. The owner is written wherever the rows are written; the comment, the changeset and the pin say so, and the pin gains the adapter-less row. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...0740-line-items-held-for-another-parent.md | 21 +++++----- ...sPanel.heldForAnotherParent-10740.test.tsx | 42 +++++++++++++++++++ packages/plugin-form/src/LineItemsPanel.tsx | 40 +++++++++++------- 3 files changed, 79 insertions(+), 24 deletions(-) diff --git a/.changeset/10740-line-items-held-for-another-parent.md b/.changeset/10740-line-items-held-for-another-parent.md index c34f15b9e0..2a46465e12 100644 --- a/.changeset/10740-line-items-held-for-another-parent.md +++ b/.changeset/10740-line-items-held-for-another-parent.md @@ -9,14 +9,15 @@ when a load commits. When the host moved the panel to another parent record without a remount and that parent's load failed, the previous parent's lines stayed on screen, edits included, drawn editable with Save enabled; Save then wrote them under the new parent's id, moving another record's lines to it. A load -that declined for the new parent (a refused filter) left the same Save enabled -over the same rows. +that declined for the new parent (a refused filter) drew the decline's own notice +but left the same Save enabled over the same held rows. -Now the panel records which parent its held rows belong to, in the same commit -as the rows. While that parent is not the current one, no held line is drawn (a -placeholder stands where the grid would be, under the load failure's banner), -the Save button is off, and a save sends nothing. A load for the current parent -that commits takes the grid back. Unchanged: a load for the new parent that -succeeds replaces the rows as before, and a re-load of the same parent that -fails keeps the author's unsaved edits drawn, editable and saveable under that -parent. +Now the panel records which parent its held rows belong to, wherever the rows are +written: a load's commit, or an edit made before any load settled. While that +parent is not the current one, no held line is drawn (after a failed load a +placeholder stands where the grid would be, under the failure's banner; after a +declined load the decline's own notice stands there, as before), the Save button +is off, and a save sends nothing. A load for the current parent that commits +takes the grid back. Unchanged: a load for the new parent that succeeds replaces +the rows as before, and a re-load of the same parent that fails keeps the +author's unsaved edits drawn, editable and saveable under that parent. diff --git a/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx b/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx index f3afc414f9..5b57a3b691 100644 --- a/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx +++ b/packages/plugin-form/src/LineItemsPanel.heldForAnotherParent-10740.test.tsx @@ -35,6 +35,12 @@ * keeps the author's unsaved edits drawn, editable and saveable — under * that parent — exactly as it did before this card. * + * The owner is written wherever the rows are written. A grid offered with no + * adapter (the load declines before any fetch) holds rows nobody loaded; a line + * typed into it takes the parent on screen as its owner at the edit, so the + * first parent whose load later fails cannot adopt it as its own (the + * adapter-less row below). + * * Rendered through the real `SchemaRenderer` and this package's own * registration of `record:line_items`. Every `find` and every `batchTransaction` * returns a promise the test settles by hand, so each assertion is made in the @@ -255,6 +261,42 @@ describe('LineItemsPanel refuses the lines it holds for another parent (objectui await settle(() => {}); expect(dataSource.batchTransaction).not.toHaveBeenCalled(); }); + + it('a line typed under an adapter-less mount on p1, a swap to p2 with no adapter, then the adapter arrives and p2’s load fails: no line drawn under p2, Save off, no batch', async () => { + const { dataSource, lineReads } = makeLinesDataSource(); + const tree = (b: Record, ds: unknown) => ( + + + + ); + // No adapter: the load declines before any fetch and the panel offers its + // empty grid (its entry row) for p1. A line typed there is p1's. + const view = render(tree(linesBlock(), null)); + await waitFor(() => expect(lineInputs().length).toBeGreaterThan(0)); + await editFirstLine('typed under p1'); + await waitFor(() => expect(shownLines()).toEqual(['typed under p1'])); + expect(dataSource.find).not.toHaveBeenCalled(); + + // The host moves to p2, still with no adapter. + view.rerender(tree(linesBlock({ parentId: 'p2' }), null)); + await settle(() => {}); + expect(shownLines(), 'a line typed under p1 was drawn under p2 before any load').toEqual([]); + expect(saveButton()?.disabled).toBe(true); + + // The adapter arrives, and p2's load fails. + view.rerender(tree(linesBlock({ parentId: 'p2' }), dataSource)); + const first = await nth(lineReads, 1); + expect(first.parentId).toBe('p2'); + await fail(first, 'p2 load failed'); + await waitFor(() => expect(bannerShows('p2 load failed')).toBe(true)); + + expect(shownLines(), 'a line typed under p1 was adopted by p2').toEqual([]); + expect(placeholder()).toBeTruthy(); + expect(saveButton()?.disabled).toBe(true); + await clickSave(); + await settle(() => {}); + expect(dataSource.batchTransaction).not.toHaveBeenCalled(); + }); }); describe('what the rule leaves alone (objectui#10740 controls)', () => { diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index 47696a5819..d527922ad3 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -202,19 +202,21 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch // while it was in flight) does neither. Held in a ref: nothing renders from it. const loadSeqRef = useRef(0); // objectui#10740 — the parent the held `rows` / `original` belong to. Written - // in the same commit as the rows (a load that succeeds), and adopted by the - // CURRENT load that fails while nothing is held yet: the rows are then the - // initial empty set, and a line added in this parent's panel is this - // parent's. A load for ANOTHER parent that fails leaves it where it was, so - // the rows and this value keep agreeing after `parentId` has moved on. That - // disagreement is `heldForAnotherParent`: the grid is not drawn from those - // rows, the Save button is off, and `save` sends nothing. Before this card the - // failed load left the previous parent's edited lines drawn, editable and - // saveable, and Save wrote them under the CURRENT `parentId` (an edit batch's - // child rows carry it directly), moving another record's lines. The guard - // rather than a clear in the load's `catch`, because a load that DECLINES for - // the new parent (a refused filter, say) never reaches that `catch` and left - // the same Save enabled over the same rows. + // wherever the rows are written: beside a load's commit (that run's parent), + // and beside an edit made while nothing is held yet (a grid offered with no + // adapter, so no load has settled: the parent on screen). The CURRENT load + // that fails while nothing is held adopts its parent too, since the empty + // rows it leaves on screen are that parent's. A load for ANOTHER parent that + // fails or declines leaves it where it was, so the rows and this value keep + // agreeing after `parentId` has moved on. That disagreement is + // `heldForAnotherParent`: the grid is not drawn from those rows, the Save + // button is off, and `save` sends nothing. Before this card the failed load + // left the previous parent's edited lines drawn, editable and saveable, and + // Save wrote them under the CURRENT `parentId` (an edit batch's child rows + // carry it directly), moving another record's lines. The guard rather than a + // clear in the load's `catch`, because a load that DECLINES for the new parent + // (a refused filter, say) never reaches that `catch` and left the same Save + // enabled over the same held rows. const [rowsHeldFor, setRowsHeldFor] = useState(undefined); const heldForAnotherParent = rowsHeldFor !== undefined && rowsHeldFor !== parentId; @@ -405,7 +407,17 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch const onChange = useCallback((next: Record[]) => { setRows(next); setDirty(true); - }, []); + // An edit made while nothing is held yet gives the rows their owner, the + // parent on screen (objectui#10740). The only grid offered before a load has + // settled is the one drawn when the load declined for want of an adapter; + // a line typed into it stayed ownerless, and the first parent whose load + // later failed adopted it and could save it as its own. Written in the same + // handler as the rows rather than in that decline, so that no other way of + // offering the grid before a settle (a superseded run releasing `loading`, + // objectui#10712's surface) can leave an edited row without an owner. Rows + // already held keep their parent. + setRowsHeldFor((held) => held ?? parentId); + }, [parentId]); const save = useCallback(async () => { // `childObject` joins this guard for the same reason both reads decline From ef16b93486250060287b13345d6f031fbf031778 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:20:29 +0000 Subject: [PATCH 5/6] fix(plugin-form): narrow parentId before the onChange owner updater closes over it The edit-owner adoption `setRowsHeldFor((held) => held ?? parentId)` widened the setter's `string | undefined` state to include `null`: parentId is `string | null | undefined` in onChange, with no load guard above it to narrow it, so tsc and the dts build rejected it. Capture the narrowed value in an `if (parentId)` block; the grid is only offered with a parent bound, so the guard loses no coverage. Semantics unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- packages/plugin-form/src/LineItemsPanel.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index d527922ad3..5884928c7a 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -415,8 +415,16 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch // handler as the rows rather than in that decline, so that no other way of // offering the grid before a settle (a superseded run releasing `loading`, // objectui#10712's surface) can leave an edited row without an owner. Rows - // already held keep their parent. - setRowsHeldFor((held) => held ?? parentId); + // already held keep their parent. `parentId` is narrowed to a string before + // the updater closes over it: the setter's state is `string | undefined`, + // and `parentId` is `string | null | undefined` here (there is no `load` + // guard above this handler to narrow it), so a bare `held ?? parentId` + // would widen the updater's result to include `null`. A grid is only + // offered with a parent bound anyway (the `!parentId` branch draws no grid). + if (parentId) { + const owner = parentId; + setRowsHeldFor((held) => held ?? owner); + } }, [parentId]); const save = useCallback(async () => { From 854e0922cc22bbb21d57d325898eb70f13bbf7c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 06:07:16 +0000 Subject: [PATCH 6/6] docs(plugin-form): the objectui#10740 changeset and onChange comment name each refused state precisely Prose and comment only; no code token moves. The changeset parenthetical now separates the three refused states (failed load: placeholder under the banner; refused-filter decline: that filter's own notice; lost-adapter decline: the placeholder alone), and the onChange comment no longer claims the adapter-less grid is the only grid offered before a load settles. Frontmatter byte-identical. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10740-line-items-held-for-another-parent.md | 5 +++-- packages/plugin-form/src/LineItemsPanel.tsx | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.changeset/10740-line-items-held-for-another-parent.md b/.changeset/10740-line-items-held-for-another-parent.md index 2a46465e12..488473bd69 100644 --- a/.changeset/10740-line-items-held-for-another-parent.md +++ b/.changeset/10740-line-items-held-for-another-parent.md @@ -16,8 +16,9 @@ Now the panel records which parent its held rows belong to, wherever the rows ar written: a load's commit, or an edit made before any load settled. While that parent is not the current one, no held line is drawn (after a failed load a placeholder stands where the grid would be, under the failure's banner; after a -declined load the decline's own notice stands there, as before), the Save button -is off, and a save sends nothing. A load for the current parent that commits +refused-filter decline that filter's own notice stands there, as before; after a +lost-adapter decline the placeholder stands alone), the Save button is off, and a +save sends nothing. A load for the current parent that commits takes the grid back. Unchanged: a load for the new parent that succeeds replaces the rows as before, and a re-load of the same parent that fails keeps the author's unsaved edits drawn, editable and saveable under that parent. diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index 5884928c7a..42ebd9123f 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -408,10 +408,10 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch setRows(next); setDirty(true); // An edit made while nothing is held yet gives the rows their owner, the - // parent on screen (objectui#10740). The only grid offered before a load has - // settled is the one drawn when the load declined for want of an adapter; - // a line typed into it stayed ownerless, and the first parent whose load - // later failed adopted it and could save it as its own. Written in the same + // parent on screen (objectui#10740). The grid this was found on is the one + // drawn when the load declined for want of an adapter; a line typed into it + // stayed ownerless, and the first parent whose load later failed adopted it + // and could save it as its own. Written in the same // handler as the rows rather than in that decline, so that no other way of // offering the grid before a settle (a superseded run releasing `loading`, // objectui#10712's surface) can leave an edited row without an owner. Rows