Last active
February 1, 2021 12:31
-
-
Save julianrubisch/11190dc79e330760074bbf3508c2b702 to your computer and use it in GitHub Desktop.
StimulusReflex Patterns - 3 Optimistic UI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ReactionReflex < ApplicationReflex | |
def toggle | |
# reaction create/delete omitted | |
morph :nothing | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# app/reflexes/reaction_reflex.rb | |
class ReactionReflex < ApplicationReflex | |
after_reflex do | |
StreamBoardJob.perform_now(board: @reactable.board) | |
end | |
def toggle | |
# ... | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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