How to open a modal, update the URL and then close the modal and update the URL

I have an index action list view and a show action that opens a modal over the top of the list in a turbo frame. When the modal opens, the URL changes to /path/:id and when it is closed it goes back to /path. I also need be able to go straight to /path/:id and the modal open, and then be able to close it and go to /path.

Does anyone have any suggestions for how to implement this?

I currently have:

  • each list item with a link to the show path annotated with data: {turbo_frame: :modal, turbo_action: :advance} so the modal loads into the modal turbo frame and the URL updates accordingly.
  • the close modal link that goes to the index path annotated with data: {turbo_frame: "_self", turbo_action: :advance}. This feels counter intuitive because it replaces the modal with the whole response from the index path - which contains an empty turbo stream as well as all the list data which we don’t use from the response.

Is there a simpler way of doing this?

You can use js to hook into turbo and do the url changes manually.

Let’s say you have a modal_controller.js, and you have show() and hide() methods - like the one from tailwind stimulus components.

Then

hide = () => {
  // ...

  this.restorePreviousUrl()
  // ...
}

show = (event) => {
  // ...
   const link = event.currentTarget // or similar
   this.changeUrl(link.href)
  // ...
}
import { navigator, session } from '@hotwired/turbo'

...

  changeUrl(url) {
    this.currentUrl = new URL(url)
    navigator.history.push(new URL(url))
    session.view.lastRenderedLocation = new URL(url)
  }

  restorePreviousUrl() {
    if (!this.currentUrl) {
      return
    }

    if (this.currentUrl.href == new URL(window.location).href) {
      // Pause history navigation listening, so we can
      // manually pop a url from the stack without triggering
      // a turbo reload
      navigator.history.stop()
      history.back()

      // as back() is an async method, we need to wait a bit before
      // starting Turbo history again
      window.setTimeout(() => {
        navigator.history.start()
      }, 80)
    }
  }

Let me know how it goes

We have something similar setup. Take the parts you like

  • index page with turbo modal links to display the show page of each resource in the modal (we don’t advance the URL)
  • the “close” of the modal redirects to the index page while breaking out of the turbo frame, via a query param (eg ?full_page_load=true) picked up by the index page, and when present, will use the turbo_page_requires_reload helper to cause the turbo-visit-control tag to be set to reload.
  • if the resource show URL is actually entered, we display the turbo modal contents on the page (not in a turbo modal). This is helpful for automated tests, since can go directly to the page (the modal aspect isn’t important for many of the operations on that modal)
  • if the index page URL is entered with a query param (eg, ?show=123):
    • the controller sets the @modal instance variable with the modal view component instance, containing the show page for resource 123
    • the layout, wherein is the top-level body element for the modal, renders the @modal (when present)

Thanks for your suggestions.

I learnt turbo frame requests have a header set with the name of the turbo frame declared in the link – e.g data-turbo-frame="modal" will result in a Turbo-Frame: modal header being set. Conveniently, rails-turbo provides a helper, turbo_frame_request?, which tells you if a turbo frame header is present.

This meant I could close the modal, and update the URL, all using Turbo frames simply checking when a request originates from a turbo frame and tailoring the response accordingly.

The pattern looks like:

  1. Annotate the modal open link like data-turbo-action="advance" and data-turbo-frame="modal". This opens the modal and updates the URL.
  2. Annotate the modal close link like data-turbo-action"advance". The link is within a modal turbo frame so behaves like it has data-turbo-frame="modal" on it. This closes the modal and advances the URL.
  3. Utilise the turbo_frame_request? helper when opening the modal in our controller’s show action. When opening the modal, if it is a turbo frame request, we only return the modal turbo frame. When it is not a turbo frame request, we can return the entire page including the modal turbo frame. This means we can open the modal from the link in step 1, but also render the entire page with the modal open when the URL is hit directly.
  4. When closing the modal by going to our controller’s index action, if it is a turbo frame request, simply return an empty modal turbo frame. This simply closes the modal by replacing it with an empty turbo frame. When it’s not a turbo frame request, return the entire page which includes the empty modal turbo frame. Similarly this means the index action caters for being accessed directly via a URL, and also via the close link in step 2.