Rails + Turbo_stream custom actions: can I make a response conditional on DOM state without Stimulus?

272 views Asked by At

I have a page with several modal forms for filling out academic data. For each modal, the relevant Rails controller actions render turbo responses for the form in a separate modal.

Note: this does not follow the pattern of populating a modal that is pre-printed in the page, because in this app, the user may need to have several modals in progress at once.

So I'm doing it this way because I want users to be able to work on more than one form at a time, before submitting any of the forms. For example, they can close a modal to consult the page underneath, or consult a different modal form in progress. Then they can re-open the first modal again with the same toggle on the page, and pick up where they left off.

Each modal is only rendered from scratch the first time it appears, and then it can be closed or opened, without removing it from the DOM, until it's submitted.

I'm currently handling this with a Stimulus controller that checks if the modal is already in the DOM, and either shows the existing modal, or fetches a response from the Rails controller using requestjs-rails, to render the blank form modal and put it in the DOM, and show it.

Having just discovered Turbo.StreamActions, i'm wondering if this can be accomplished entirely within the Turbo response.

But I can't imagine how to check the DOM state (specifically, return a boolean whether there's a modal with the same ID already printed in the page) with a Turbo custom action: Turbo actions seem to be a one way response from Ruby --> JS. They are for sending "HTML over the wire." The "payload" of a Turbo.StreamAction is html, not data.

or... can I write a custom Turbo.StreamAction to return a value? I can't picture how it would work... and doing something like this seems to violate the intended separation of scope between Turbo and Stimulus.

Here's where i'm stuck:

# app/views/somethings/new.erb

# can a turbo_stream.action take two arguments?
<%
args = { partial: "shared/modal_form",
         locals: { title: title, body: body } }

<%= turbo_stream.show_or_render_modal_form(id, args) %>
// app/javascript/application.js

Turbo.StreamActions.open_modal = function () {
  $("#" + this.templateContent.textContent).modal('show')
};

Turbo.StreamActions.modal_exists = function () {
  return document.getElementById(this.templateContent.textContent)
};

Turbo.StreamActions.show_or_render_modal_form = function () {
  // ??? need to pass an id for the form, the partial path,
  //     and local_assigns for the partial. Can that all be 
  //     packed into this.templateContent ?
};
# config/initializers/turbo_stream_actions.rb
  def show_or_render_modal_form(id, args)
    if action(:modal_exists, "#", id)
      action(:open_modal, "#", id)
    else
      action(:render, "#", **args)
    end
  end
1

There are 1 answers

1
Alex On BEST ANSWER

There is no need for Stimulus here, but the "render or show modal" part should be handled from javascript.

<!-- view -->

<!-- `content` is not necessary, you could render whatever you want from controller actions -->
<!--                                                                  vvvvvvvvvvvvvv        -->
<%= button_to "edit something 1", "/", params: {modal: {id: :modal_1, content: "one"}} %>
<%= button_to "edit something 2", "/", params: {modal: {id: :modal_2, content: "two"}} %>
<!-- adjust urls as needed        ^^^ -->

<div id="modals"></div>

<style type="text/css">
  .hidden { display: none; }
</style>

When you click on these buttons, it should first render a text field, second click will just reveal rendered modal and hide all the other modals, any changes in the text fields should persist.

# controller

respond_to do |format|
  format.turbo_stream do
    render turbo_stream: turbo_stream.action(
      :modal,   # action
      "modals", # target  (not strictly necessary, you could append modal to <body>)
      #           content (modal form or anything you want to have here)
      helpers.tag.div(id: params[:modal][:id]) do
        helpers.text_field_tag :content, params[:modal][:content]
      end
    )
  end
end
// app/javascript/application.js

// append action for reference: https://github.com/hotwired/turbo/blob/v7.3.0/src/core/streams/stream_actions.ts#L11
Turbo.StreamActions.modal = function () {
  // targetElements are automatically found by Turbo which is just #modals target
  // it is an array because you could have multiple targets with a class selector
  // https://github.com/hotwired/turbo/blob/v7.3.0/src/elements/stream_element.ts#L99
  this.targetElements.forEach((target) => {
    Array.from(target.children).forEach((child) => {
      child.classList.add("hidden")
    })
    if (this.duplicateChildren.length > 0) {
      // duplicateChildren is a TurboStream function
      // https://github.com/hotwired/turbo/blob/v7.3.0/src/elements/stream_element.ts#L75
      this.duplicateChildren.forEach((modal) => {
        // if there is a modal already on the page, just unhide it
        modal.classList.remove("hidden")
      })
    } else {
      target.append(this.templateContent)
    }
  });
};

Obviously, I've skipped the actual modal css look, that will depend on your front end css framework.

https://github.com/hotwired/turbo/blob/v7.3.0/src/elements/stream_element.ts
https://github.com/hotwired/turbo-rails/blob/v1.5.0/app/models/turbo/streams/tag_builder.rb


If you need a more complicated logic, you could render a <turbo-stream> tag with other attributes that can be used in javascript:

# config/initializers/turbo_stream_actions.rb

module CustomTurboStreamActions
  # https://github.com/hotwired/turbo-rails/blob/v1.5.0/app/models/turbo/streams/tag_builder.rb#L214
  # add attributes to <turbo-stream> 
  #                                vvvvvvvvvvvvvv
  def modal(target, content = nil, attributes: {}, allow_inferred_rendering: true, **rendering, &block)
    template = render_template(target, content, allow_inferred_rendering: allow_inferred_rendering, **rendering, &block)
    #                                                   vvvvvvvvvvvv
    turbo_stream_action_tag :modal, target:, template:, **attributes
  end

  ::Turbo::Streams::TagBuilder.include(self)
end

https://github.com/hotwired/turbo-rails/blob/v1.5.0/app/helpers/turbo/streams/action_helper.rb#L26

# controller

respond_to do |format|
  format.turbo_stream do
    render turbo_stream: turbo_stream.modal(
      "modals",
      helpers.tag.div(id: params[:modal][:id]) do
        helpers.text_field_tag :content, params[:modal][:content]
      end,
      # add some attributes 
      attributes: {if: "something"}
    )
    #=>
    # <turbo-stream if="something" action="modal" target="modals">
    #   <template>
    #     <div id="modal_1">
    #       <input type="text" name="content" id="content" value="one">
    #     </div>
    #   </template>
    # </turbo-stream>
  end
end
// app/javascript/application.js

Turbo.StreamActions.modal = function () {
  console.log(this.getAttribute("if"));
  // ... hopefully, you get the idea.
};

But, maybe at this point it would be simpler to have a stimulus controller for the front end logic.