diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index acfacc43a41..5dbac920e7d 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -397,7 +397,10 @@ export default { this.initEditor(); this.json = this.editor.getJSON().content; - this.html = this.editor.getHTML(); + + if (this.config.reading_time) { + this.html = this.editor.getHTML(); + } this.$nextTick(() => this.mounted = true); @@ -893,7 +896,10 @@ export default { if (countNodes(oldJson) !== countNodes(newJson)) this.debounceNextUpdate = false; this.json = newJson; - this.html = this.editor.getHTML(); + + if (this.config.reading_time) { + this.html = this.editor.getHTML(); + } }, onCreate: ({ editor }) => { const state = editor.view.state; diff --git a/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js new file mode 100644 index 00000000000..6d44c4c6183 --- /dev/null +++ b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js @@ -0,0 +1,119 @@ +import { mount } from '@vue/test-utils'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { Editor } from '@tiptap/vue-3'; +import * as Globals from '@/bootstrap/globals'; +import BardFieldtype from '@/components/fieldtypes/bard/BardFieldtype.vue'; +import { containerContextKey } from '@/components/ui/Publish/Container.vue'; + +Object.keys(Globals).forEach((fn) => (window[fn] = Globals[fn])); +window.cp_url = (url) => url; +window.__ = (key, replacements = {}) => + Object.entries(replacements).reduce((string, [key, value]) => string.replace(`:${key}`, value), key); +window.__n = (key, count) => key.split('|')[count === 1 ? 0 : 1].replace(/:count/g, count); + +// Five words, twenty four characters. +const value = [{ type: 'paragraph', content: [{ type: 'text', text: 'One two three four five.' }] }]; + +async function mountField(config = {}) { + const wrapper = mount(BardFieldtype, { + props: { + value, + handle: 'content', + config: { sets: [], buttons: ['bold'], ...config }, + meta: { existing: {}, collapsed: [], defaults: {}, new: {} }, + }, + global: { + stubs: { + 'publish-field-fullscreen-header': true, + 'ui-icon': true, + 'set-picker': true, + }, + mocks: { + $bard: { extensionCallbacks: [], extensionReplacementCallbacks: [], buttonCallbacks: [] }, + $events: { $on: () => {}, $off: () => {} }, + }, + provide: { + [containerContextKey]: { + values: { value: {} }, + previews: { value: {} }, + errors: { value: {} }, + setFieldValue: vi.fn(), + setFieldMeta: vi.fn(), + }, + }, + }, + }); + + await vi.waitUntil(() => wrapper.vm.editor); + await wrapper.vm.$nextTick(); + + return wrapper; +} + +async function type(wrapper, text) { + wrapper.vm.editor.commands.insertContent(text); + await wrapper.vm.$nextTick(); +} + +beforeEach(() => { + window.Statamic = { + $components: { has: () => true, register: () => {} }, + $fieldActions: { get: () => [] }, + $commandPalette: { preventIf: () => {}, add: () => {} }, + $config: { get: () => null }, + }; +}); + +afterEach(() => vi.restoreAllMocks()); + +// Serializing the whole document is expensive, and the reading time is the only thing that needs it. +test('the document is not serialized to html on mount when reading time is disabled', async () => { + const getHTML = vi.spyOn(Editor.prototype, 'getHTML'); + + const wrapper = await mountField({ reading_time: false }); + + expect(getHTML).not.toHaveBeenCalled(); + expect(wrapper.vm.html).toBe(null); +}); + +test('the document is serialized to html on mount when reading time is enabled', async () => { + const wrapper = await mountField({ reading_time: true }); + + expect(wrapper.vm.html).toContain('One two three four five.'); +}); + +test('the document is not serialized to html on update when reading time is disabled', async () => { + const wrapper = await mountField({ reading_time: false }); + const getHTML = vi.spyOn(wrapper.vm.editor, 'getHTML'); + + await type(wrapper, ' six seven eight.'); + + expect(getHTML).not.toHaveBeenCalled(); +}); + +test('the document is serialized to html on update when reading time is enabled', async () => { + const wrapper = await mountField({ reading_time: true }); + const getHTML = vi.spyOn(wrapper.vm.editor, 'getHTML'); + + await type(wrapper, ' six seven eight.'); + + expect(getHTML).toHaveBeenCalled(); + expect(wrapper.vm.html).toContain('six seven eight.'); +}); + +test('reading time is rendered when enabled', async () => { + const wrapper = await mountField({ reading_time: true }); + + await type(wrapper, ' six seven eight.'); + + expect(wrapper.vm.readingTime).toMatch(/^\d{2}:\d{2}$/); + expect(wrapper.find('.bard-footer-toolbar').text()).toContain(wrapper.vm.readingTime); +}); + +// The counts come from the character count extension's storage rather than the serialized +// html, so they are rendered whether or not the html is being kept up to date. +test('word and character counts are rendered when reading time is disabled', async () => { + const wrapper = await mountField({ reading_time: false, word_count: true, character_limit: 100 }); + + expect(wrapper.find('.bard-footer-toolbar').text()).toBe('5 words, 24/100 characters'); +});