-
Notifications
You must be signed in to change notification settings - Fork 329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Prevent multiple form submissions #1018
Conversation
Hi @quis thanks for taking the time and contributed this. One thing that springs to mind is whether we should only trigger debounce on |
I love this idea but I'm worried about the impact it could have. GOV.UK Frontend is intended to work in any service and we cannot be sure that buttons will always be used in a traditional form context, for example someone may build a number counter component that relies on being able to press 'increase' quickly. It's also hard to know if this would result in any negative results since if this triggers in the wrong circumstance users are likely to blame themselves. With this in mind, do you think it's possible to get what you have learned here and turn it into guidance? I wonder if this is something that could be handled server side? Given that the server could know how quickly requests are coming in. Failing this we could consider making this an opt in feature for buttons? What do you think? Do you think I'm being overly cautious? |
In the original implementation this was scoped to only elements with Might be sensible to replicate that here because it would prevent this behaviour being applied to:
Thoughts? |
It does seem reasonable to only do this when a button is using type=submit, we should bare in mind that if a user does not supply a type to their button submit is the implicit default. In terms of detecting if it is in a form I would be careful not to check any process that walks up the DOM is not slow for performance, it might be that that it is not necessary to check if the button is inside a form? Interested to hear what others think. As a side note if we go with this I believe we can do it in memory rather than setting attributes in the DOM. Edit: I had a go trying something out: var DEBOUNCE_TIMEOUT_IN_SECONDS = 0.5
var debounceFormSubmitTimer = null
button.addEventListener('click', function(event) {
var isInsideAForm = event.target.form
if (!isInsideAForm) {
return;
}
if (debounceFormSubmitTimer) {
event.preventDefault()
return false;
}
debounceFormSubmitTimer = setTimeout(function () {
debounceFormSubmitTimer = null
}, DEBOUNCE_TIMEOUT_IN_SECONDS * 1000)
}); |
@NickColley I think this: var isInsideAForm = event.target.form should be something more like: var isSubmittingAForm = event.target.form && event.target.type === 'submit' (I don’t know if that’s the right property names, but you get the idea) Otherwise I think this is a solid implementation. |
Good shout I've made an example page to test this: https://output.jsbin.com/deriwub psuedo code for demonstration: var DEBOUNCE_TIMEOUT_IN_SECONDS = 0.5
var debounceFormSubmitTimer = null
var buttons = document.querySelectorAll('button, input')
buttons.forEach(button => {
button.addEventListener('click', function(event) {
var $button = event.target
var isSubmitButton = $button.type === 'submit'
var isInsideAForm = $button.form
if (!isSubmitButton || !isInsideAForm) {
return;
}
if (debounceFormSubmitTimer) {
event.preventDefault()
return false;
}
debounceFormSubmitTimer = setTimeout(function () {
debounceFormSubmitTimer = null
}, DEBOUNCE_TIMEOUT_IN_SECONDS * 1000)
}
}); Edge 17 on Windows 10, IE11 on Windows 10, IE8-10 on Windows 7 (✅)I'm testing through browserstack but I wonder if windows users in IE and Edge already are protected against double clicking? BeforeAfterFirefox 62.0.2 (64-bit) on OSX 10.13.3 (✅)BeforeAfterGoogle Chrome Version 69.0.3497.100 on OSX 10.13.3 (✅)BeforeAfterSafari Version 11.0.3 on OSX 10.13.3 (✅)BeforeAfter |
Another thought, it feels like there's a few needs here:
I think this only covers the first case @quis maybe some sort of loading pattern would be necessary to satisfy both? Have you seen your complaints go down after you implemented that fix? |
Might this ever prevent valid clicks? So if I click but my connection has gone wrong, and then my connection is fixed, I sometimes find I need to click again, this would prevent that? |
@joelanman at the moment the implementation only prevents clicks that happen immediately afterwards, in my example there's a 500millisecond window, Chris' example is a 1.5 second window |
ah yes sorry, this PR is just about double clicks, not any click after the first one |
Regarding the duration of the timeout, basing it on how quickly the user expects the UI to respond seems sensible. In other words it shouldn’t interfere with someone who re-clicks because they think that the system hasn’t registered their first click. The generally accepted figure seems to be 1 second1. |
@quis so do you think the timeout should be 1.5s as suggested to cover that case? |
@NickColley I think 1s. Not sure what I based the original 1.5s number on. |
I've been busy recently but I have pushed the work I have done, I think the only thing left to do is write tests for this, so will pick this up when I get some time. |
I don’t disagree with this as a technique but would question whether it should be done by default. There may be edge cases where this functionality obstructs functionality - eg clicking a list of delete buttons in quick succession or a medium style “clap” button. Perhaps this could only be applied to buttons that explicitly request it, eg with a |
In these cases we'd expect users to be using The idea of having this as an explicit opt-in is interesting, I'll ask the team what they think, thanks. 👍 |
832f91e
to
874f21d
Compare
src/components/button/button.test.js
Outdated
await page.evaluate(() => { | ||
document.querySelector('button').outerHTML = ` | ||
<form> | ||
<button type="button">Save and continue</button> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be great to have a nicer way of doing this, but in the short term this does the job nicely 👍
I wonder if we should wrap the existing button from the page rather than replace it though – there's a chance we could change the markup and break this functionality, but it wouldn't be picked up by the test because it'd be hardcoded to use the old markup.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I did not want to spend hours having the isolated tests. I think we should solve that as part of the technical debt we've got recorded...
I'll try and wrap the existing button, that sounds fair enough.
CHANGELOG.md
Outdated
@@ -20,6 +20,10 @@ | |||
|
|||
([PR #N](https://github.com/alphagov/govuk-frontend/pull/N)) | |||
|
|||
- Prevent multiple form submissions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we might want to be more specific here – we're really debouncing form submission so that you can't submit more than once a second, which isn't quite the same thing this implies.
@@ -11,6 +11,8 @@ | |||
import '../../vendor/polyfills/Event' // addEventListener and event.target normaliziation | |||
|
|||
var KEY_SPACE = 32 | |||
var DEBOUNCE_TIMEOUT_IN_SECONDS = 1 | |||
var debounceFormSubmitTimer = null |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
src/components/button/button.js
Outdated
@@ -33,12 +35,47 @@ Button.prototype.handleKeyDown = function (event) { | |||
} | |||
} | |||
|
|||
/** | |||
* Add event handler for click |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Slightly pedantic, but this doesn't add the event handler – it is the event handler.
src/components/button/button.test.js
Outdated
|
||
const href = await page.evaluate(() => document.body.getElementsByTagName('a')[0].getAttribute('href')) | ||
|
||
// we need to start the waitForNavigation() before the keyboard action |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
keyboard action?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
recycled code 😬
src/components/button/button.test.js
Outdated
</form> | ||
` | ||
|
||
window.__BUTTON_PRESSED_COUNT = 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To make the intent super clear, I'd consider naming this something like __BUTTON_CLICK_EVENTS
as we're counting the number of events fired, not the number of times the button was actually pressed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure thing
src/components/button/button.test.js
Outdated
|
||
expect(submitCount).toBe(1) | ||
}) | ||
it('when a user clicks again intentionally it is not prevented', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again to make the intent really clear here (what does intentionally mean?) perhaps something like '(it) does not prevent additional clicks after one second'?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried to capture something closer to a user need than a technical implementation, since we care that pressing the submit twice after a period of time is what we want to allow.
I'll make this clearer
src/components/button/button.test.js
Outdated
@@ -41,5 +41,102 @@ describe('/components/button', () => { | |||
const url = await page.url() | |||
expect(url).toBe(baseUrl + href) | |||
}) | |||
describe('debouncing', () => { | |||
it('does not block button links', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests are great 👍
@@ -11,6 +11,8 @@ | |||
import '../../vendor/polyfills/Event' // addEventListener and event.target normaliziation | |||
|
|||
var KEY_SPACE = 32 | |||
var DEBOUNCE_TIMEOUT_IN_SECONDS = 1 | |||
var debounceFormSubmitTimer = null |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
@@ -11,6 +11,8 @@ | |||
import '../../vendor/polyfills/Event' // addEventListener and event.target normaliziation | |||
|
|||
var KEY_SPACE = 32 | |||
var DEBOUNCE_TIMEOUT_IN_SECONDS = 1 | |||
var debounceFormSubmitTimer = null |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
874f21d
to
c208f01
Compare
👋 I really like the spirit of this - I've seen quite a few services where the continue button goes disabled when you click it - I presume because the team / framework is disabling the button on submission. Besides the accessibility issues of disabled buttons I generally find it distracting. With that said, I do wonder if this should be the concern of the server to deal with. As others have said, there may be cases where you do want to send data quickly, or connections might time out. Only the server knows what's actually been received - I'd hope there are frontend architecture patterns for recognising multiple submissions in a reliable way. The server solution presumably being harder - if we can provide built-in solutions so we avoid teams having disabled buttons - all the better. |
@edwardhorsford one alternative is we turn this into guidance with a recommendation to do this server side for sure, I'm personally a little uncomfortable with how broad this JavaScript could apply based on @36degrees and @penx 's feedback. |
c6f42ea
to
ed182ab
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally looking good, but a few minor things I think might need tidying up.
src/components/button/button.js
Outdated
Button.prototype.debounce = function (event) { | ||
var target = event.target | ||
// Check the button that is clicked on has the preventDoubleClick feature enabled | ||
if (target.attributes && target.getAttribute('data-prevent-double-click') !== 'true') { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the target doesn't have any attributes (not sure how likely that is), this won't evaluate the second statement. I think it needs to be flipped:
if (target.attributes && target.getAttribute('data-prevent-double-click') !== 'true') { | |
if (!target.attributes || target.getAttribute('data-prevent-double-click') !== 'true') { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, I think getAttribute is generally safe to use, and will just return null
if the attribute isn't set, so not sure the first check is necessary? In which case this could just be
if (target.attributes && target.getAttribute('data-prevent-double-click') !== 'true') { | |
if (target.getAttribute('data-prevent-double-click') !== 'true') { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've been guarding them since if you try to use 'getAttribute' on document it will throw.
Not sure this makes sense here though.
src/components/button/button.js
Outdated
|
||
var $button = event.target | ||
|
||
// We only want to to handle submit buttons that are used in forms |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is now an opt-in behaviour, do we need to be as strict here?
If users add data-prevent-double-click
, I think they'd expect it to work, regardless of whether it's in a form / a submit button?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah could probably simplify this now...
src/components/button/button.test.js
Outdated
// Links should take a user to another part of the page or url, so debouncing it wouldnt have any impact. | ||
// But we can check that we're not blocking any initial clicks. | ||
|
||
await page.goto(baseUrl + '/components/button/link/preview', { waitUntil: 'load' }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The way the logic is currently written, this test is never going to reach the part of the function that tests that the element is an input
/ button
, because it'll already have returned because it doesn't have data-prevent-double-click
.
What are we trying to test here? Do we need to be testing clicking a link that has data-prevent-double-click
set to true
? Or is this test now redundant?
src/components/button/button.test.js
Outdated
const url = await page.url() | ||
expect(url).toBe(baseUrl + href) | ||
}) | ||
it('does not trigger on buttons with type=button', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same thing applies here – this is just going to be returning because of the lack of data-prevent-double-click
src/components/button/button.yaml
Outdated
- name: preventDoubleClick | ||
type: boolean | ||
required: false | ||
description: Prevent accidental double clicks from submitting forms multiple times |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we be clear that this doesn't affect links?
Have seen users complaining that they got an invitation email twice. This is probably because they clicked the ‘send invite’ button twice even though they think they only clicked it once. Double form submission is a common issue on web pages, and there are a number of different ways to prevent it. I’ve chosen to do it this way because: - temporarily, not permanently disabling the button means that this addresses the double clicking issue without breaking things if the user did, really want to click the button again deliberately (for whatever reason) - doing it with a `data` attribute, rather than the `disabled` attribute means that the interaction behaviour of the button doesn’t change ( `disabled` buttons can’t be focused, for example) Adapted from https://github.com/alphagov/notifications-admin/blob/93e7f9213533790ffb52a8f3afadbace9145eea2/app/assets/javascripts/preventDuplicateFormSubmissions.js
7473dc5
to
a63befa
Compare
|
||
expect(submitCount).toBe(1) | ||
}) | ||
it('does not prevent intentional multiple clicks', async () => { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
a63befa
to
887cb1e
Compare
Will this also stop users from submitting the form twice by pressing enter within a form field? |
@adamsilver no it does not, potentially the same users that have motor impairments (for example tremors) that cause them to double click a button would be helped by that though. Something to think about in the future. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is good to go. Really cool tests 🙌 I like the idea of the alternative PR by @NickColley that allows either delegating event listeners to document
or binding them to button element. But that that logic feels a bit hidden from API point of view and could be tricky to document for various scenarios. I think releasing this as an opt in feature will allow us to gather more research about whether we need alternatives to globally listening for events.
We’ve also now got some guidance which amongst other things addresses checking for accidental clicks on server side.
Thanks everyone that contributed to this, took us a long time to get it right but I'm happy with what we've managed here. Special thanks to @quis for the original contribution. We will release this with some new guidance next week. |
Take advantage of new protection against accidental duplicate submissions of the journey form. ref: alphagov/govuk-frontend#1018 Fixes #42 Signed-off-by: James Gauld <james.gauld@engineering.digital.dwp.gov.uk>
GOV.UK Frontend has a double click on buttons protector built in so favouring that. Plus moved inclusion of the GOV.UK Frontend scripts into our compiled JS rather than including as extra HTTP request. Details of how it works here - alphagov/govuk-frontend#1018
Although the problem described here is specific to Notify it feels like the solution could be more generally applicable. The code here is a rough first go at adapting it to GOV.UK Frontend. I might not have put it in the right place. It doesn’t have tests. I haven’t even checked that it works.
In Notify we’ve seen users complaining that they got sent an invitation email twice. This is probably because someone on their team clicked the ‘send invite’ button twice (even though they think they only clicked it once). Some users will just double click everything on a web page because Windows.
Double form submission is a common issue on web pages, and there are a number of different ways to prevent it. I chose to do it this way because:
data
attribute, rather than thedisabled
attribute means that the interaction behaviour of the button doesn’t change (disabled
buttons can’t be focused, for example)Adapted from https://github.com/alphagov/notifications-admin/blob/93e7f9213533790ffb52a8f3afadbace9145eea2/app/assets/javascripts/preventDuplicateFormSubmissions.js