6

Custom Form Handling With Turbo

 2 years ago
source link: https://sourcediving.com/custom-form-handling-with-turbo-29e5525ff4c3?source=collection_home---4------1-----------------------&gi=b299509d8e96
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Custom Form Handling With Turbo

Turbo will be a default part of Rails from Rails 7, replacing Turbolinks and rails-ujs. This post is a result of time I spent digging into Turbo, in particular its implications for forms that don’t seem to fit what Turbo is designed for: that is, forms that don’t necessarily (or only) trigger a redirect or DOM changes. I don’t have a definitive answer for what we should do in these cases, but I’ll explain some options that might be useful if or when Turbo’s constraints feel a bit awkward.

Introducing Turbo…

Most of this post is about Turbo Drive, one of the four techniques that together constitute Turbo.

Turbo Drive is the bit that intercepts link clicks and form submissions to avoid full page reloads. It’s the new incarnation of Turbolinks, which has been a default part of Rails apps for a long time. Turbolinks only intercepted link clicks, not form submissions — but now, if you have Turbo installed, a form without any data attributes will automatically be handled and ultimately submitted by Turbo’s javascript. This means form submissions are by default ajax requests, which don’t result in a full page load when the browser gets a response.

So what does happen with the response after Turbo submits a form?

  1. If the response is a redirect, Turbo will follow that redirect, navigating to the new page (without a full page load) as if the user had clicked a link. This is equivalent to the redirect support in Turbolinks-Rails when a form is submitted as an ajax request — in other words, we did have a way pre-Turbo to submit a form and redirect without a full page load.
  2. If the response is html and the status is 4XX or 5XX, Turbo will render that html (without changing the URL). Turbolinks-Rails didn’t do this. Previously, if a POST request returned some html, nothing would happen without custom javascript to swap that html into the page or simulate a Turbolinks visit.
  3. If the response is a ‘Turbo Stream’ response, Turbo will process it… A what? Turbo Streams are a new kind of response. Their content-type header is text/vnd.turbo-stream.html and they contain one or more Turbo Stream elements, which are custom html elements. Turbo automatically appends these elements to the DOM and whenever such an element is added, it triggers DOM changes (such as appending or replacing or removing html as specified by the markup in the Turbo Stream element.

Those three alternatives are the only things Turbo is designed to do after a form is submitted:

  1. follow a redirect,
  2. render html if the http status is 4XX or 5XX, or
  3. process Turbo Streams, which can trigger only a limited range of DOM changes

Doing what Turbo isn’t designed for…

These constraints are deliberate and there’s no reason to debate them. But it is important to understand them and what they mean in practice. If we want to do something Turbo isn’t really designed for, what should we do? What can we do?

I was learning about Turbo soon after implementing a checkout flow in Cookpad using stripe js. It works by creating a Payment Method in Stripe, then submitting the Payment Method’s id in a form to our server. If all goes well processing the purchase, the user is redirected to a success page. But the purchase might fail because the user needs to authorise the payment with their bank. In that scenario, our server returns the data needed to call stripe's confirmCardPayment function. And that function launches the authorisation flow for the user’s bank. [1]

Calling javascript functions using data returned by the server doesn’t feel like one of the Three Things Turbo is designed to do after submitting a form. So as I read about Turbo, I kept asking myself this: what if we need (or want) to do something else? Or, being a bit more specific:

With Turbo set up, (how) can we submit a form then handle the response — in particular an error response — in a custom way, without only redirecting or inserting and/or removing some html?

Option 1…

One option is to use Turbo up to a point, then, at that point, take over from it. Let Turbo submit the request, let Turbo handle a redirect, but prevent Turbo handling the response if, instead of rendering html or appending Turbo Stream elements, we want to do “other stuff” like call some javascript functions.

This is doable by listening for the turbo:before-fetch-response event, emitted on the document after the request has been made but before the response has been used.

We can put this stimulus action on a form:

<form data-action="turbo:before-fetch-response@document->prevent-default#preventDefault">
...
</form>

Then define preventDefault in a prevent-default stimulus controller:

export default class extends Controller {
// Let Turbo make the request.
// Check the response. If it's unsuccessful, stop Turbo attempting to handle the response.
async preventDefault(event) {
// The response is available here and we can block Turbo's default behaviour.
if (!event.detail.fetchResponse.succeeded) {
event.preventDefault()
const json = await event.detail.fetchResponse.response.clone().json()
console.log("Do stuff with the json...", json)
}
}
}

Now, if the server responds with an error, we can do whatever we want. See how the response doesn’t even have to be html.

But there’s a problem. Because the event target is document, I couldn’t find a nice way to be sure it corresponds to the correct form on the page. We could check the URL the request was sent to, or we could put a DOM identifier in the response, but neither is ideal. If the target was the element that triggered the request, we could listen for the event on the specific form we want to handle. That would be a convenient way to let Turbo make the request then optionally 'take over' when the response is ready. [2]

Option 2a…

Another option is to trigger the ‘other stuff’ (the stuff that isn’t inserting and/or removing html by inserting some html.

For example, if we want to trigger stripe’s card authorisation flow, we can return a Turbo Stream element that appends a block of html that attaches a stimulus controller that triggers the card authorisation flow.

The Turbo Stream element could be rendered like this:

<%= turbo_stream.update "stripe-authentication-container" do %>
<%= render "shared/payment/authentication",
client_secret: error_payload[:data][:client_secret],
payment_method_id: error_payload[:data][:payment_method_id] %>
<% end %>

When it’s added to the DOM, it will update the contents of the stripe-authentication-container with an authentication partial.

The authentication partial could look like this:

<div
data-controller="stripe-authentication"
data-stripe-authentication-public-key-value="<%= Rails.configuration.x.stripe.public_key %>"
data-stripe-authentication-client-secret-value="<%= client_secret %>"
data-stripe-authentication-payment-method-id-value="<%= payment_method_id %>">
</div>

And the stimulus controller’s connect function could look like this:

async connect() {
this.stripe = await loadStripe(this.publicKeyValue)
const result = await this.stripe.confirmCardPayment(this.clientSecretValue, {
payment_method: this.paymentMethodIdValue,
})
// ...handle the result by showing errors or sending another request to fulfil the purchase
}

I think using a Turbo Stream to insert html as a way to do other things - things that could be done without inserting html at all - is in line with what the Turbo docs advocate here:

Turbo Streams consciously restricts you to seven actions: append, prepend, (insert) before, (insert) after, replace, update, and remove. If you want to trigger additional behavior when these actions are carried out, you should attach behavior using Stimulus controllers.

Option 2b…

In the above example, the ‘other behaviour’ is triggered when a stimulus controller connects, which happens when an element is added to the DOM. In that sense, the additional behaviour is triggered by the DOM change.

But we could also use a Turbo Stream to trigger behaviour in a more roundabout way: the Turbo Stream could cause a stimulus controller (A) to connect, which could emit an event, which we could listen for in some other stimulus controller (B). Stimulus controller B would then perform the action not because it has just connected, making the resulting behaviour a bit more removed from the thing the Turbo Stream is designed for: making a DOM change.

We could render a Turbo Stream like this:

<%= turb_stream.append "form" do %>
<div data-controller="pass-error" data-pass-error-payload-value="<%%= @object.errors.to_json %>"></div>
<% end %>

The ‘pass-error’ stimulus controller could connect like this:

connect() {
this.element.dispatchEvent(
new CustomEvent("error", { bubbles: true, detail: { payload: this.payloadValue }})
)
this.element.remove()
}

And we could listen for the custom error event in the same way we can listen for rails-ujs ajax:error events:

<form data-action="error->error-handler#handleError">
...
</form>

This effectively means using a Turbo Stream to simulate the standard way we (at Cookpad) currently handle a response payload. It feels a bit like hacking Turbo Streams to let us handle non-html responses, and isn't really in the spirit of Turbo… but it could be useful, especially if you want to switch to Turbo but continue acting on events similar to ajax:error.

Option 3…

Finally, even with Turbo installed (and Turbolinks removed), we don’t have to use it. We can disable Turbo on an individual form by adding a data-turbo=false attribute. This will result in a standard non-ajax form submission. Or we can add a data-remote=true attribute to the form. As long as we still have rails-ujs installed, the 'data-remote' attribute will stop Turbo handling the submission because rails-ujs will intercept it first.

This is definitely a way to have Turbo set up while handling a form response in ways Turbo isn’t designed for. Submit the form with rails-ujs instead and act on the events it emits to do whatever needs to be done. Great.

Except that by default we then lose the option of responding to the submission with a redirect. Without Turbolinks-Rails installed, if you try to redirect in response to a rails-ujs form submission, nothing will happen...

What we need for rails-ujs to be viable in a non-Turbolinks setup is a way to redirect with Turbo when a non-Turbo ajax form is submitted.

And here it is, in the Turbo docs. A Turbo version of the Turbolinks-Rails redirect_to method. Drop this into your ApplicationController, and you can redirect with Turbo even when Turbo didn't submit the form.

Conclusions…

I don’t know how many others are or will be asking themselves the question I found myself asking, and I haven’t found a definitive answer to that question anyway… But hopefully I have explained a few approaches that might help as we adapt to Rails without Turbolinks and without rails-ujs.

I’ll finish with a bit of practical advice, because something that has become clear as I’ve tried out these approaches is a way to make the leap to Turbo a bit calmer and more gradual.

If your existing application submits remote: true forms, there's no need to rewrite them all straight away. Let rails-ujs continue intercepting the submissions. Let it continue emitting the convenient ajax:error and ajax:success hooks. Start by letting Turbo take over the other forms: Turbo will seamlessly [3] turn them into ajax submissions and handle them without a full page load. Then consider each 'remote' form individually, either removing remote: true and refactoring to deliver the necessary behaviour with Turbo, or keeping remote: true, or using neither rails-ujs nor Turbo.

Thanks for reading, and feel free to get in touch with me @olliedoodleday. 👋


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK