Skip to content
Merged
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
24 changes: 24 additions & 0 deletions app/javascript/controllers/disable_submit_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Controller } from '@hotwired/stimulus'

// Disables a form's submit buttons once it is submitted, so a double click
// cannot POST twice. Turbo Drive does this for opted-in forms, but this app
// disables Drive globally (application.js), so plain full-page forms get no
// protection from Turbo and rails-ujs (disable_with) is no longer bundled.
//
// Usage: form html: {data: {controller: 'disable-submit', action: 'submit->disable-submit#disable'}}
export default class extends Controller {
disable(event) {
// A handler registered before this one may have canceled the submission
// (e.g. client-side validation) — leave the button usable then.
if (event.defaultPrevented) return

// Disable on the next tick: the browser builds the form data set after
// the submit event finishes, and a button disabled too early would have
// its own name/value dropped from the submission.
setTimeout(() => {
this.element.querySelectorAll('button[type="submit"], input[type="submit"]').forEach(button => {
button.disabled = true
})
})
}
}
2 changes: 2 additions & 0 deletions app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AlertAutodismissController from "./alert_autodismiss_controller"
import AlertController from "./alert_controller"
import BannerAdsController from "./banner_ads_controller"
import CardDisplayToggleController from "./card_display_toggle_controller"
import DisableSubmitController from "./disable_submit_controller"
import GridModalController from "./grid_modal_controller"
import CfpDatatableController from "./cfp_datatable_controller"
import ContentController from "./content_controller"
Expand Down Expand Up @@ -53,6 +54,7 @@ application.register("alert-autodismiss", AlertAutodismissController)
application.register("alert", AlertController)
application.register("banner-ads", BannerAdsController)
application.register("card-display-toggle", CardDisplayToggleController)
application.register("disable-submit", DisableSubmitController)
application.register("grid-modal", GridModalController)
application.register("cfp-datatable", CfpDatatableController)
application.register("content", ContentController)
Expand Down
2 changes: 1 addition & 1 deletion app/views/proposals/edit.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
%a{href: 'https://help.github.com/articles/github-flavored-markdown'}
%strong GitHub Flavored Markdown.

= simple_form_for [event, proposal], url: event_proposal_path(event.slug, proposal), html: {'data-proposal-preview-target' => 'form'} do |f|
= simple_form_for [event, proposal], url: event_proposal_path(event.slug, proposal), html: {data: {proposal_preview_target: 'form', controller: 'disable-submit', action: 'submit->disable-submit#disable'}} do |f|
= render partial: 'form', locals: {f: f}
#preview.tab-pane
= render partial: 'preview', locals: { proposal: proposal }
2 changes: 1 addition & 1 deletion app/views/proposals/new.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
%a{href: 'https://help.github.com/articles/github-flavored-markdown'}
%strong GitHub Flavored Markdown.

= simple_form_for proposal, url: event_proposals_path(event), html: {'data-proposal-preview-target' => 'form'} do |f|
= simple_form_for proposal, url: event_proposals_path(event), html: {data: {proposal_preview_target: 'form', controller: 'disable-submit', action: 'submit->disable-submit#disable'}} do |f|
= render partial: 'form', locals: {f: f}
#preview.tab-pane
= render partial: 'preview', locals: { proposal: proposal }
48 changes: 48 additions & 0 deletions spec/system/proposal_double_submit_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
require 'rails_helper'

feature 'Proposal submission double-click protection', type: :system, js: true do
let!(:user) { create(:user) }
let!(:event) { create(:event, state: 'open') }
let!(:session_format) { create(:session_format, name: 'Only format') }

before do
login_as(user)
visit new_event_proposal_path(event)

fill_in 'Title', with: 'Once and only once'
fill_in 'Abstract', with: 'A talk about idempotent form submissions.'
fill_in 'proposal_speakers_attributes_0_bio', with: 'I am awesome.'
fill_in 'Pitch', with: 'Delivered exactly once.'
fill_in 'Details', with: 'No double submissions.'
select 'Only format', from: 'Session format'
end

scenario 'the submit button is disabled as soon as the form is submitted' do
# Keep the page around after submit so the button state can be inspected:
# this listener runs after the disable-submit controller's (registered at
# connect time), so the disable still happens first.
page.execute_script(<<~JS)
document.querySelector('[data-proposal-preview-target="form"]')
.addEventListener('submit', function(e) { e.preventDefault() })
JS
click_button 'Submit'

expect(page).to have_button('Submit', disabled: true)
expect(Proposal.count).to eq(0)
end

scenario 'a double click creates exactly one proposal' do
button = find('.form-submit button[type="submit"]')
button.click
# The second click of a double-click either hits an already-disabled
# button or a page mid-navigation; either way one proposal must result.
begin
button.click
rescue StandardError
# element detached mid-navigation — the first submit won, which is fine
end

expect(page).to have_content('Thank you!')
expect(Proposal.where(title: 'Once and only once').count).to eq(1)
end
end
Loading