diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 1210738842..83bc23b560 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -33,6 +33,7 @@ Changelog * Fix: Additional login form fields from `WAGTAILADMIN_USER_LOGIN_FORM` are now rendered correctly (Michael Karamuth) * Fix: Icon only button styling issue on small devices where height would not be set correctly (Vu Pham) * Fix: Add padding to the Draftail editor to ensure `ol` items are not cut off (Khanh Hoang) + * Fix: Prevent opening choosers multiple times for Image, Page, Document, Snippet (LB (Ben Johnston)) 2.15.2 (xx.xx.xxxx) - IN DEVELOPMENT diff --git a/client/src/entrypoints/admin/__snapshots__/modal-workflow.test.js.snap b/client/src/entrypoints/admin/__snapshots__/modal-workflow.test.js.snap new file mode 100644 index 0000000000..8f4190673e --- /dev/null +++ b/client/src/entrypoints/admin/__snapshots__/modal-workflow.test.js.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`modal-workflow should generate a modal when called that is removed when closed 1`] = ` +HTMLCollection [ + , +] +`; diff --git a/client/src/entrypoints/admin/modal-workflow.js b/client/src/entrypoints/admin/modal-workflow.js index d2395aa9bb..e23c19797e 100644 --- a/client/src/entrypoints/admin/modal-workflow.js +++ b/client/src/entrypoints/admin/modal-workflow.js @@ -25,6 +25,10 @@ function ModalWorkflow(opts) { /* remove any previous modals before continuing (closing doesn't remove them from the dom) */ $('body > .modal').remove(); + // disable the trigger element so it cannot be clicked twice while modal is loading + self.triggerElement = document.activeElement; + self.triggerElement.setAttribute('disabled', true); + // set default contents of container const iconClose = ''; const container = $(''); @@ -33,6 +37,17 @@ function ModalWorkflow(opts) { $('body').append(container); container.modal('hide'); + // add listener - once modal is about to be hidden, re-enable the trigger + container.on('hide.bs.modal', () => { + self.triggerElement.removeAttribute('disabled'); + }); + + // add listener - once modal is fully hidden (closed & css transitions end) - re-focus on trigger and remove from DOM + container.on('hidden.bs.modal', function () { + self.triggerElement.focus(); + container.remove(); + }); + self.body = container.find('.modal-body'); self.loadUrl = function (url, urlParams) { diff --git a/client/src/entrypoints/admin/modal-workflow.test.js b/client/src/entrypoints/admin/modal-workflow.test.js new file mode 100644 index 0000000000..d28e2210c1 --- /dev/null +++ b/client/src/entrypoints/admin/modal-workflow.test.js @@ -0,0 +1,133 @@ +import $ from 'jquery'; +window.$ = $; +window.jQuery = $; + +import '../../../../wagtail/admin/static_src/wagtailadmin/js/vendor/bootstrap-modal'; +import './modal-workflow'; + +$.get = jest.fn().mockImplementation((url, data, cb) => { + cb(JSON.stringify({ html: `
${url}
`, data, step: 'start' })); + return { fail: jest.fn() }; +}); + +describe('modal-workflow', () => { + beforeEach(() => { + document.body.innerHTML = + '
'; + }); + + it('exposes module as global', () => { + expect(window.ModalWorkflow).toBeDefined(); + }); + + it('should generate a modal when called that is removed when closed', () => { + let modalWorkflow; + + const openModal = () => { + // eslint-disable-next-line new-cap + modalWorkflow = window.ModalWorkflow({ url: 'path/to/endpoint' }); + }; + + expect(document.getElementsByClassName('modal')).toHaveLength(0); + + const triggerButton = document.getElementById('trigger'); + + triggerButton.addEventListener('click', openModal); + triggerButton.dispatchEvent(new MouseEvent('click')); + + // check that modal has been created in the DOM + const modal = document.getElementsByClassName('modal'); + expect(modal).toHaveLength(1); + expect(modal).toMatchSnapshot(); + + // check that modalWorkflow returns self + expect(modalWorkflow).toBeInstanceOf(Object); + expect(modalWorkflow).toHaveProperty('close', expect.any(Function)); + + // close modal & check it is removed from the DOM + modalWorkflow.close(); + expect(document.getElementsByClassName('modal')).toHaveLength(0); + }); + + it('should pull focus from the trigger element to the modal and back to the trigger when closed', () => { + let modalWorkflow; + + const openModal = () => { + // eslint-disable-next-line new-cap + modalWorkflow = window.ModalWorkflow({ url: 'path/to/endpoint' }); + }; + + expect(document.getElementsByClassName('modal')).toHaveLength(0); + + const triggerButton = document.getElementById('trigger'); + + triggerButton.focus(); + triggerButton.addEventListener('click', openModal); + + expect(document.activeElement).toEqual(triggerButton); + triggerButton.dispatchEvent(new MouseEvent('click')); + + // check that modal has been created in the DOM & takes focus + expect(document.getElementsByClassName('modal')).toHaveLength(1); + expect(document.activeElement.className).toEqual('modal fade in'); + + // close modal & check that focus moves back to the trigger + modalWorkflow.close(); + expect(document.getElementsByClassName('modal')).toHaveLength(0); + expect(document.activeElement).toEqual(triggerButton); + }); + + it('should disable the trigger element when the modal is opened and re-enable it when closing', () => { + let modalWorkflow; + + const openModal = () => { + // eslint-disable-next-line new-cap + modalWorkflow = window.ModalWorkflow({ url: 'path/to/endpoint' }); + }; + + expect(document.getElementsByClassName('modal')).toHaveLength(0); + + const triggerButton = document.getElementById('trigger'); + + triggerButton.focus(); + triggerButton.addEventListener('click', openModal); + expect(triggerButton.disabled).toBe(false); + triggerButton.dispatchEvent(new MouseEvent('click')); + + // check that the trigger button is disabled + expect(triggerButton.disabled).toBe(true); + + // close modal & check that trigger button is re-enabled + modalWorkflow.close(); + expect(triggerButton.disabled).toBe(false); + }); + + it('should handle onload and URL param options', () => { + const onload = { start: jest.fn() }; + const urlParams = { page: 23 }; + + let modalWorkflow; + + const openModal = () => { + // eslint-disable-next-line new-cap + modalWorkflow = window.ModalWorkflow({ + url: 'path/to/endpoint', + urlParams, + onload, + }); + }; + + expect(onload.start).not.toHaveBeenCalled(); + const triggerButton = document.getElementById('trigger'); + triggerButton.addEventListener('click', openModal); + + const event = new MouseEvent('click'); + triggerButton.dispatchEvent(event); + + expect(modalWorkflow).toBeInstanceOf(Object); + + // important: see mock implementation above, returning a response with injected data to validate behaviour + const response = { data: urlParams, html: '
path/to/endpoint
', step: 'start' }; + expect(onload.start).toHaveBeenCalledWith(modalWorkflow, response); + }); +}); diff --git a/docs/releases/2.16.md b/docs/releases/2.16.md index 8292f8eada..b3ba8fd4e7 100644 --- a/docs/releases/2.16.md +++ b/docs/releases/2.16.md @@ -41,6 +41,7 @@ * Additional login form fields from `WAGTAILADMIN_USER_LOGIN_FORM` are now rendered correctly (Michael Karamuth) * Fix icon only button styling issue on small devices where height would not be set correctly (Vu Pham) * Add padding to the Draftail editor to ensure `ol` items are not cut off (Khanh Hoang) + * Prevent opening choosers multiple times for Image, Page, Document, Snippet (LB (Ben Johnston)) ## Upgrade considerations