diff --git a/resources/js/components/fieldtypes/bard/Set.vue b/resources/js/components/fieldtypes/bard/Set.vue index ee293fa8e9c..7019a1dbf9e 100644 --- a/resources/js/components/fieldtypes/bard/Set.vue +++ b/resources/js/components/fieldtypes/bard/Set.vue @@ -443,9 +443,11 @@ export default { () => data_get(this.publishContainer.values.value, this.fieldPathPrefix), (values) => { if (! values) return; - if (JSON.stringify(values) === JSON.stringify(this.node.attrs.values)) return; + const json = JSON.stringify(values); + if (json === JSON.stringify(this.node.attrs.values)) return; - this.updateAttributes({ values }); + // A copy, so later edits to the container's object can't change the node without a transaction. + this.updateAttributes({ values: JSON.parse(json) }); }, { deep: true } ); diff --git a/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js index c2743f020b1..6e6a427af6e 100644 --- a/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js @@ -1,7 +1,8 @@ import { mount } from '@vue/test-utils'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; -import { ref } from 'vue'; +import { nextTick, ref } from 'vue'; import { Editor } from '@tiptap/vue-3'; +import { closeHistory } from '@tiptap/pm/history'; import * as Globals from '@/bootstrap/globals'; import BardFieldtype from '@/components/fieldtypes/bard/BardFieldtype.vue'; import Provider from '@/components/portals/Provider.vue'; @@ -83,6 +84,10 @@ async function mountFieldInContainer(config, initialValue) { return { wrapper, values }; } +function waitForDebounce() { + return new Promise((resolve) => setTimeout(resolve, UPDATE_DEBOUNCE_MS + 50)); +} + async function type(wrapper, text) { wrapper.vm.editor.commands.insertContent(text); await wrapper.vm.$nextTick(); @@ -216,6 +221,45 @@ test('the editor is rebuilt when the value changes while the editor is not focus expect(wrapper.vm.editor.getJSON().content).toEqual([set('set-a', 'Changed'), { type: 'paragraph' }]); }); +async function editSetLabel(values, label) { + data_set(values.value, 'content.1.attrs.values.label', label); + await nextTick(); +} + +test('set field edits made while an update is debounced are kept', async () => { + const { wrapper, values } = await mountFieldInContainer({ sets }, [ + { type: 'paragraph', content: [{ type: 'text', text: 'One' }] }, + set('set-a', 'A'), + ]); + + await editSetLabel(values, 'AB'); + await editSetLabel(values, 'ABC'); + await waitForDebounce(); + + expect(values.value.content[1].attrs.values.label).toBe('ABC'); + expect(wrapper.vm.editor.getJSON().content[1].attrs.values.label).toBe('ABC'); +}); + +test('each set field edit can be undone', async () => { + const { wrapper, values } = await mountFieldInContainer({ sets }, [ + { type: 'paragraph', content: [{ type: 'text', text: 'One' }] }, + set('set-a', 'A'), + ]); + const editor = wrapper.vm.editor; + + for (const label of ['AB', 'ABC', 'ABCD']) { + await editSetLabel(values, label); + await waitForDebounce(); + editor.view.dispatch(closeHistory(editor.state.tr)); + } + + editor.commands.undo(); + await waitForDebounce(); + + expect(editor.getJSON().content[1].attrs.values.label).toBe('ABC'); + expect(values.value.content[1].attrs.values.label).toBe('ABC'); +}); + test('the editor is rebuilt when the value omits attribute defaults but changes content', async () => { const wrapper = await mountField({ sets }, [set('set-a', 'A'), { type: 'paragraph' }]);