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 [
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ path/to/endpoint
+
+
+
+
+
+
+
+
+
+
+
+
+
,
+]
+`;
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 = $('
\n
\n
\n \n \n
\n
\n
');
@@ -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