diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index eb32269050e..23a7e46b1b4 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -895,7 +895,12 @@ export default { return count; }; - if (countNodes(oldJson) !== countNodes(newJson)) this.debounceNextUpdate = false; + // Sets look up their values by index, so moving them can't wait for the debounce. + const setPositions = (nodes) => (nodes ?? []).map((node) => (node.type === 'set' ? node.attrs.id : '')).join(); + + if (countNodes(oldJson) !== countNodes(newJson) || setPositions(oldJson) !== setPositions(newJson)) { + this.debounceNextUpdate = false; + } this.json = newJson; diff --git a/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js index fc948362c00..c2743f020b1 100644 --- a/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js @@ -1,10 +1,12 @@ import { mount } from '@vue/test-utils'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { ref } from 'vue'; import { Editor } from '@tiptap/vue-3'; import * as Globals from '@/bootstrap/globals'; import BardFieldtype from '@/components/fieldtypes/bard/BardFieldtype.vue'; import Provider from '@/components/portals/Provider.vue'; import { containerContextKey } from '@/components/ui/Publish/Container.vue'; +import { UPDATE_DEBOUNCE_MS } from '@/components/fieldtypes/constants.js'; Object.keys(Globals).forEach((fn) => (window[fn] = Globals[fn])); window.cp_url = (url) => url; @@ -21,7 +23,11 @@ function set(id, label) { return { type: 'set', attrs: { id, enabled: true, values: { type: 'card', label } } }; } -async function mountField(config = {}, initialValue = value) { +function listItem(text) { + return { type: 'listItem', content: [{ type: 'paragraph', ...(text && { content: [{ type: 'text', text }] }) }] }; +} + +async function mountField(config = {}, initialValue = value, container = {}) { const wrapper = mount(BardFieldtype, { props: { value: initialValue, @@ -51,6 +57,7 @@ async function mountField(config = {}, initialValue = value) { errors: { value: {} }, setFieldValue: vi.fn(), setFieldMeta: vi.fn(), + ...container, }, }, }, @@ -62,6 +69,20 @@ async function mountField(config = {}, initialValue = value) { return wrapper; } +async function mountFieldInContainer(config, initialValue) { + const values = ref({ content: initialValue }); + const wrapper = await mountField(config, initialValue, { values }); + + await wrapper.setProps({ + 'onUpdate:value': (value) => { + data_set(values.value, 'content', value); + wrapper.setProps({ value }); + }, + }); + + return { wrapper, values }; +} + async function type(wrapper, text) { wrapper.vm.editor.commands.insertContent(text); await wrapper.vm.$nextTick(); @@ -212,3 +233,40 @@ test('the editor is cleared when an empty value arrives', async () => { expect(wrapper.vm.editor.getJSON().content).toEqual([{ type: 'paragraph' }]); }); + +test('sets keep their values when an edit moves them without changing the node count', async () => { + const { wrapper, values } = await mountFieldInContainer({ sets, buttons: ['unorderedlist'] }, [ + { type: 'bulletList', content: [listItem('One'), listItem(), listItem('Two')] }, + set('set-a', 'A'), + { type: 'paragraph', content: [{ type: 'text', text: 'Between' }] }, + set('set-b', 'B'), + ]); + const editor = wrapper.vm.editor; + + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.childCount === 0) editor.commands.setTextSelection(pos + 1); + }); + editor.commands.keyboardShortcut('Enter'); + await new Promise((resolve) => setTimeout(resolve, UPDATE_DEBOUNCE_MS + 50)); + + const labels = (nodes) => nodes.filter((node) => node.type === 'set').map((node) => node.attrs.values.label); + expect(editor.getJSON().content.map((node) => node.type)).toEqual(['bulletList', 'paragraph', 'bulletList', 'set', 'paragraph', 'set']); + expect(labels(editor.getJSON().content)).toEqual(['A', 'B']); + expect(labels(values.value.content)).toEqual(['A', 'B']); +}); + +test('typing is still debounced when it does not move any sets', async () => { + const { wrapper, values } = await mountFieldInContainer({ sets }, [ + { type: 'paragraph', content: [{ type: 'text', text: 'One' }] }, + set('set-a', 'A'), + ]); + + wrapper.vm.editor.commands.insertContentAt(4, ' two'); + await wrapper.vm.$nextTick(); + + expect(values.value.content[0].content[0].text).toBe('One'); + + await new Promise((resolve) => setTimeout(resolve, UPDATE_DEBOUNCE_MS + 50)); + + expect(values.value.content[0].content[0].text).toBe('One two'); +});