diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 13737068d3..6fd6077b3a 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -58,6 +58,7 @@ Changelog * Fix: Show not allowed extension in error message (Sahil Jangra) * Fix: Fix focal point chooser when localization enabled (Sébastien Corbin) * Fix: Ensure that system checks for `WAGTAIL_DATE_FORMAT`, `WAGTAIL_DATETIME_FORMAT` and `WAGTAIL_TIME_FORMAT` take `FORMAT_MODULE_PATH` into account (Sébastien Corbin) + * Fix: Prevent rich text fields inside choosers from being duplicated when opened repeatedly (Sage Abdullah) * Docs: Remove duplicate section on frontend caching proxies from performance page (Jake Howard) * Docs: Document `restriction_type` field on PageViewRestriction (Shlomo Markowitz) * Docs: Document Wagtail's bug bounty policy (Jake Howard) diff --git a/client/src/entrypoints/admin/draftail.js b/client/src/entrypoints/admin/draftail.js index 6534c43c7a..950ff8c9f5 100644 --- a/client/src/entrypoints/admin/draftail.js +++ b/client/src/entrypoints/admin/draftail.js @@ -11,52 +11,59 @@ import draftail, { * Entry point loaded when the Draftail editor is in use. */ -// Expose Draftail package as a global. -window.Draftail = Draftail; -// Expose module as a global. -window.draftail = draftail; +// This file is included and run when there's a DraftailRichTextArea widget in the response. +// Normally this is only included once in the initial page load, but it may be included +// more than once when there's an AJAX response that includes the widget, e.g. in choosers. +// Ensure we only run the initialization code once. +// https://github.com/wagtail/wagtail/issues/12002 +if (!window.Draftail || !window.draftail) { + // Expose Draftail package as a global. + window.Draftail = Draftail; + // Expose module as a global. + window.draftail = draftail; -/** @deprecated RemovedInWagtail70 - Ensure that any third party packages that use global.chooserUrls can still append to this global object until support is removed. */ -window.chooserUrls = global.chooserUrls || {}; + /** @deprecated RemovedInWagtail70 - Ensure that any third party packages that use global.chooserUrls can still append to this global object until support is removed. */ + window.chooserUrls = global.chooserUrls || {}; -// Plugins for the built-in entities. -const entityTypes = [ - { - type: 'DOCUMENT', - source: draftail.DocumentModalWorkflowSource, - decorator: Document, - }, - { - type: 'LINK', - source: draftail.LinkModalWorkflowSource, - decorator: Link, - onPaste: onPasteLink, - }, - { - type: 'IMAGE', - source: draftail.ImageModalWorkflowSource, - block: ImageBlock, - }, - { - type: 'EMBED', - source: draftail.EmbedModalWorkflowSource, - block: EmbedBlock, - }, -]; + // Plugins for the built-in entities. + const entityTypes = [ + { + type: 'DOCUMENT', + source: draftail.DocumentModalWorkflowSource, + decorator: Document, + }, + { + type: 'LINK', + source: draftail.LinkModalWorkflowSource, + decorator: Link, + onPaste: onPasteLink, + }, + { + type: 'IMAGE', + source: draftail.ImageModalWorkflowSource, + block: ImageBlock, + }, + { + type: 'EMBED', + source: draftail.EmbedModalWorkflowSource, + block: EmbedBlock, + }, + ]; -entityTypes.forEach((type) => draftail.registerPlugin(type, 'entityTypes')); + entityTypes.forEach((type) => draftail.registerPlugin(type, 'entityTypes')); -/** - * Initialize a Draftail editor on a given element when the w-draftail:init event is fired. - */ -document.addEventListener('w-draftail:init', ({ detail = {}, target }) => { - const id = target.id; + /** + * Initialize a Draftail editor on a given element when the w-draftail:init event is fired. + */ + document.addEventListener('w-draftail:init', ({ detail = {}, target }) => { + const id = target.id; - if (!id) { - // eslint-disable-next-line no-console - console.error('`w-draftail:init` event must have a target with an id.'); - return; - } + if (!id) { + // eslint-disable-next-line no-console + console.error('`w-draftail:init` event must have a target with an id.'); + return; + } - window.draftail.initEditor(`#${id}`, detail, document.currentScript); -}); + window.draftail.initEditor(`#${id}`, detail, document.currentScript); + }); +} diff --git a/client/src/entrypoints/admin/draftail.test.js b/client/src/entrypoints/admin/draftail.test.js index 2484e85740..5a22615f9d 100644 --- a/client/src/entrypoints/admin/draftail.test.js +++ b/client/src/entrypoints/admin/draftail.test.js @@ -62,7 +62,7 @@ describe('Calling initEditor via event dispatching', () => { jest.resetAllMocks(); }); - it.only('should support creating a new editor with event dispatching', async () => { + it('should support creating a new editor with event dispatching', async () => { expect(window.draftail.initEditor).not.toHaveBeenCalled(); document.body.innerHTML = '
'; @@ -106,6 +106,59 @@ describe('Calling initEditor via event dispatching', () => { afterAll(() => { console.error.mockRestore(); window.draftail.initEditor.mockRestore(); - /* eslint-enable no-console */ + }); +}); + +describe('importing the module multiple times', () => { + it('should run the init function once if the script is included multiple times', async () => { + // Imported at the top level (similar to the initial page load) + const firstDraftail = window.draftail; + + // Subsequent imports (e.g. in AJAX responses) + jest.isolateModules(() => { + require('./draftail'); + }); + + // Should be the same instance + const secondDraftail = window.draftail; + expect(secondDraftail).toBe(firstDraftail); + + jest.isolateModules(() => { + require('./draftail'); + }); + + const thirdDraftail = window.draftail; + expect(thirdDraftail).toBe(firstDraftail); + + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(window.draftail, 'initEditor').mockImplementation(() => {}); + + expect(window.draftail.initEditor).not.toHaveBeenCalled(); + + document.body.innerHTML = '
'; + + document.getElementById('editor').dispatchEvent( + new CustomEvent('w-draftail:init', { + bubbles: true, + cancelable: false, + detail: { some: 'detail' }, + }), + ); + + expect(console.error).toHaveBeenCalledTimes(0); + + // Should only be called once. If the script isn't written correctly, then + // the window.draftail object would be a new instance every time, or + // the initEditor function would be called multiple times. + expect(window.draftail.initEditor).toHaveBeenCalledTimes(1); + expect(window.draftail.initEditor).toHaveBeenLastCalledWith( + '#editor', + { some: 'detail' }, + null, + ); + + /* eslint-enable no-console */ + jest.clearAllMocks(); + jest.restoreAllMocks(); }); }); diff --git a/docs/releases/6.2.md b/docs/releases/6.2.md index bc2d93d6d3..791137f115 100644 --- a/docs/releases/6.2.md +++ b/docs/releases/6.2.md @@ -85,6 +85,7 @@ StreamField definitions within migrations are now represented in a more compact * Show not allowed extension in error message (Sahil Jangra) * Fix focal point chooser when localization enabled (Sébastien Corbin) * Ensure that system checks for `WAGTAIL_DATE_FORMAT`, `WAGTAIL_DATETIME_FORMAT` and `WAGTAIL_TIME_FORMAT` take `FORMAT_MODULE_PATH` into account (Sébastien Corbin) + * Prevent rich text fields inside choosers from being duplicated when opened repeatedly (Sage Abdullah) ### Documentation