Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/client/javascripts/debounce-click.js
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>} */ (
document.querySelectorAll('.prevent-multiple-clicks')
)

for (const button of buttons) {
button.addEventListener('click', handleButtonClick)
button.addEventListener('keydown', handleButtonKeydown)
}
}
235 changes: 235 additions & 0 deletions src/client/javascripts/debounce-click.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
3 changes: 3 additions & 0 deletions src/client/javascripts/shared.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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

Expand All @@ -17,6 +19,7 @@ export const initPreviewCloseLink = initPreviewCloseLinkImp
export function initAll() {
initAllGovuk()
initAllAutocomplete()
initDebounceClick()
initFileUpload()
initPreviewCloseLink()
}
6 changes: 5 additions & 1 deletion src/server/plugins/engine/views/summary.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,15 @@ <h2 class="govuk-heading-m" id="declaration">Declaration</h2>
{% 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 %}
Expand Down
Loading