0

I have a Rails app where I just added Turbo Streams to update a list of items in real time. I followed this guide to do it: https://www.hotrails.dev/turbo-rails/

It works really well, except for one thing:

Each item in the list has buttons to "Edit" and "Remove" the item. But I need to only show those buttons when the user actually has access to do so. Before Turbo Streams I used policy(my_model).edit? from Pundit to decide whether or not to include the buttons, but this won't work because the Rails renderer does not work in the context of the receiver, so it cannot make that decision.

So my idea is to include the buttons and have some custom Javascript run whenever an item is created or updated, and then decide whether or not to show the buttons. Client side. But I can't find how to somehow hook up my own JS methods to run when a turbo stream is received.

Christoffer Reijer
  • 1,925
  • 2
  • 21
  • 40
  • Not quite clear. Why you can not use pundit on the view side? <% if policy(model).edit? %> <%= link_to 'Edit', post_path(post) %> <% end %> (example from the documentation) – sapiv71097 Jun 13 '23 at 20:11
  • Because Turbo Streams have no access to the Warden context. It is explained by DHH here: https://discuss.hotwired.dev/t/authentication-and-devise-with-broadcasts/1752/4 – Christoffer Reijer Jun 14 '23 at 08:01
  • If you think about it, it makes sense. User1 creates something, and Rails then renders HTML to send over WebSocket to whoever is listening. Rails cannot know who should display the button or not, and the "current_user" is the one who created the record, not the one who will receive the update. So I need to do that client side in JavaScript once the update is received. – Christoffer Reijer Jun 14 '23 at 08:04

1 Answers1

0

I found a solution to my problem!

I hooked up some Javascript via Stimulus to detect when a new resource is coming via Turbo Streams, then make a URL request to check permissions, and update the button visibility accordingly. Of course, this means one extra request whenever a new resource is added via ActionCable, but that is pretty much unavoidable if the permissions differ per resource.

Anyway, here's the code showing how I did everything.

I started by creating a helper function to detect server side if the rendering was going to be sent over ActionCable as a TurboStream.

# app/controllers/application_controller.rb

def turbo_stream?
  formats.any?(:turbo_stream)
end
helper_method :turbo_stream?

This helper can then be used in the view to skip the policy check if the request was inside the Turbo Stream context (as this will result in Devise could not find the `Warden::Proxy` instance on your request environment).

I also decided to hide the buttons by default in that case, so incoming updates will be without buttons for a brief fraction of a second, and they will then appear if the user has permission.

I also added a URL to the resource, and tags to the action buttons I want to hide or show. This will be used later in the Javascript controller.

<!-- app/views/resource/_resource.html.erb -->

<%= turbo_frame_tag resource do %>
  <div id="<%= dom_id resource %>"
       data-resource-url="<%= resource_path(resource, format: :json) %>">

    <!-- omitted code -->

    <% if turbo_stream? || policy(resource).edit? %>
      <%= link_to edit_resource_path(resource),
                  class: "btn btn-primary #{'d-none' if turbo_stream?}",
                  data: { resource_action: :edit } do %>
        <i class="las la-edit"></i>
        <span class="d-none d-lg-inline">
          <%= t("buttons.edit") %>
        </span>
      <% end %>
    <% end %>

    <% if turbo_stream? || policy(resource).destroy? %>
      <%= link_to resource,
                  class: "btn btn-danger #{'d-none' if turbo_stream?}",
                  data: {
                    resource_action: :destroy,
                    turbo_confirm: t("confirm.short"),
                    turbo_method: :delete
                  } do %>
        <i class="las la-trash-alt"></i>
        <span class="d-none d-lg-inline">
          <%= t("buttons.remove") %>
        </span>
      <% end %>
    <% end %>

  </div>
<% end %>

I adjusted my JSON template to include permissions for the requesting user.

# app/views/resources/_resource.json.jbuilder

json.permissions do
  json.edit policy(resource).edit?
  json.destroy policy(resource).destroy?
end

I then created a controller which would listen to the event turbo:before-stream-render and modify the event.detail.render method to run my processing after the default action.

That processing makes a URL request to fetch the permissions, and updates the visibility of the tagged action buttons based on the response.

// app/javascript/controllers/turbostream_controller.js

import Rails from "@rails/ujs"
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    addEventListener("turbo:before-stream-render",
                     (e) => { this.beforeStreamRender(e) })
  }

  beforeStreamRender(event) {
    const defaultAction = event.detail.render
    event.detail.render = (streamElement) => {
      defaultAction(streamElement)
      try { this.processStream(streamElement) }
      catch(error) { console.error(error) }
    }
  }

  processStream(streamElement) {
    if (["prepend", "append", "update"].includes(streamElement.action)) {
        var template = streamElement.children[0].content
        var templateDiv = template.querySelector('[data-resource-url]')
        if (templateDiv != null) {
          var id = templateDiv.getAttribute('id')
          this.setActionButtonVisibility(id)
        }
    }
  }

  setActionButtonVisibility(id) {
    var div = document.querySelector(`div#${id}`)
    var url = div.getAttribute('data-resource-url')
    var edit = div.querySelector('[data-resource-action="edit"]')
    var destroy = div.querySelector('[data-resource-action="destroy"]')
    Rails.ajax({
      type: "GET",
      url: url,
      success: (data, _status, _xhr) => {
        try {
          edit.classList.toggle('d-none', !data.permissions.edit)
          destroy.classList.toggle('d-none', !data.permissions.destroy)
        } catch(error) {
          console.error(error)
        }
      }
    })
  }
}

Last thing was to wrap my resource list in a div with the controller:

<!-- app/views/resource/index.html.erb -->

<div data-controller="turbostream">
  <!-- my original code -->
</div>

And that's it!

There is probably room for improvement but if someone else in the future faces a similar use case, I hope this could contribute.

Cheers!

Christoffer Reijer
  • 1,925
  • 2
  • 21
  • 40