What's the hotwire way of dealing with data-disable-with?

Viewed 2936

I'm starting to use Hotwire in my Rails app and I implemented something similar to the tutorial video where a form gets submitted and refresh another part of the page. Like in the video, I had to implement my own reset form controller:

import {Controller} from "stimulus"

export default class extends Controller {
  reset() {
    this.element.reset()
  }
}

to reset the values in the form. The form looks something like:

<%= form_with(model: @invitation,
              data: {controller: "reset-form",
                     action: "turbo:submit-end->reset-form#reset"}) do |form| %>
  <!-- other form elements -->
  <%= form.submit "Invite" %>
<% end %>

which generates in button like this:

<input type="submit" name="commit" value="Invite" data-disable-with="Invite">

Because there's a data-disable-with, when you press the button, it gets disabled to avoid double-click submissions, which is great. The problem is that this.element.reset() doesn't reset it.

What's the proper way of dealing with this?

I'm not searching for a workaround, I know many, but I'm searching for the clean solution to this problem.

Is this disabling of a button caused by UJS? does it mean UJS shouldn't be used in Stimulus application?

I can re-enable the button from the reset JavaScript function but if the input button is specified like this:

<%= form.submit "Invite", data: {disable_with: "Inviting, please wait..."} %>

then the original label (value) of the button is lost and I don't have a way to re-establish it, which makes me think whatever is implementing this functionality (UJS?) it's not designed for hotwire/spa applications, and expects a full reload of the page.

I could just throw:

config.action_view.automatically_disable_submit_tag = false

and implement my own double-click prevention with a Stimulus controller, but that feels wrong. The problem is not with the attribute data-disable-with but how it was implemented.

3 Answers

The most "Hotwire way" of replacing HTML content on the page upon a user action is to use a turbo stream response.

If I understand correctly, you want to return the form (or just the button) back to its original state after form submission.

You would wrap your form with a turbo frame, like so

<%= turbo_frame_tag dom_id(@invitation) do %>
  <%= form_with(...) %>
  <% end %>
<% end %>

Then, like @stwienert said, have your controller return a format.turbo_stream response that renders the partial/template for your form. Here is an example of a turbo_stream response adapted from the Turbo docs:

format.turbo_stream do
  render turbo_stream: turbo_stream.replace(@invitation, partial: "your/form",
    locals: { invitation: @invitation }) # or Invitation.new, perhaps
end

The goal would be for your controller response to target the DOM id of your form, such that on form submit, your data is handled appropriately and then the client receives a turbo stream response containing exactly the HTML you want to see inside your turbo_frame tag.

If you only want to replace the button, wrap only the button in the turbo_frame, give it a unique DOM id, and pass that ID to turbo_stream.replace/append/etc, e.g. turbo_stream.replace(:button_dom_id_here, partial: ...)

data-disable-with is from UJS, which isn’t really meant to work with Turbo (though this might be confusing because of efforts to smooth the transition from UJS to Turbo).

Turbo uses Websockets, while UJS leverages AJAX. The approaches are orthogonal.

When you switch from UJS to Turbo, you lose the disable-with convenience, and you will return content via turbo_stream vs. js (the UJS/AJAX way).

Resetting the form clears the input, but it doesn’t toggle the disabled attribute of the submit button.

Replacing the entire form via Websockets will give you a new form.

I would probably add the "original" text to the form.submit:

<% = form.submit "Invite", data: { 
  disable_with: "...", original: "Invite", "data-reset-form-button-target" } %>

Then you could enhance your stimulus controller:

 static targets = ["button"]
 reset() {
   this.element.reset()
   this.buttonTarget.value  = this.buttonTarget.dataset.original
 }

Could not test it, just scribble out.

Related