Skip to content

Instantly share code, notes, and snippets.

@julianrubisch
Last active February 1, 2021 12:31
Show Gist options
  • Save julianrubisch/11190dc79e330760074bbf3508c2b702 to your computer and use it in GitHub Desktop.
Save julianrubisch/11190dc79e330760074bbf3508c2b702 to your computer and use it in GitHub Desktop.
StimulusReflex Patterns - 3 Optimistic UI
# app/channels/reaction_channel.rb
class ReactionChannel < ApplicationCable::Channel
def subscribed
stream_from "reaction:#{current_user.id}"
end
end
# app/jobs/stream_reaction_job.rb
class StreamReactionJob < ApplicationJob
include CableReady::Broadcaster
queue_as :default
def perform(reactable:, user:)
cable_ready["reaction:#{user.id}"].inner_html(
selector: "#{dom_id(reactable)}-reactions",
html: ApplicationController.render(ReactionComponent.new(reactable: reactable, user: user), layout: false)
).broadcast
end
end
# app/reflexes/reaction_reflex.rb
class ReactionReflex < ApplicationReflex
after_reflex do
@reactable.users.each do |notified_user|
StreamReactionJob.perform_later(reactable: @reactable, user: notified_user)
end
end
def toggle
# ...
end
end
# app/models/embede.rb
class Embed < ApplicationRecord
# ...
delegate :users, to: :board
# ...
end
# db/migrate/....create_reactions.rb
class CreateReactions < ActiveRecord::Migration[6.1]
def change
create_table :reactions do |t|
t.references :reactable, polymorphic: true
t.references :user, null: false, foreign_key: true
t.string :content
t.timestamps
end
end
end
# app/models/reaction.rb
REACTION_EMOJIS = %w[thumbsup thumbsdown clap heart rocket headphones star scissors sparkles question].map { |name| Emoji.find_by_alias(name) }.freeze
class Reaction < ApplicationRecord
belongs_to :reactable, polymorphic: true, touch: true
belongs_to :user
validates :content, uniqueness: {scope: %i[user reactable]}
end
# app/models/user.rb
class User < ApplicationRecord
# ...
has_many :reactions
end
# app/models/comment.rb
class Comment < ApplicationRecord
# ...
has_many :reactions, as: :reactable
# ...
end
# app/models/embed.rb
class Embed < ApplicationRecord
# ...
has_many :reactions, as: :reactable
# ...
end
<!-- app/views/comments/_comment.html.erb -->
<li>
<div class="relative pb-8">
<!-- ... -->
<div class="relative flex items-start space-x-3">
<!-- ... -->
<div class="min-w-0 flex-1">
<div>
<div class="text-sm flex justify-between">
<a href="#" class="font-medium text-gray-900"><%= comment.user.email %></a>
<!-- this is new 👇 -->
<%= render(ReactionComponent.new(reactable: comment, user: current_user)) %>
</div>
<!-- ... -->
</div>
<!-- ... -->
</div>
</div>
</div>
</li>
# app/components/reaction_component.rb
class ReactionComponent < ViewComponent::Base
# ...
def reaction_button(emoji:, klass: "", disabled: false, count: nil, &block)
button_tag type: "button",
class: klass,
id: "#{dom_id(@reactable)}-#{Emoji.find_by_unicode(emoji)&.name&.parameterize}",
disabled: disabled,
data: {action: "click->reaction#toggle",
reactable_class: @reactable.class.name, # <-- this is new
emoji: emoji,
reactable_id: @reactable.id,
count: count,
user_reacted: emoji == "EMOJI" ? "USER_REACTED" : user_reacted_with_emoji?(emoji)},
&block
end
end
# app/reflexes/reaction_reflex.rb
class ReactionReflex < ApplicationReflex
# ...
def toggle
reactable_class = element.dataset.reactable_class.safe_constantize
@reactable = reactable_class.find(element.dataset.reactable_id)
# ...
end
end
# app/models/comment.rb
class Comment < ApplicationRecord
# ...
delegate :users, to: :embed
end
# app/components/reaction_component.rb
class ReactionComponent < ViewComponent::Base
# ...
def reaction_button(emoji:, klass: "", disabled: false, count: nil, &block)
button_tag type: "button",
class: klass,
id: "#{dom_id(@reactable)}-#{Emoji.find_by_unicode(emoji)&.name&.parameterize}",
disabled: disabled,
data: {action: "click->reaction#toggle",
emoji: emoji,
reactable_id: @reactable.id,
count: count,
user_reacted: emoji == "EMOJI" ? "USER_REACTED" : user_reacted_with_emoji?(emoji)},
&block
end
end
<!-- app/components/reaction_component.html.erb -->
<div class="flex-1 flex justify-end items-center space-x-2 md: space-x-3" data-controller="reaction">
<div data-reaction-target="reactions">
<!-- all present reactions as buttons -->
</div>
<template data-reaction-target="template">
<%= reaction_button(emoji: "EMOJI",
count: "COUNT",
klass: "inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium space-x-1 BACKGROUND text-gray-800 border BORDER") do %>
<span class="emoji">EMOJI</span>
<span class="count">COUNT</span>
<% end %>
</template>
</div>
<!-- app/components/reaction_component.html.erb -->
<div class="...">
<% reactions.each do |emoji, reactions| %>
<%= reaction_button(emoji: emoji,
count: reactions.size,
klass: "...
#{user_reacted_with_emoji?(emoji) ? 'bg-lime-100' : 'bg-gray-100'}
#{user_reacted_with_emoji?(emoji) ? 'border-lime-500' : 'border-transparent'}") do %>
<span class="emoji"><%= emoji %></span>
<span class="count"><%= reactions.size %></span>
<% end %>
<% end %>
<div class="..." data-controller="emoji-popover">
<div data-emoji-popover-target="template" class="...">
<% REACTION_EMOJIS.each do |emoji| %>
<%= reaction_button(emoji: emoji.raw, disabled: user_reacted_with_emoji?(emoji.raw), klass: ("opacity-50 cursor-default" if user_reacted_with_emoji?(emoji.raw))) do %>
<%= emoji&.raw %>
<% end %>
<% end %>
</div>
<span class="fa-layers fa-fw">
<i class="fas fa-grin"></i>
<i class="fas fa-plus-circle fa-inverse" data-fa-transform="shrink-6 up-4 right-4"></i>
</span>
</div>
</div>
# app/components/reaction_component.rb
class ReactionComponent < ViewComponent::Base
def initialize(reactable:, user:)
@reactable = reactable
@user = user
end
def reactions
@reactable.reactions.reduce(Hash.new([])) do |memo, reaction|
memo[reaction.content] += [reaction]
memo
end
end
def user_reacted_with_emoji?(emoji)
reactions[emoji].map(&:user).include?(@user)
end
def reaction_button(emoji:, klass: "", disabled: false, count: nil, &block)
button_tag type: "button",
class: klass,
id: "#{dom_id(@reactable)}-#{Emoji.find_by_unicode(emoji)&.name&.parameterize}",
data: {reflex: "click->ReactionReflex#toggle", emoji: emoji&.raw, reactable_id: @reactable.id}, &block
disabled: disabled,
end
end
class ReactionReflex < ApplicationReflex
def toggle
@reactable = Embed.find(element.dataset.reactable_id)
reaction = @reactable.reactions.find_or_initialize_by(user: current_user, content: element.dataset.emoji)
if reaction.new_record?
reaction.save
else
reaction.destroy
end
end
end
class ReactionReflex < ApplicationReflex
def toggle
# reaction create/delete omitted
morph :nothing
end
end
# app/reflexes/reaction_reflex.rb
class ReactionReflex < ApplicationReflex
after_reflex do
StreamBoardJob.perform_now(board: @reactable.board)
end
def toggle
# ...
end
end
// app/javascript/controllers/reaction_controller.js
export default class extends ApplicationController {
// ...
toggleError(element, reflex, error, reflexId) {
console.error("toggleError", error);
alert(
"An error transmitting the reaction occurred. Refreshing the page..."
);
location.reload();
}
}
import ApplicationController from "./application_controller";
export default class extends ApplicationController {
static targets = ["template", "reactions"];
connect() {
super.connect();
}
toggle(e) {
this.stimulate("ReactionReflex#toggle", e.currentTarget);
}
beforeToggle(element, reflex, noop, reflexId) {
let { count, userReacted, emoji } = element.dataset;
count = parseInt(count);
userReacted = userReacted === "true";
let content = this.templateTarget.innerHTML.replace(/EMOJI/g, emoji);
if (userReacted) {
if (count > 1) { // (2)
content = content
.replace(/COUNT/g, count - 1)
.replace(/USER_REACTED/g, "false")
.replace(/BORDER/g, "border-transparent")
.replace(/BACKGROUND/g, "bg-gray-100");
element.outerHTML = content;
} else { // (1)
element.parentNode.removeChild(element); }
} else {
content = content
.replace(/USER_REACTED/g, "true")
.replace(/BORDER/g, "border-lime-500")
.replace(/BACKGROUND/g, "bg-lime-100");
if (count > 0) { // (4)
content = content.replace(/COUNT/g, count + 1);
element.outerHTML = content;
} else { // (3)
content = content.replace(/COUNT/g, 1);
this.reactionsTarget.insertAdjacentHTML("beforeend", content);
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment