Rails Actioncable for specific pages
Asked Answered
P

5

6

In my project I have a model categories, which has a detail view(show). The view contains data that should be updated in realtime with actioncable. The content/data of the view is not important to understand the problem.

A standart setup where I have just a single category with hardcoded id works perfectly, but now I want to make it dynamic and page specific, so that I don't need to open 100 subscriptions that I mabye do not even need if I'm not on a categories detail page.

First problem: how can I create connections for only the current page/category?

Second problem: how can I get the id of the current category?

App.cable.subscriptions.create { channel: "RankingChannel", category_id: HOW_DO_I_GET_THIS}

The only thing I found was this, but it did not work: https://mcmap.net/q/1632212/-page-specific-actioncable

Prescott answered 20/9, 2016 at 15:8 Comment(0)
M
10

Channels don't seem to be designed to be page specific (just as controllers are not page specific), but you can scope your channel (as you are trying to do) so that the subscription only receives messages for the given category.


You can selectively include the channel's js file on a page by putting something like this in the head of the layout file:

<%= javascript_include_tag 'channels/'+params[:controller] %>

rather than doing //= require_tree ./channels in your cables.js file.

but this has the disadvantage of requiring you to include the js file in the precompile array in your config\initializers\assets.rb initializer and restarting your server:

Rails.application.config.assets.precompile += %w( channels/categories.js )

The channel definition is where you scope the stream, using stream_for with a model, which makes the connection id unique, e.g., categories:Z2lkOi8vcmFpbHMtc3RaaaRlci9Vc2VyLzI rather than just categories.

app/channels/ranking_channel.rb

class RankingChannel < ApplicationCable::Channel
  def subscribed
    category = Category.find(params[:category_id])
    stream_for category
  end

  ...

end

You send the category_id from the client page to the server. Here, we're doing it via a data-category-id attribute on a #category element in the DOM (using jQuery syntax, but could be converted to straight js pretty easily).

app/assets/javascripts/channels/categories.js

$( function() {
  App.categories = App.cable.subscriptions.create(
    {
      channel: "RankingChannel",
      category_id: $("#category").data('category-id')
    },{
      received: function(data) {
        ...
      }
    }
  );
});

So in your view you would need to include the category id when the page is generated:

app/views/categories/show.html.erb

<div id="category" data-category-id="<%= @category.id %>">
  <div class="title">My Category</div>

  ...

</div>
Mucor answered 22/9, 2016 at 19:31 Comment(3)
Interesting answer. Would you mind explaining how the plug between clientToServer works since you generate a unique key server side and just use the id client side. Does rails do the matching automagically? One more thing, this seems to be unsecure for private data (like chat), since the user just has to change the data-id manually to plug to another channel and receive new message, or maybe I am missing something. Thank you.Padlock
@Patient55 yes, the matching is automagical, but basically the javascript file and the corresponding server side .rb file have corresponding methods that the ActionCable server and client side code glue together for you. As for security, you can specify authentication in the subscribed method of the ranking_channel.rb file. For example, you can reject unless current_user.categories.include?(category) to restrict user access to categories that they have an association with.Mucor
Thanks for your answer. I also concluded with the same approach and described it on my blog post jademind.com/blog/posts/….Palpitate
B
5

Well, for Rails 6, I solved it similar to @YaEvan's answer above.

I put a condition on all of my *_channel.js files:

import consumer from "./consumer"

document.addEventListener('turbolinks:load', function() {
  // Because I was going to work with this element anyway, I used it to make the condition.
  var body = document.getElementById("news_body");
  if(body) {
    consumer.subscriptions.create("NewsChannel", {
      connected() {
        // Called when the subscription is ready for use on the server
      },

      disconnected() {
        // Called when the subscription has been terminated by the server
      },

      received(data) {
        // Called when there's incoming data on the websocket for this channel
      }
    });
  }
});

It is a work around. I didn't find an answer for what is the proper way to do it. So if you ever discover, please let me know!

Bilodeau answered 19/12, 2019 at 0:1 Comment(0)
B
2

If you want to specify the current page, you should add the controller and action tags inside the body tag.

use: data-controller="#{controller.controller_path}" data-action="#{controller.action_name in "body"

detail: Body class for controller in Rails app

And then judge whether the specified page in the js.

coffee:

jQuery(document).on 'turbolinks:load', ->
    if $('body').attr('data-controller') == 'special_controller' && $('body').attr('data-action') == 'special_action'
       App.server = App.cable.subscriptions.create "SpecialChannel",
       received: (data) -> 

js:

jQuery(document).on('turbolinks:load', function() {
    if ($('body').attr('data-controller') === 'special_controller' && 
        $('body').attr('data-action') === 'special_action') {
            return App.server = 
                App.cable.subscriptions.create("SpecialChannel", {
                    received: function(data) {}
                });
         }
 });

Sometimes we add js only to a certain page, unless some js file is very large, I recommend loading all js, judging the page and behavior running js.

Benison answered 13/4, 2018 at 7:16 Comment(0)
E
0

Another approach I've seen is decorating your html with data attributes to indicate behavior expectations (like putting the current resource's id in a data attribute when visiting a show page or something). This way, the data attribute could be looked for in your javascript, and if found, create the subscription client side.

<div data-category="<%= @category.id %>">
  ...
</div>

I like this approach more because it let's the server side code remain closer to the default and it only take an additional if statement in your javascript and an additional attribute in your html.

This is really similar to the link you mention in your question. I'd suggest trying it again as I was able to get it to work in my own context / usecase.

Erivan answered 14/10, 2016 at 19:28 Comment(0)
D
0

I've created a function that I can call when I want to trigger the connection. This gives the added benefit of passing additional context into the registration.

registerSomeChannel = function(bleh) {
  console.log("Register some channel for bleh:", bleh);
  App.cable.subscriptions.create({
    channel: "SomeChannel",
    bleh: bleh
  }, {
    connected: function() {
      // Called when the subscription is ready for use on the server
      console.log("Connected to some channel", this);
    },

    disconnected: function() {
      // Called when the subscription has been terminated by the server
      console.log("Disconnected from some channel");
    },

    received: function(data) {
      // Called when there's incoming data on the websocket for this channel
      console.log("ActionCable received some data:", data);
    }
  });
}

In the controller's JavaScript file I can then do this:

$(document).ready(function(){
  // Register for some channel
  var bleh = "yay";
  registerSomeChannel(bleh);
});

Caveat: This is my first time to really play with ActionCable, so YMMV.

Downrange answered 26/4, 2017 at 18:14 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.