How to use stimulus and turbo_stream on items which are often being changed?
Asked Answered
D

1

2

I have a simple list of items. Each item has attributes which are often changing (e.g. every second).

I use turbo_stream_from "list_items" in order to keep the data fresh.

I wanted to add a checkbox next to each list_item in order to have ability to e.g. delete specific items from the list.

When user checks/selects a specific item and when new change is received via turbo stream afterwards -> selection is lost because HTML for the item is fully replaced.

Is there a way to deal with this via "Rails standards"? Is it better to consider React/Vue (libraries with virtual DOM)?

Distracted answered 25/4, 2023 at 20:16 Comment(0)
B
2

Here is one solution: morphdom + custom turbo stream action:

# app/models/item.rb

class Item < ApplicationRecord
  after_create_commit -> { broadcast_append_to(:list_items) }
  after_destroy_commit -> { broadcast_remove_to(:list_items) }
  # send a custom turbo stream action, instead of `update`
  after_update_commit -> { broadcast_action_to(:list_items, action: :morph, target: self, **broadcast_rendering_with_defaults({})) }

  # `Item.play` from a console to send some test streams
  def self.play
    Item.destroy_all
    10.times { Item.create(name: "") }
    item_ids = Item.ids
    count = 0
    loop do
      item = Item.find(item_ids.sample)
      item.update(name: item.name + ("a".."z").to_a.sample)

      Item.where("length(name) > 15").update_all(name: "")

      count += 1
      Turbo::StreamsChannel.broadcast_prepend_to(:list_items,
        target: :items,
        html: %(<div id="counter">#{count}</div>)
      )
    end
  end
end
# app/views/items/index.html.erb
<%= turbo_stream_from :list_items %>
<%= tag.div id: :items, class: "grid gap-2" do %>
  <%= render @items %>
<% end %>

# app/views/items/_item.html.erb
<%= tag.div id: dom_id(item), class: "flex gap-4" do %>
  <%= check_box field_name(:item, item.id), :_destroy %>
  <%= link_to item.name, item %>
<% end %>

Handle custom morph action on the front end:

// config/importmap.rb

pin "morphdom", to: "https://cdn.jsdelivr.net/npm/[email protected]/dist/morphdom-esm.js"
// app/javascript/application.js

import morphdom from "morphdom";

// this function is called when `turbo_stream.action(:morph, ...)` renders
// on the front end.
Turbo.StreamActions.morph = function () {
  this.targetElements.forEach((target) => {
    morphdom(target, this.templateContent, {
      // this is the magic bit
      onBeforeElUpdated: function (fromElement, toElement) {
        if (fromElement.isEqualNode(toElement)) return false;
        return true;
      },
    });
  });
};

https://github.com/patrick-steele-idem/morphdom

https://turbo.hotwired.dev/handbook/streams#custom-actions


Update

For custom actions there is action method, it works in templates and controllers:
https://github.com/hotwired/turbo-rails/blob/v1.4.0/app/models/turbo/streams/tag_builder.rb#L214

<%= tag.div "one", id: :target_id %>


<%= turbo_stream.action :morph, :target_id, tag.div("two") %>

<%= turbo_stream.action :morph, :target_id, partial: "two", locals: {name: "two"} %>

To make it a method, add it to turbo tag builder:

# config/initializers/turbo_stream_actions.rb

module TurboStreamActions
  def morph(...)
    action(:morph, ...)
  end

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

Now you can do this:

<%= turbo_stream.morph :target_id, partial: "two", locals: {name: "three"} %>
Bobette answered 26/4, 2023 at 16:7 Comment(2)
turbo_stream.turbo_stream_action_tag :morph, template: render('generating_status'), target: 'generating_status' is there a better way for calling custom actions in erb files?Distracted
Snap .. This morph library is insane. Amazing! Thanks @BobetteDistracted

© 2022 - 2024 — McMap. All rights reserved.