diff --git a/src/client/javascripts/debounce-click.js b/src/client/javascripts/debounce-click.js new file mode 100644 index 000000000..d062366a5 --- /dev/null +++ b/src/client/javascripts/debounce-click.js @@ -0,0 +1,62 @@ +/** How long (ms) a button stays locked after the first click. */ +const DEBOUNCE_TIMEOUT_MS = 10_000 + +/** + * Shared debounce logic used by both the click and keydown handlers. + * @param {Event} event + */ +function handleActivation(event) { + const button = /** @type {HTMLButtonElement} */ (event.currentTarget) + + if (button.dataset.debouncing === 'true') { + event.preventDefault() + event.stopImmediatePropagation() + return + } + + button.dataset.debouncing = 'true' + + setTimeout(() => { + delete button.dataset.debouncing + }, DEBOUNCE_TIMEOUT_MS) +} + +/** + * Click handler that prevents a button from being activated more than once + * within {@link DEBOUNCE_TIMEOUT_MS}. + * @param {MouseEvent} event + */ +function handleButtonClick(event) { + handleActivation(event) +} + +/** + * Keydown handler that prevents a button from being activated more than once + * within {@link DEBOUNCE_TIMEOUT_MS} when submitted via Enter or Space. + * @param {KeyboardEvent} event + */ +function handleButtonKeydown(event) { + if (event.key === 'Enter' || event.key === ' ') { + handleActivation(event) + } +} + +/** + * Attaches {@link handleButtonClick} to every button that carries the + * `prevent-multiple-clicks` CSS class so that double-submissions are blocked + * across the page. + * + * Safe to call multiple times — adding the same listener twice on a given + * element has no effect (the browser deduplicates identical listener/options + * pairs). + */ +export function initDebounceClick() { + const buttons = /** @type {NodeListOf} */ ( + document.querySelectorAll('.prevent-multiple-clicks') + ) + + for (const button of buttons) { + button.addEventListener('click', handleButtonClick) + button.addEventListener('keydown', handleButtonKeydown) + } +} diff --git a/src/client/javascripts/debounce-click.test.js b/src/client/javascripts/debounce-click.test.js new file mode 100644 index 000000000..2d99eb38c --- /dev/null +++ b/src/client/javascripts/debounce-click.test.js @@ -0,0 +1,235 @@ +import { initDebounceClick } from '~/src/client/javascripts/debounce-click.js' + +const DEBOUNCE_TIMEOUT_MS = 10_000 + +/** + * @param {string} [extraClasses] + * @returns {HTMLButtonElement} + */ +function makeButton(extraClasses = '') { + const button = document.createElement('button') + button.className = `prevent-multiple-clicks ${extraClasses}`.trim() + document.body.appendChild(button) + return button +} + +/** + * @param {HTMLButtonElement} button + * @returns {MouseEvent} + */ +function click(button) { + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + button.dispatchEvent(event) + return event +} + +/** + * @param {HTMLButtonElement} button + * @param {string} key + * @returns {KeyboardEvent} + */ +function keydown(button, key) { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true + }) + button.dispatchEvent(event) + return event +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('initDebounceClick', () => { + it('attaches a click listener to every .prevent-multiple-clicks button', () => { + const b1 = makeButton() + const b2 = makeButton() + const spy1 = jest.fn() + const spy2 = jest.fn() + b1.addEventListener('click', spy1) + b2.addEventListener('click', spy2) + + initDebounceClick() + + click(b1) + click(b2) + + expect(spy1).toHaveBeenCalledTimes(1) + expect(spy2).toHaveBeenCalledTimes(1) + }) + + it('does not attach to buttons that lack the class', () => { + const plain = document.createElement('button') + document.body.appendChild(plain) + const spy = jest.fn() + plain.addEventListener('click', spy) + + initDebounceClick() + click(plain) + + // Listener still runs — debounce was never applied + expect(plain.dataset.debouncing).toBeUndefined() + }) +}) + +describe('handleButtonClick (via initDebounceClick)', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('sets data-debouncing="true" on the first click', () => { + const button = makeButton() + initDebounceClick() + + click(button) + + expect(button.dataset.debouncing).toBe('true') + }) + + it('allows a second click after the debounce timeout expires', () => { + const button = makeButton() + const spy = jest.fn() + button.addEventListener('click', spy) + initDebounceClick() + + click(button) + jest.advanceTimersByTime(DEBOUNCE_TIMEOUT_MS) + click(button) + + expect(button.dataset.debouncing).toBe('true') + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('removes data-debouncing after the timeout', () => { + const button = makeButton() + initDebounceClick() + + click(button) + expect(button.dataset.debouncing).toBe('true') + + jest.advanceTimersByTime(DEBOUNCE_TIMEOUT_MS) + expect(button.dataset.debouncing).toBeUndefined() + }) + + it('prevents the default action on a duplicate click', () => { + const button = makeButton() + initDebounceClick() + click(button) + + const duplicate = new MouseEvent('click', { + bubbles: true, + cancelable: true + }) + button.dispatchEvent(duplicate) + + expect(duplicate.defaultPrevented).toBe(true) + }) + + it('stops immediate propagation on a duplicate click', () => { + const button = makeButton() + initDebounceClick() + click(button) + + const subsequent = jest.fn() + button.addEventListener('click', subsequent) + + click(button) + + expect(subsequent).not.toHaveBeenCalled() + }) + + it('does not fire listeners registered after the handler for a click within the timeout window', () => { + const button = makeButton() + initDebounceClick() + // Registered after initDebounceClick so the debounce handler runs first and + // can call stopImmediatePropagation before this listener is reached. + const spy = jest.fn() + button.addEventListener('click', spy) + + click(button) + jest.advanceTimersByTime(DEBOUNCE_TIMEOUT_MS - 1) + click(button) + + // First click let through, second is still within the window — spy blocked + expect(spy).toHaveBeenCalledTimes(1) + }) +}) + +describe('handleButtonKeydown (via initDebounceClick)', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('sets data-debouncing="true" on Enter', () => { + const button = makeButton() + initDebounceClick() + + keydown(button, 'Enter') + + expect(button.dataset.debouncing).toBe('true') + }) + + it('sets data-debouncing="true" on Space', () => { + const button = makeButton() + initDebounceClick() + + keydown(button, ' ') + + expect(button.dataset.debouncing).toBe('true') + }) + + it('ignores keys other than Enter and Space', () => { + const button = makeButton() + initDebounceClick() + + keydown(button, 'Tab') + + expect(button.dataset.debouncing).toBeUndefined() + }) + + it('prevents default on a duplicate Enter keydown', () => { + const button = makeButton() + initDebounceClick() + keydown(button, 'Enter') + + const duplicate = keydown(button, 'Enter') + + expect(duplicate.defaultPrevented).toBe(true) + }) + + it('stops immediate propagation on a duplicate keydown within the timeout window', () => { + const button = makeButton() + initDebounceClick() + keydown(button, 'Enter') + + const spy = jest.fn() + button.addEventListener('keydown', spy) + keydown(button, 'Enter') + + expect(spy).not.toHaveBeenCalled() + }) + + it('allows a second keydown after the debounce timeout expires', () => { + const button = makeButton() + const spy = jest.fn() + button.addEventListener('keydown', spy) + initDebounceClick() + + keydown(button, 'Enter') + jest.advanceTimersByTime(DEBOUNCE_TIMEOUT_MS) + keydown(button, 'Enter') + + expect(button.dataset.debouncing).toBe('true') + expect(spy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/client/javascripts/shared.js b/src/client/javascripts/shared.js index 5056966d1..02729e818 100644 --- a/src/client/javascripts/shared.js +++ b/src/client/javascripts/shared.js @@ -1,4 +1,5 @@ import { initAllAutocomplete as initAllAutocompleteImp } from '~/src/client/javascripts/autocomplete.js' +import { initDebounceClick as initDebounceClickImp } from '~/src/client/javascripts/debounce-click.js' import { initFileUpload as initFileUploadImp } from '~/src/client/javascripts/file-upload.js' import { initAllGovuk as initAllGovukImp } from '~/src/client/javascripts/govuk.js' import { initPreviewCloseLink as initPreviewCloseLinkImp } from '~/src/client/javascripts/preview-close-link.js' @@ -8,6 +9,7 @@ export * as geospatialMap from '~/src/client/javascripts/geospatial-map.js' export const initAllGovuk = initAllGovukImp export const initAllAutocomplete = initAllAutocompleteImp +export const initDebounceClick = initDebounceClickImp export const initFileUpload = initFileUploadImp export const initPreviewCloseLink = initPreviewCloseLinkImp @@ -17,6 +19,7 @@ export const initPreviewCloseLink = initPreviewCloseLinkImp export function initAll() { initAllGovuk() initAllAutocomplete() + initDebounceClick() initFileUpload() initPreviewCloseLink() } diff --git a/src/server/plugins/engine/views/summary.html b/src/server/plugins/engine/views/summary.html index 0f01e2bac..17f031683 100644 --- a/src/server/plugins/engine/views/summary.html +++ b/src/server/plugins/engine/views/summary.html @@ -75,11 +75,15 @@

Declaration

{% set isDeclaration = declaration or components | length %} {% set paymentPending = paymentRequired and not paymentState %} + {# The prevent-multiple-clicks CSS class wires up to debounce-click.js to disable the button for 10s. + For those with JS enabled, it will help prevent multiple submissions when a large form is taking a while to submit. + When a better fix is implemented, this class can be removed and preventDoubleClick should be set back to true. #} {{ govukButton({ text: "Pay and submit" if paymentPending else ("Accept and submit" if isDeclaration else "Submit"), + classes: "prevent-multiple-clicks", name: "action", value: "send", - preventDoubleClick: true + preventDoubleClick: false }) }} {% if allowSaveAndExit %}