Fix self-inflicted edit conflicts: dialogs no longer pin a stale node version

Properties, Raw JSON and AI proposals sent the version captured when the
dialog opened. Any other edit to the node meanwhile (style chip, drag handle,
inline text, child insert/move) bumped it, so the save 409'd against the
user's own work. Save through ctrl.saveData instead: compare only the keys
being written against the current node (a real conflict is a key someone else
changed) and send no version. 409 toasts now show the server's reason.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-11-01 13:57:00 +01:00
1 parent 3fdf415847
commit d3fb060016
5 files changed
+55 -8

No files matched your search

+6 -3
View File
@@ -8,8 +8,10 @@ const node = { id: 'n1', dataType: 'hero', version: 3, data: { title: 'Hello wro
function fakeCtrl() {
const setData = vi.fn(async () => ({}))
const saveData = vi.fn(async () => ({}))
return {
setData,
saveData,
ctrl: {
store: { get: () => ({ selected: 'n1', blocks: [{ id: 'n1', type: 'hero', depth: 0 }] }) },
block: (id: string) => (id === 'n1' ? { id } : undefined),
@@ -19,6 +21,7 @@ function fakeCtrl() {
schema: async () => schema,
labelFor: () => 'Hero',
setData,
saveData,
} as any,
}
}
@@ -80,10 +83,10 @@ describe('runAgent + tools', () => {
})
describe('applyProposal', () => {
it('rebuilds the nested patch from the fresh node and saves with its version', async () => {
const { ctrl, setData } = fakeCtrl()
it('rebuilds the nested patch from the fresh node and saves it through the key-level conflict check', async () => {
const { ctrl, saveData } = fakeCtrl()
await applyProposal(ctrl, { id: 1, kind: 'set', nodeId: 'n1', label: 'Hero', path: 'links.0.text', before: 'a', after: 'b' })
expect(setData).toHaveBeenCalledWith('n1', { links: [{ text: 'b' }] }, { version: 3 })
expect(saveData).toHaveBeenCalledWith('n1', node.data, { links: [{ text: 'b' }] })
})
})
+20 -1
View File
@@ -100,7 +100,7 @@ describe('on-page editor against the mock CMS', () => {
expect(h.cms.graph.node('s1').data).toMatchObject({ title: 'Hello', tone: 'dark', padded: true })
expect(byId('s1').querySelector('.sec__title')!.textContent).toBe('Hello')
const edit = h.editor.ctrl.store.get().log.find((l) => l.url === '/api/edit')!
expect((edit.request as any).ops[0]).toMatchObject({ op: 'set', id: 's1', data: { title: 'Hello' }, version: 1 })
expect((edit.request as any).ops[0]).toMatchObject({ op: 'set', id: 's1', data: { title: 'Hello' } })
})
it('array editors save through the dialog as a minimal diff, and invalid data blocks Save', async () => {
@@ -126,6 +126,25 @@ describe('on-page editor against the mock CMS', () => {
expect(Object.keys(op.ops[0].data)).toEqual(['links']) // companyName, usps, social... untouched
})
it('edits to OTHER fields of the node while the dialog is open do not conflict (the dialog does not pin a version)', async () => {
h = await setup()
await selectBlock('s1')
await h.press('Properties')
// style chip / drag handle / inline edit on the same node bump its version under the open dialog
await h.editor.ctrl.setData('s1', { tone: 'accent' })
await h.editor.ctrl.setData('s1', { padded: false })
const input = h.shadow.querySelector<HTMLInputElement>('[data-path="title"] input')!
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(input, 'Mine')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => void h.shadow.querySelector<HTMLElement>('.cms-btn--primary')!.click())
await h.settle()
expect(h.cms.graph.node('s1').data).toMatchObject({ title: 'Mine', tone: 'accent', padded: false })
expect(h.editor.ctrl.store.get().toast?.kind).not.toBe('error')
})
it('a stale version shows a conflict instead of overwriting', async () => {
h = await setup()
await selectBlock('s1')