Allow boosts on cards (#2411)
* Add reactions association to Card model Adds `has_many :reactions` to Card, matching the implementation in Comment. This allows cards to be reacted to directly with emoji reactions. The association: - Orders reactions chronologically - Uses polymorphic `:reactable` interface - Deletes reactions when card is destroyed Includes test coverage for the new association. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add reactions UI to card detail view - Create Cards::ReactionsController with turbo stream responses - Add card reactions views (reactions list, new form, menu partial) - Position reaction button flush-right when no reactions exist - Show reactions on bottom-left when present, with button inline - Handle narrow viewports by flowing button below meta section - Add controller tests for card reactions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add boost count to card preview Display reaction count alongside comment count on card tiles in board columns. Introduces a .card__counts wrapper to group both counts with proper positioning on all viewport sizes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Consolidate reaction views for cards and comments Extract shared views to app/views/reactions/ using polymorphic routing. Both card and comment reactions now render the same partials, with a helper to compute the correct path prefix for nested routes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix emoji picker width in card reactions Override .card .popup { inline-size: 260px } for the reaction popup so the emoji grid displays without horizontal scrollbar. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Hide boost count where comment count is hidden Add .card__boosts alongside .card__comments in: - Tray view (display: none) - Cards with background images (opacity toggle on hover) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix reactions button overlap with zoom button in card footer When a card has a background image, the footer contains both a reactions button and a zoom button. Previously they would overlap because the reactions button was absolutely positioned to the bottom-right. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Tighten up previews * Move card reactions from meta partial to container Fixes two issues: 1. Reactions were duplicated each time a card was assigned because the turbo stream replaced the meta div with the full perma/meta partial, which included reactions outside the replaced element. 2. Draft cards incorrectly showed the boost button because the meta partial was shared between published and draft containers. Moving reactions to _container.html.erb (published cards only) fixes both issues and keeps the meta partial focused on meta content. Fixes https://app.fizzy.do/5986089/cards/3837 Fixes https://app.fizzy.do/5986089/cards/3835 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * WIP adjust perma page * Position and style the footer * Render the stamp in the card body instead of the footer * Fix undefined local variable in public cards view Changed `card` to `@card` when rendering the stamp partial. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Preload card reactions to avoid N+1 queries Include reactions and their reacters in the preloaded scope, matching what we already do for comments. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add model tests for card and comment reaction cleanup Test that: - Creating a card reaction touches the card's last_active_at - Reactions are deleted when their parent comment is destroyed - Reactions are deleted when their parent card is destroyed Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Minor improvements to reactions code - Use size instead of count in boosts partial to avoid extra SQL query on already-loaded collection - Add else clause to reaction_path_prefix_for to raise ArgumentError for unknown reactable types instead of silently returning nil Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Bump specificity of reaction popup in the card perma --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Andy Smith <andy@37signals.com>
This commit is contained in:
@@ -536,16 +536,33 @@
|
||||
/* Set lower limit for font size */
|
||||
font-size: clamp(0.6rem, 0.85cqi, 100px);
|
||||
|
||||
.card__comments {
|
||||
--column-gap: 0.8ch;
|
||||
.card__counts {
|
||||
--gap: 0.5ch;
|
||||
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
margin-block-end: calc(var(--block-space) * -1.7);
|
||||
margin-inline-end: calc(var(--card-padding-inline) * -0.5);
|
||||
flex-shrink: 0;
|
||||
gap: calc(2 * var(--gap));
|
||||
margin-inline: auto calc(var(--card-padding-inline) * -0.5);
|
||||
padding-inline-start: var(--gap);
|
||||
}
|
||||
|
||||
.icon {
|
||||
--icon-size: 1.6em;
|
||||
.card__boosts,
|
||||
.card__comments {
|
||||
--icon-size: 1.6em;
|
||||
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
gap: var(--gap);
|
||||
|
||||
img {
|
||||
block-size: var(--icon-size);
|
||||
inline-size: var(--icon-size);
|
||||
}
|
||||
|
||||
.icon--comment {
|
||||
color: var(--card-color);
|
||||
}
|
||||
}
|
||||
@@ -584,11 +601,16 @@
|
||||
.local-time-value {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
inline-size: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.card__background img:not([src=""])) {
|
||||
.card__content,
|
||||
.card__meta,
|
||||
.card__boosts,
|
||||
.card__comments,
|
||||
.card__column-name:not(.card__column-name--current) {
|
||||
opacity: 0;
|
||||
@@ -599,6 +621,7 @@
|
||||
&:hover {
|
||||
.card__content,
|
||||
.card__footer,
|
||||
.card__boosts,
|
||||
.card__comments,
|
||||
.card__column-name:not(.card__column-name--current) {
|
||||
opacity: 1;
|
||||
|
||||
@@ -34,6 +34,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 799px) {
|
||||
--half-btn-height: 1.25rem;
|
||||
--padding-inline: 1.5ch;
|
||||
|
||||
column-gap: 0;
|
||||
grid-template-areas:
|
||||
"notch-top notch-top notch-top"
|
||||
"card card card"
|
||||
"actions-left notch-bottom actions-right"
|
||||
"closure-message closure-message closure-message";
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
inline-size: calc(100% + 2 * var(--padding-inline));
|
||||
margin-inline: calc(-1 * var(--padding-inline));
|
||||
max-inline-size: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card {
|
||||
--card-aspect-ratio: 2 / 0.95;
|
||||
--lexxy-bg-color: var(--card-bg-color);
|
||||
@@ -47,6 +64,8 @@
|
||||
}
|
||||
|
||||
.card__body {
|
||||
position: relative;
|
||||
|
||||
@media (max-width: 639px) {
|
||||
flex-direction: column;
|
||||
padding-block-end: calc(var(--card-padding-block) * 1.5);
|
||||
@@ -86,6 +105,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card__meta {
|
||||
grid-area: meta;
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.card__stages {
|
||||
max-inline-size: 32ch;
|
||||
|
||||
@@ -119,6 +143,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card__footer {
|
||||
--btn-size: 2.5rem;
|
||||
|
||||
display: flex;
|
||||
gap: 0.5ch;
|
||||
inline-size: 100%;
|
||||
text-align: start;
|
||||
|
||||
/* Switch to grid layout so that the bg zoom button can stay next to the
|
||||
* meta element, and the reactions can sit below */
|
||||
&:has(.reaction) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
"meta bg-zoom"
|
||||
"reactions reactions";
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
font-size: var(--text-x-small);
|
||||
}
|
||||
}
|
||||
|
||||
.reactions {
|
||||
--reaction-size: var(--btn-size);
|
||||
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
gap: 0.5ch;
|
||||
grid-area: reactions;
|
||||
margin-inline-start: auto;
|
||||
|
||||
&:has(.reaction) {
|
||||
--padding: calc(var(--card-padding-block) / 2);
|
||||
--reaction-size: 1.6875rem;
|
||||
|
||||
margin-block: var(--padding) calc(-1 * var(--padding));
|
||||
padding-block-start: var(--padding);
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
border-block-start: 1px dashed color-mix(in srgb, transparent, var(--card-color) 33%);
|
||||
content: "";
|
||||
inset: 0 calc(-1 * var(--card-padding-inline)) auto;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:has(.reaction)) {
|
||||
position: static;
|
||||
|
||||
.reactions__trigger {
|
||||
--btn-border-color: var(--color-ink-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reaction__popup.popup {
|
||||
inline-size: max-content;
|
||||
}
|
||||
|
||||
.card__zoom-bg-btn {
|
||||
grid-area: bg-zoom;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
--bubble-number-max: 42px;
|
||||
--bubble-size: 6rem;
|
||||
@@ -132,23 +221,6 @@
|
||||
inset: calc(var(--bubble-size) / 1.5) 0 auto auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 799px) {
|
||||
--half-btn-height: 1.5rem;
|
||||
--padding-inline: 1.5ch;
|
||||
|
||||
column-gap: 0;
|
||||
grid-template-areas:
|
||||
"notch-top notch-top notch-top"
|
||||
"card card card"
|
||||
"actions-left notch-bottom actions-right"
|
||||
"closure-message closure-message closure-message";
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
inline-size: calc(100% + 2 * var(--padding-inline));
|
||||
margin-inline: calc(-1 * var(--padding-inline));
|
||||
max-inline-size: none;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
/* Child items
|
||||
|
||||
@@ -252,9 +252,10 @@
|
||||
padding-block: var(--block-space-half);
|
||||
}
|
||||
|
||||
/* Meta
|
||||
/* Footer
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
/* Card metadata */
|
||||
.card__meta {
|
||||
--meta-spacer-block: 0.5ch;
|
||||
--meta-spacer-inline: 0.75ch;
|
||||
@@ -376,11 +377,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: bold;
|
||||
inset: auto 1ch 1ch auto;
|
||||
inset: auto 0 -1lh auto;
|
||||
justify-content: center;
|
||||
max-inline-size: 25ch;
|
||||
min-inline-size: 16ch;
|
||||
padding: 1ch;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
rotate: 5deg;
|
||||
transform-origin: top right;
|
||||
|
||||
@@ -80,6 +80,11 @@
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.reactions {
|
||||
margin-block-start: var(--block-space-half);
|
||||
margin-inline: calc(var(--column-gap) / -1);
|
||||
}
|
||||
}
|
||||
|
||||
.comment__author {
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
flex-wrap: wrap;
|
||||
gap: var(--inline-space-half);
|
||||
inline-size: 100%;
|
||||
margin-block-start: var(--block-space-half);
|
||||
margin-inline: calc(var(--column-gap) / -1);
|
||||
|
||||
&:has([open]) {
|
||||
z-index: var(--z-popup);
|
||||
@@ -34,11 +32,8 @@
|
||||
|
||||
.reactions__trigger {
|
||||
--btn-border-color: var(--color-ink-lightest);
|
||||
background-color: var(--color-ink-lightest);
|
||||
|
||||
&:not(:hover) {
|
||||
opacity: 0.66;
|
||||
}
|
||||
background-color: var(--color-ink-lightest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,7 @@
|
||||
.card__meta-text:not(.card__meta-text--updated),
|
||||
.card__stages,
|
||||
.card__steps,
|
||||
.card__boosts,
|
||||
.card__comments,
|
||||
.card__closed {
|
||||
display: none;
|
||||
|
||||
@@ -2,20 +2,26 @@ class Cards::Comments::ReactionsController < ApplicationController
|
||||
include CardScoped
|
||||
|
||||
before_action :set_comment
|
||||
before_action :set_reaction, only: %i[ destroy ]
|
||||
before_action :ensure_permision_to_administer_reaction, only: %i[ destroy ]
|
||||
before_action :set_reactable
|
||||
|
||||
with_options only: :destroy do
|
||||
before_action :set_reaction
|
||||
before_action :ensure_permission_to_administer_reaction
|
||||
end
|
||||
|
||||
def index
|
||||
render "reactions/index"
|
||||
end
|
||||
|
||||
def new
|
||||
render "reactions/new"
|
||||
end
|
||||
|
||||
def create
|
||||
@reaction = @comment.reactions.create!(params.expect(reaction: :content))
|
||||
@reaction = @reactable.reactions.create!(params.expect(reaction: :content))
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.turbo_stream { render "reactions/create" }
|
||||
format.json { head :created }
|
||||
end
|
||||
end
|
||||
@@ -24,7 +30,7 @@ class Cards::Comments::ReactionsController < ApplicationController
|
||||
@reaction.destroy
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.turbo_stream { render "reactions/destroy" }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
@@ -34,11 +40,15 @@ class Cards::Comments::ReactionsController < ApplicationController
|
||||
@comment = @card.comments.find(params[:comment_id])
|
||||
end
|
||||
|
||||
def set_reaction
|
||||
@reaction = @comment.reactions.find(params[:id])
|
||||
def set_reactable
|
||||
@reactable = @comment
|
||||
end
|
||||
|
||||
def ensure_permision_to_administer_reaction
|
||||
def set_reaction
|
||||
@reaction = @reactable.reactions.find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_permission_to_administer_reaction
|
||||
head :forbidden if Current.user != @reaction.reacter
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
class Cards::ReactionsController < ApplicationController
|
||||
include CardScoped
|
||||
|
||||
before_action :set_reactable
|
||||
|
||||
with_options only: :destroy do
|
||||
before_action :set_reaction
|
||||
before_action :ensure_permission_to_administer_reaction
|
||||
end
|
||||
|
||||
def index
|
||||
render "reactions/index"
|
||||
end
|
||||
|
||||
def new
|
||||
render "reactions/new"
|
||||
end
|
||||
|
||||
def create
|
||||
@reaction = @reactable.reactions.create!(params.expect(reaction: :content))
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream { render "reactions/create" }
|
||||
format.json { head :created }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@reaction.destroy
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream { render "reactions/destroy" }
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_reactable
|
||||
@reactable = @card
|
||||
end
|
||||
|
||||
def set_reaction
|
||||
@reaction = @reactable.reactions.find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_permission_to_administer_reaction
|
||||
head :forbidden if Current.user != @reaction.reacter
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
module ReactionsHelper
|
||||
def reaction_path_prefix_for(reactable)
|
||||
case reactable
|
||||
when Card then [ reactable ]
|
||||
when Comment then [ reactable.card, reactable ]
|
||||
else
|
||||
raise ArgumentError, "Unknown reactable type: #{reactable.class}"
|
||||
end
|
||||
end
|
||||
end
|
||||
+2
-1
@@ -7,6 +7,7 @@ class Card < ApplicationRecord
|
||||
belongs_to :board
|
||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||
|
||||
has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all
|
||||
has_one_attached :image, dependent: :purge_later
|
||||
|
||||
has_rich_text :description
|
||||
@@ -22,7 +23,7 @@ class Card < ApplicationRecord
|
||||
scope :chronologically, -> { order created_at: :asc, id: :asc }
|
||||
scope :latest, -> { order last_active_at: :desc, id: :desc }
|
||||
scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) }
|
||||
scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }
|
||||
scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, reactions: :reacter, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }
|
||||
|
||||
scope :indexed_by, ->(index) do
|
||||
case index
|
||||
|
||||
@@ -21,11 +21,14 @@
|
||||
<% if card.published? %>
|
||||
<%= render "cards/triage/columns", card: card %>
|
||||
<% end %>
|
||||
|
||||
<%= render "cards/display/common/stamp", card: card %>
|
||||
</div>
|
||||
|
||||
<footer class="card__footer full-width flex align-start gap">
|
||||
<footer class="card__footer">
|
||||
<%= render "cards/display/perma/meta", card: card %>
|
||||
<%= render "cards/display/perma/background", card: card %>
|
||||
<%= render "reactions/reactions", reactable: card %>
|
||||
</footer>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<%= comment.body %>
|
||||
</div>
|
||||
|
||||
<%= render "cards/comments/reactions/reactions", comment: comment %>
|
||||
<%= render "reactions/reactions", reactable: comment %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<%= turbo_frame_tag comment, :reacting do %>
|
||||
<div class="reactions">
|
||||
<div id="<%= dom_id(comment, :reactions) %>" class="reactions__list">
|
||||
<%= render partial: "cards/comments/reactions/reaction", collection: comment.reactions %>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag comment, :new_reaction do %>
|
||||
<%= link_to new_card_comment_reaction_path(comment.card, comment), role: "button",
|
||||
class: "reactions__trigger btn btn--circle", action: "soft-keyboard#open",
|
||||
data: { turbo_frame: dom_id(comment, :new_reaction), action: "dialog#close" } do %>
|
||||
<%= image_tag "boost-color.svg", aria: { hidden: true } %>
|
||||
<span class="for-screen-reader">Add your own reaction</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,3 +0,0 @@
|
||||
<%= turbo_stream.replace([ @comment, :reacting ]) do %>
|
||||
<%= render "cards/comments/reactions/reactions", comment: @comment.reload %>
|
||||
<% end %>
|
||||
@@ -1 +0,0 @@
|
||||
<%= render "cards/comments/reactions/reactions", comment: @comment %>
|
||||
@@ -1 +0,0 @@
|
||||
json.array! @comment.reactions.ordered, partial: "cards/comments/reactions/reaction", as: :reaction
|
||||
@@ -41,12 +41,16 @@
|
||||
</div>
|
||||
|
||||
<%= render "cards/display/preview/columns", card: card %>
|
||||
<%= render "cards/display/common/stamp", card: card %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="card__footer flex gap-half">
|
||||
<%= render "cards/display/preview/meta", card: card, preview: true %>
|
||||
<%= render "cards/display/preview/comments", card: card %>
|
||||
<div class="card__counts">
|
||||
<%= render "cards/display/preview/boosts", card: card %>
|
||||
<%= render "cards/display/preview/comments", card: card %>
|
||||
</div>
|
||||
<%= render "cards/display/common/background", card: card %>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
</div>
|
||||
|
||||
<%= render "cards/display/public_preview/columns", card: card if card.triaged? %>
|
||||
<%= render "cards/display/common/stamp", card: card %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,19 +5,3 @@
|
||||
|
||||
<%= yield %>
|
||||
<% end %>
|
||||
|
||||
<% if card.postponed? %>
|
||||
<div class="card__closed <%= "card__closed--system" if card.postponed_by&.system? %>">
|
||||
<span class="card__closed-title" data-text="Not Now">Not Now</span>
|
||||
<strong class="card__closed-date"><%= card.postponed_at.strftime("%b %d, %Y") %></strong>
|
||||
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.postponed_by&.familiar_name %></span></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if card.closed? %>
|
||||
<div class="card__closed">
|
||||
<span class="card__closed-title" data-text="Done">Done</span>
|
||||
<strong class="card__closed-date"><%= card.closed_at.strftime("%b %d, %Y") %></strong>
|
||||
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.closed_by.familiar_name %></span></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<% if card.postponed? %>
|
||||
<div class="card__closed <%= "card__closed--system" if card.postponed_by&.system? %>">
|
||||
<span class="card__closed-title" data-text="Not Now">Not Now</span>
|
||||
<strong class="card__closed-date"><%= card.postponed_at.strftime("%b %d, %Y") %></strong>
|
||||
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.postponed_by&.familiar_name %></span></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if card.closed? %>
|
||||
<div class="card__closed">
|
||||
<span class="card__closed-title" data-text="Done">Done</span>
|
||||
<strong class="card__closed-date"><%= card.closed_at.strftime("%b %d, %Y") %></strong>
|
||||
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.closed_by.familiar_name %></span></span>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= render "cards/display/common/background", card: card do %>
|
||||
<%= link_to card.image.presence, class: "btn fill-transparent flex-item-justify-end", data: { controller: "tooltip", lightbox_target: "image" } do %>
|
||||
<%= link_to card.image.presence, class: "card__zoom-bg-btn btn", data: { controller: "tooltip", lightbox_target: "image" } do %>
|
||||
<%= icon_tag "picture-zoom" %>
|
||||
<span class="for-screen-reader">Zoom background image</span>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<% boosts = card.reactions %>
|
||||
<% if boosts.any? %>
|
||||
<div class="card__boosts">
|
||||
<%= image_tag "boost-color.svg", aria: { hidden: true } %>
|
||||
<span><%= boosts.size %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,7 +1,7 @@
|
||||
<% comments = card.comments.by_user %>
|
||||
<% if comments.any? %>
|
||||
<div class="card__comments align-center gap-half flex-item-justify-end flex-item-no-shrink">
|
||||
<div class="card__comments">
|
||||
<%= icon_tag "comment" %>
|
||||
<strong><%= comments.count %></strong>
|
||||
<span><%= comments.count %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="card__footer full-width flex align-start gap">
|
||||
<footer class="card__footer">
|
||||
<%= render "cards/display/perma/meta", card: card %>
|
||||
<%= render "cards/display/perma/background", card: card %>
|
||||
</footer>
|
||||
|
||||
@@ -30,11 +30,12 @@
|
||||
<div class="card__body justify-space-between">
|
||||
<%= render "public/cards/show/content", card: @card %>
|
||||
<%= render "cards/display/public_preview/columns", card: @card if @card.open? %>
|
||||
<%= render "cards/display/common/stamp", card: @card %>
|
||||
</div>
|
||||
|
||||
<%= render "public/cards/show/steps", card: @card %>
|
||||
|
||||
<footer class="card__footer full-width flex align-start gap">
|
||||
<footer class="card__footer">
|
||||
<%= render "cards/display/public_preview/meta", card: @card %>
|
||||
<%= render "cards/display/perma/background", card: @card %>
|
||||
</footer>
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
class: [ "txt-small", { "txt-medium": reaction.all_emoji? } ],
|
||||
data: { action: "click->reaction-delete#reveal keydown.enter->reaction-delete#reveal:prevent", reaction_delete_target: "content" } %>
|
||||
|
||||
<%= button_to card_comment_reaction_path(reaction.reactable.card, reaction.reactable, reaction),
|
||||
<%= button_to polymorphic_path([ *reaction_path_prefix_for(reaction.reactable), reaction ]),
|
||||
method: :delete,
|
||||
class: "reaction__delete btn btn--negative flex-item-justify-end",
|
||||
data: { action: "reaction-delete#perform", reaction_delete_target: "button" } do %>
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
json.cache! reaction do
|
||||
json.(reaction, :id, :content)
|
||||
json.reacter reaction.reacter, partial: "users/user", as: :user
|
||||
json.url card_comment_reaction_url(reaction.reactable.card, reaction.reactable, reaction)
|
||||
json.url polymorphic_url([ *reaction_path_prefix_for(reaction.reactable), reaction ])
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
<%= turbo_frame_tag reactable, :reacting do %>
|
||||
<div class="reactions">
|
||||
<div id="<%= dom_id(reactable, :reactions) %>" class="reactions__list">
|
||||
<%= render partial: "reactions/reaction", collection: reactable.reactions %>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag reactable, :new_reaction do %>
|
||||
<%= link_to new_polymorphic_path([ *reaction_path_prefix_for(reactable), :reaction ]), role: "button",
|
||||
class: "reactions__trigger btn btn--circle", action: "soft-keyboard#open",
|
||||
data: { turbo_frame: dom_id(reactable, :new_reaction), action: "dialog#close" } do %>
|
||||
<%= image_tag "boost-color.svg", aria: { hidden: true } %>
|
||||
<span class="for-screen-reader">Add your own reaction</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,3 @@
|
||||
<%= turbo_stream.replace([ @reactable, :reacting ]) do %>
|
||||
<%= render "reactions/reactions", reactable: @reactable.reload %>
|
||||
<% end %>
|
||||
@@ -0,0 +1 @@
|
||||
<%= render "reactions/reactions", reactable: @reactable %>
|
||||
@@ -0,0 +1 @@
|
||||
json.array! @reactable.reactions.ordered, partial: "reactions/reaction", as: :reaction
|
||||
@@ -1,8 +1,8 @@
|
||||
<%= turbo_frame_tag @comment, :new_reaction do %>
|
||||
<%= form_with model: [ @comment.card, @comment, Reaction.new ],
|
||||
<%= turbo_frame_tag @reactable, :new_reaction do %>
|
||||
<%= form_with model: [ *reaction_path_prefix_for(@reactable), Reaction.new ],
|
||||
class: "reaction reaction__form expanded",
|
||||
html: { aria: { label: "New reaction" } },
|
||||
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@comment, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %>
|
||||
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@reactable, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %>
|
||||
<label class="reaction__form-label flex gap" style="--column-gap: 0.4ch;">
|
||||
<figure class="reaction__avatar margin-none flex-item-no-shrink">
|
||||
<%= avatar_tag Current.user %>
|
||||
@@ -12,14 +12,14 @@
|
||||
pattern: /\S+.*/, class: "input reaction__input txt-small", data: { form_target: "input", reaction_emoji_target: "input", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a reaction" } %>
|
||||
</label>
|
||||
|
||||
<%= render "cards/comments/reactions/menu", comment: @comment %>
|
||||
<%= render "reactions/menu" %>
|
||||
|
||||
<%= form.button class: "reaction__submit-btn btn btn--circle borderless", type: "submit", data: { form_target: "submit" } do %>
|
||||
<%= icon_tag "check-circle" %> <span class="for-screen-reader">Submit</span>
|
||||
<% end %>
|
||||
|
||||
<%= link_to card_comment_reactions_path(@comment.card, @comment), role: "button",
|
||||
data: { turbo_frame: dom_id(@comment, :reacting), form_target: "cancel" }, class: "reaction__cancel-btn btn btn--circle borderless" do %>
|
||||
<%= link_to polymorphic_path([ *reaction_path_prefix_for(@reactable), :reactions ]), role: "button",
|
||||
data: { turbo_frame: dom_id(@reactable, :reacting), form_target: "cancel" }, class: "reaction__cancel-btn btn btn--circle borderless" do %>
|
||||
<%= icon_tag "close-circle" %> <span class="for-screen-reader">Cancel</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -86,6 +86,8 @@ Rails.application.routes.draw do
|
||||
resource :watch
|
||||
resource :reading
|
||||
|
||||
resources :reactions
|
||||
|
||||
resources :assignments
|
||||
resource :self_assignment, only: :create
|
||||
resources :steps
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
# Card Boosts Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Extend the Reaction model to support reactions on Cards (not just Comments), with a compact boost count on card previews and full reaction UI in the card detail footer.
|
||||
|
||||
**Architecture:** Make Reaction polymorphic with `reactable_type`/`reactable_id`. Create `Cards::ReactionsController` mirroring the existing comment reactions controller. Add views for card reactions in the footer, and a compact count display on card previews.
|
||||
|
||||
**Tech Stack:** Rails 8, Turbo, Stimulus, ERB views
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Migration - Make Reaction Polymorphic
|
||||
|
||||
**Files:**
|
||||
- Create: `db/migrate/YYYYMMDDHHMMSS_make_reactions_polymorphic.rb`
|
||||
|
||||
**Step 1: Generate and write the migration**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
bin/rails generate migration MakeReactionsPolymorphic
|
||||
```
|
||||
|
||||
Then edit the migration file:
|
||||
|
||||
```ruby
|
||||
class MakeReactionsPolymorphic < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :reactions, :reactable_type, :string
|
||||
add_column :reactions, :reactable_id, :uuid
|
||||
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
execute <<~SQL
|
||||
UPDATE reactions SET reactable_type = 'Comment', reactable_id = comment_id
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
change_column_null :reactions, :reactable_type, false
|
||||
change_column_null :reactions, :reactable_id, false
|
||||
|
||||
remove_column :reactions, :comment_id, :uuid
|
||||
|
||||
add_index :reactions, [:reactable_type, :reactable_id]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Run the migration**
|
||||
|
||||
Run: `bin/rails db:migrate`
|
||||
|
||||
**Step 3: Verify migration succeeded**
|
||||
|
||||
Run: `bin/rails runner "puts Reaction.column_names.inspect"`
|
||||
|
||||
Expected: Should include `reactable_type` and `reactable_id`, should NOT include `comment_id`
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add db/migrate/*_make_reactions_polymorphic.rb db/schema.rb
|
||||
git commit -m "Make Reaction polymorphic for card and comment reactions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update Reaction Model
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/models/reaction.rb`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add to `test/models/reaction_test.rb`:
|
||||
|
||||
```ruby
|
||||
test "reaction can belong to a card" do
|
||||
card = cards(:logo)
|
||||
reaction = card.reactions.create!(content: "🎉")
|
||||
|
||||
assert_equal card, reaction.reactable
|
||||
assert_equal "Card", reaction.reactable_type
|
||||
end
|
||||
|
||||
test "creating card reaction touches card last_active_at" do
|
||||
card = cards(:logo)
|
||||
original_last_active_at = card.last_active_at
|
||||
|
||||
travel 1.minute do
|
||||
card.reactions.create!(content: "🚀")
|
||||
end
|
||||
|
||||
assert_operator card.reload.last_active_at, :>, original_last_active_at
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `bin/rails test test/models/reaction_test.rb`
|
||||
|
||||
Expected: FAIL - Card doesn't have `reactions` association yet
|
||||
|
||||
**Step 3: Update the Reaction model**
|
||||
|
||||
Replace `app/models/reaction.rb`:
|
||||
|
||||
```ruby
|
||||
class Reaction < ApplicationRecord
|
||||
belongs_to :account, default: -> { reactable.account }
|
||||
belongs_to :reactable, polymorphic: true, touch: true
|
||||
belongs_to :reacter, class_name: "User", default: -> { Current.user }
|
||||
|
||||
scope :ordered, -> { order(:created_at) }
|
||||
|
||||
after_create :register_card_activity
|
||||
|
||||
delegate :all_emoji?, to: :content
|
||||
|
||||
private
|
||||
def register_card_activity
|
||||
reactable.card.touch_last_active_at
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they still fail (need Card association)**
|
||||
|
||||
Run: `bin/rails test test/models/reaction_test.rb`
|
||||
|
||||
Expected: FAIL - Card doesn't have `reactions` association
|
||||
|
||||
**Step 5: Commit model change**
|
||||
|
||||
```bash
|
||||
git add app/models/reaction.rb test/models/reaction_test.rb
|
||||
git commit -m "Update Reaction model to be polymorphic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Comment Model
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/models/comment.rb`
|
||||
|
||||
**Step 1: Update Comment association**
|
||||
|
||||
In `app/models/comment.rb`, change line 7 from:
|
||||
|
||||
```ruby
|
||||
has_many :reactions, -> { order(:created_at) }, dependent: :delete_all
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ruby
|
||||
has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all
|
||||
```
|
||||
|
||||
**Step 2: Run existing comment reaction tests**
|
||||
|
||||
Run: `bin/rails test test/controllers/cards/comments/reactions_controller_test.rb`
|
||||
|
||||
Expected: All tests PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/models/comment.rb
|
||||
git commit -m "Update Comment reactions association for polymorphic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add Reactions to Card Model
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/models/card.rb`
|
||||
|
||||
**Step 1: Add reactions association to Card**
|
||||
|
||||
In `app/models/card.rb`, after the `has_many :comments` line (around line 10), add:
|
||||
|
||||
```ruby
|
||||
has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all
|
||||
```
|
||||
|
||||
**Step 2: Run the model tests**
|
||||
|
||||
Run: `bin/rails test test/models/reaction_test.rb`
|
||||
|
||||
Expected: All tests PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/models/card.rb
|
||||
git commit -m "Add reactions association to Card model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add Routes for Card Reactions
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/routes.rb`
|
||||
|
||||
**Step 1: Write failing test for routes**
|
||||
|
||||
Add to `test/controllers/cards/reactions_controller_test.rb` (new file):
|
||||
|
||||
```ruby
|
||||
require "test_helper"
|
||||
|
||||
class Cards::ReactionsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in_as :david
|
||||
@card = cards(:logo)
|
||||
end
|
||||
|
||||
test "routes exist" do
|
||||
assert_routing({ path: "/5986089/cards/#{@card.number}/reactions", method: :get },
|
||||
{ controller: "cards/reactions", action: "index", account_id: "5986089", card_id: @card.number.to_s })
|
||||
assert_routing({ path: "/5986089/cards/#{@card.number}/reactions", method: :post },
|
||||
{ controller: "cards/reactions", action: "create", account_id: "5986089", card_id: @card.number.to_s })
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bin/rails test test/controllers/cards/reactions_controller_test.rb`
|
||||
|
||||
Expected: FAIL - No route matches
|
||||
|
||||
**Step 3: Add routes**
|
||||
|
||||
In `config/routes.rb`, inside the `resources :cards` block (around line 93), add after `resources :taggings`:
|
||||
|
||||
```ruby
|
||||
resources :reactions, module: :cards
|
||||
```
|
||||
|
||||
So it looks like:
|
||||
|
||||
```ruby
|
||||
resources :taggings
|
||||
|
||||
resources :reactions, module: :cards
|
||||
|
||||
resources :comments do
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify routes exist**
|
||||
|
||||
Run: `bin/rails test test/controllers/cards/reactions_controller_test.rb`
|
||||
|
||||
Expected: FAIL - Controller doesn't exist (but routes work)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add config/routes.rb test/controllers/cards/reactions_controller_test.rb
|
||||
git commit -m "Add routes for card reactions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create Cards::ReactionsController
|
||||
|
||||
**Files:**
|
||||
- Create: `app/controllers/cards/reactions_controller.rb`
|
||||
|
||||
**Step 1: Add controller tests**
|
||||
|
||||
Replace `test/controllers/cards/reactions_controller_test.rb`:
|
||||
|
||||
```ruby
|
||||
require "test_helper"
|
||||
|
||||
class Cards::ReactionsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in_as :david
|
||||
@card = cards(:logo)
|
||||
end
|
||||
|
||||
test "index" do
|
||||
get card_reactions_path(@card)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "create" do
|
||||
assert_difference -> { @card.reactions.count }, 1 do
|
||||
post card_reactions_path(@card, format: :turbo_stream), params: { reaction: { content: "🎉" } }
|
||||
assert_turbo_stream action: :replace, target: dom_id(@card, :reacting)
|
||||
end
|
||||
end
|
||||
|
||||
test "destroy" do
|
||||
reaction = @card.reactions.create!(content: "👍")
|
||||
|
||||
assert_difference -> { @card.reactions.count }, -1 do
|
||||
delete card_reaction_path(@card, reaction, format: :turbo_stream)
|
||||
assert_turbo_stream action: :remove, target: dom_id(reaction)
|
||||
end
|
||||
end
|
||||
|
||||
test "non-owner cannot destroy reaction" do
|
||||
sign_in_as :kevin
|
||||
reaction = @card.reactions.create!(content: "👍", reacter: users(:david))
|
||||
|
||||
assert_no_difference -> { @card.reactions.count } do
|
||||
delete card_reaction_path(@card, reaction, format: :turbo_stream)
|
||||
assert_response :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
test "create as JSON" do
|
||||
assert_difference -> { @card.reactions.count }, 1 do
|
||||
post card_reactions_path(@card), params: { reaction: { content: "👍" } }, as: :json
|
||||
end
|
||||
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
test "destroy as JSON" do
|
||||
reaction = @card.reactions.create!(content: "👍")
|
||||
|
||||
assert_difference -> { @card.reactions.count }, -1 do
|
||||
delete card_reaction_path(@card, reaction), as: :json
|
||||
end
|
||||
|
||||
assert_response :no_content
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `bin/rails test test/controllers/cards/reactions_controller_test.rb`
|
||||
|
||||
Expected: FAIL - Controller doesn't exist
|
||||
|
||||
**Step 3: Create the controller**
|
||||
|
||||
Create `app/controllers/cards/reactions_controller.rb`:
|
||||
|
||||
```ruby
|
||||
class Cards::ReactionsController < ApplicationController
|
||||
include CardScoped
|
||||
|
||||
before_action :set_reaction, only: %i[ destroy ]
|
||||
before_action :ensure_permision_to_administer_reaction, only: %i[ destroy ]
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
@reaction = @card.reactions.create!(params.expect(reaction: :content))
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.json { head :created }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@reaction.destroy
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.json { head :no_content }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_reaction
|
||||
@reaction = @card.reactions.find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_permision_to_administer_reaction
|
||||
head :forbidden if Current.user != @reaction.reacter
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Step 4: Run tests (will fail due to missing views)**
|
||||
|
||||
Run: `bin/rails test test/controllers/cards/reactions_controller_test.rb`
|
||||
|
||||
Expected: FAIL - Missing template
|
||||
|
||||
**Step 5: Commit controller**
|
||||
|
||||
```bash
|
||||
git add app/controllers/cards/reactions_controller.rb test/controllers/cards/reactions_controller_test.rb
|
||||
git commit -m "Add Cards::ReactionsController"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Create Card Reactions Views
|
||||
|
||||
**Files:**
|
||||
- Create: `app/views/cards/reactions/_reactions.html.erb`
|
||||
- Create: `app/views/cards/reactions/_reaction.html.erb`
|
||||
- Create: `app/views/cards/reactions/_menu.html.erb`
|
||||
- Create: `app/views/cards/reactions/index.html.erb`
|
||||
- Create: `app/views/cards/reactions/new.html.erb`
|
||||
- Create: `app/views/cards/reactions/create.turbo_stream.erb`
|
||||
- Create: `app/views/cards/reactions/destroy.turbo_stream.erb`
|
||||
|
||||
**Step 1: Create the views directory**
|
||||
|
||||
Run: `mkdir -p app/views/cards/reactions`
|
||||
|
||||
**Step 2: Create `_reactions.html.erb`**
|
||||
|
||||
```erb
|
||||
<%= turbo_frame_tag card, :reacting do %>
|
||||
<div class="reactions">
|
||||
<div id="<%= dom_id(card, :reactions) %>" class="reactions__list">
|
||||
<%= render partial: "cards/reactions/reaction", collection: card.reactions %>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag card, :new_reaction do %>
|
||||
<%= link_to new_card_reaction_path(card), role: "button",
|
||||
class: "reactions__trigger btn btn--circle", action: "soft-keyboard#open",
|
||||
data: { turbo_frame: dom_id(card, :new_reaction), action: "dialog#close" } do %>
|
||||
<%= image_tag "boost-color.svg", aria: { hidden: true } %>
|
||||
<span class="for-screen-reader">Add your own reaction</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
```
|
||||
|
||||
**Step 3: Create `_reaction.html.erb`**
|
||||
|
||||
```erb
|
||||
<div id="<%= dom_id(reaction) %>"
|
||||
class="reaction"
|
||||
data-controller="reaction-delete"
|
||||
data-reaction-delete-reacter-id-value="<%= reaction.reacter.id %>"
|
||||
data-reaction-delete-perform-class="reaction--deleting"
|
||||
data-reaction-delete-reveal-class="expanded"
|
||||
data-reaction-delete-deleteable-class="reaction--deleteable">
|
||||
<figure class="reaction__avatar margin-none flex-item-no-shrink">
|
||||
<%= avatar_tag reaction.reacter, aria: { label: "#{reaction.reacter.name} reacted #{reaction.content}" } %>
|
||||
</figure>
|
||||
|
||||
<%= tag.span reaction.content, role: "button",
|
||||
class: [ "txt-small", { "txt-medium": reaction.all_emoji? } ],
|
||||
data: { action: "click->reaction-delete#reveal keydown.enter->reaction-delete#reveal:prevent", reaction_delete_target: "content" } %>
|
||||
|
||||
<%= button_to card_reaction_path(reaction.reactable, reaction),
|
||||
method: :delete,
|
||||
class: "reaction__delete btn btn--negative flex-item-justify-end",
|
||||
data: { action: "reaction-delete#perform", reaction_delete_target: "button" } do %>
|
||||
<%= icon_tag "trash" %>
|
||||
<span class="for-screen-reader">Delete this reaction</span>
|
||||
<% end %>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 4: Create `_menu.html.erb`**
|
||||
|
||||
```erb
|
||||
<div class="reaction__menu" data-controller="dialog" data-action="keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside">
|
||||
<button class="reaction__menu-btn btn btn--circle borderless" data-action="click->dialog#open:stop" type="button">
|
||||
<%= icon_tag "reaction" %>
|
||||
</button>
|
||||
|
||||
<dialog class="reaction__popup popup panel fill-white shadow" data-dialog-target="dialog">
|
||||
<div class="reaction__emoji-list">
|
||||
<% EmojiHelper::REACTIONS.each do |character, title| %>
|
||||
<%= tag.button character, title: title, class: "reaction__emoji-btn btn btn--circle borderless hide-focus-ring", type: "button", data: { action: "reaction-emoji#insertEmoji dialog#close", emoji: character } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 5: Create `index.html.erb`**
|
||||
|
||||
```erb
|
||||
<%= render "cards/reactions/reactions", card: @card %>
|
||||
```
|
||||
|
||||
**Step 6: Create `new.html.erb`**
|
||||
|
||||
```erb
|
||||
<%= turbo_frame_tag @card, :new_reaction do %>
|
||||
<%= form_with url: card_reactions_path(@card),
|
||||
class: "reaction reaction__form expanded",
|
||||
html: { aria: { label: "New reaction" } },
|
||||
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@card, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %>
|
||||
<label class="reaction__form-label flex gap" style="--column-gap: 0.4ch;">
|
||||
<figure class="reaction__avatar margin-none flex-item-no-shrink">
|
||||
<%= avatar_tag Current.user %>
|
||||
</figure>
|
||||
|
||||
<%= form.text_field :content, autofocus: true, autocomplete: "off", autocorrect: "off", maxlength: 16,
|
||||
pattern: /\S+.*/, class: "input reaction__input txt-small", name: "reaction[content]", data: { form_target: "input", reaction_emoji_target: "input", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a reaction" } %>
|
||||
</label>
|
||||
|
||||
<%= render "cards/reactions/menu", card: @card %>
|
||||
|
||||
<%= form.button class: "reaction__submit-btn btn btn--circle borderless", type: "submit", data: { form_target: "submit" } do %>
|
||||
<%= icon_tag "check-circle" %> <span class="for-screen-reader">Submit</span>
|
||||
<% end %>
|
||||
|
||||
<%= link_to card_reactions_path(@card), role: "button",
|
||||
data: { turbo_frame: dom_id(@card, :reacting), form_target: "cancel" }, class: "reaction__cancel-btn btn btn--circle borderless" do %>
|
||||
<%= icon_tag "close-circle" %> <span class="for-screen-reader">Cancel</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
```
|
||||
|
||||
**Step 7: Create `create.turbo_stream.erb`**
|
||||
|
||||
```erb
|
||||
<%= turbo_stream.replace([ @card, :reacting ]) do %>
|
||||
<%= render "cards/reactions/reactions", card: @card.reload %>
|
||||
<% end %>
|
||||
```
|
||||
|
||||
**Step 8: Create `destroy.turbo_stream.erb`**
|
||||
|
||||
```erb
|
||||
<%= turbo_stream.remove @reaction %>
|
||||
```
|
||||
|
||||
**Step 9: Run controller tests**
|
||||
|
||||
Run: `bin/rails test test/controllers/cards/reactions_controller_test.rb`
|
||||
|
||||
Expected: All tests PASS
|
||||
|
||||
**Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add app/views/cards/reactions/
|
||||
git commit -m "Add card reactions views"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Add Reactions UI to Card Detail Footer
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/views/cards/container/footer/_published.html.erb`
|
||||
|
||||
**Step 1: Add reactions to footer**
|
||||
|
||||
Replace `app/views/cards/container/footer/_published.html.erb`:
|
||||
|
||||
```erb
|
||||
<%# FIXME: Let's move this aside outside of the card container section so these frames don't reload/flicker when card is replaced %>
|
||||
<div class="card-perma__actions card-perma__actions--left">
|
||||
<%= render "cards/reactions/reactions", card: card %>
|
||||
</div>
|
||||
|
||||
<div class="card-perma__actions card-perma__actions--right">
|
||||
<%= turbo_frame_tag card, :watch, src: card_watch_path(card), target: "_top", refresh: :morph do %>
|
||||
<%= button_to card_watch_path(card), class: "btn", data: { controller: "tooltip" } do %>
|
||||
<%= icon_tag "bell-off" %> <span class="for-screen-reader">Watch this</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= turbo_frame_tag card, :pin, src: card_pin_path(card), refresh: :morph do %>
|
||||
<%= button_to card_pin_path(card), class: "btn", data: { controller: "tooltip" } do %>
|
||||
<%= icon_tag "unpinned" %> <span class="for-screen-reader">Pin this card</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render "cards/container/closure", card: card %>
|
||||
```
|
||||
|
||||
**Step 2: Manually test in browser**
|
||||
|
||||
Run: `bin/dev`
|
||||
|
||||
Visit a card detail page and verify the reactions UI appears in the footer.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/views/cards/container/footer/_published.html.erb
|
||||
git commit -m "Add reactions UI to card detail footer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Add Boost Count to Card Preview
|
||||
|
||||
**Files:**
|
||||
- Create: `app/views/cards/display/preview/_reactions.html.erb`
|
||||
- Modify: `app/views/cards/display/_preview.html.erb`
|
||||
|
||||
**Step 1: Create the preview reactions partial**
|
||||
|
||||
Create `app/views/cards/display/preview/_reactions.html.erb`:
|
||||
|
||||
```erb
|
||||
<% if card.reactions.any? %>
|
||||
<div class="card__reactions align-center gap-half flex-item-justify-end flex-item-no-shrink">
|
||||
<%= image_tag "boost-color.svg", aria: { hidden: true }, class: "card__reactions-icon" %>
|
||||
<strong><%= card.reactions.size %></strong>
|
||||
</div>
|
||||
<% end %>
|
||||
```
|
||||
|
||||
**Step 2: Add to card preview footer**
|
||||
|
||||
In `app/views/cards/display/_preview.html.erb`, modify the footer section (lines 47-51) to add the reactions partial. Change from:
|
||||
|
||||
```erb
|
||||
<footer class="card__footer flex gap-half">
|
||||
<%= render "cards/display/preview/meta", card: card, preview: true %>
|
||||
<%= render "cards/display/preview/comments", card: card %>
|
||||
<%= render "cards/display/common/background", card: card %>
|
||||
</footer>
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```erb
|
||||
<footer class="card__footer flex gap-half">
|
||||
<%= render "cards/display/preview/meta", card: card, preview: true %>
|
||||
<%= render "cards/display/preview/reactions", card: card %>
|
||||
<%= render "cards/display/preview/comments", card: card %>
|
||||
<%= render "cards/display/common/background", card: card %>
|
||||
</footer>
|
||||
```
|
||||
|
||||
**Step 3: Manually test in browser**
|
||||
|
||||
Visit a board page, add a reaction to a card in detail view, then verify the count appears on the card preview.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/views/cards/display/preview/_reactions.html.erb app/views/cards/display/_preview.html.erb
|
||||
git commit -m "Add boost count to card preview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Add Fixtures for Card Reactions
|
||||
|
||||
**Files:**
|
||||
- Modify: `test/fixtures/reactions.yml`
|
||||
|
||||
**Step 1: Add card reaction fixtures**
|
||||
|
||||
Add to `test/fixtures/reactions.yml`:
|
||||
|
||||
```yaml
|
||||
card_reaction_david:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("card_reaction_david", :uuid) %>
|
||||
account: 37s_uuid
|
||||
content: "🚀"
|
||||
reactable_type: Card
|
||||
reactable_id: <%= ActiveRecord::FixtureSet.identify("logo", :uuid) %>
|
||||
reacter: david_uuid
|
||||
|
||||
card_reaction_kevin:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("card_reaction_kevin", :uuid) %>
|
||||
account: 37s_uuid
|
||||
content: "👍"
|
||||
reactable_type: Card
|
||||
reactable_id: <%= ActiveRecord::FixtureSet.identify("logo", :uuid) %>
|
||||
reacter: kevin_uuid
|
||||
```
|
||||
|
||||
Also update existing fixtures to include `reactable_type`:
|
||||
|
||||
```yaml
|
||||
kevin:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("kevin_reaction", :uuid) %>
|
||||
account: 37s_uuid
|
||||
content: "👍"
|
||||
reactable_type: Comment
|
||||
reactable_id: <%= ActiveRecord::FixtureSet.identify("logo_agreement_jz", :uuid) %>
|
||||
reacter: kevin_uuid
|
||||
|
||||
david:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("david_reaction", :uuid) %>
|
||||
account: 37s_uuid
|
||||
content: "👍"
|
||||
reactable_type: Comment
|
||||
reactable_id: <%= ActiveRecord::FixtureSet.identify("logo_agreement_jz", :uuid) %>
|
||||
reacter: david_uuid
|
||||
```
|
||||
|
||||
**Step 2: Run all tests**
|
||||
|
||||
Run: `bin/rails test`
|
||||
|
||||
Expected: All tests PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add test/fixtures/reactions.yml
|
||||
git commit -m "Add card reaction fixtures"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Run Full Test Suite
|
||||
|
||||
**Step 1: Run full CI suite**
|
||||
|
||||
Run: `bin/ci`
|
||||
|
||||
Expected: All checks PASS
|
||||
|
||||
**Step 2: Fix any issues**
|
||||
|
||||
If any tests fail, fix them and commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Style Card Reactions (if needed)
|
||||
|
||||
Work with the user to adjust styling for the card reactions in both the detail footer and preview. May need to add CSS for:
|
||||
|
||||
- `.card__reactions` - container on preview
|
||||
- `.card__reactions-icon` - boost icon sizing
|
||||
- Positioning of reactions in footer
|
||||
|
||||
This will be an iterative process based on visual feedback.
|
||||
@@ -0,0 +1,67 @@
|
||||
require "test_helper"
|
||||
|
||||
class Cards::ReactionsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in_as :david
|
||||
@card = cards(:logo)
|
||||
end
|
||||
|
||||
test "index" do
|
||||
get card_reactions_path(@card)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "new" do
|
||||
get new_card_reaction_path(@card)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "create" do
|
||||
assert_difference -> { @card.reactions.count }, 1 do
|
||||
post card_reactions_path(@card, format: :turbo_stream), params: { reaction: { content: "Great work!" } }
|
||||
assert_turbo_stream action: :replace, target: dom_id(@card, :reacting)
|
||||
end
|
||||
end
|
||||
|
||||
test "destroy" do
|
||||
reaction = reactions(:logo_card_david)
|
||||
assert_difference -> { @card.reactions.count }, -1 do
|
||||
delete card_reaction_path(@card, reaction, format: :turbo_stream)
|
||||
assert_turbo_stream action: :remove, target: dom_id(reaction)
|
||||
end
|
||||
end
|
||||
|
||||
test "non-owner cannot destroy reaction" do
|
||||
reaction = reactions(:logo_card_kevin)
|
||||
|
||||
assert_no_difference -> { @card.reactions.count } do
|
||||
delete card_reaction_path(@card, reaction, format: :turbo_stream)
|
||||
assert_response :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
test "index as JSON" do
|
||||
get card_reactions_path(@card), as: :json
|
||||
|
||||
assert_response :success
|
||||
assert_equal @card.reactions.count, @response.parsed_body.count
|
||||
end
|
||||
|
||||
test "create as JSON" do
|
||||
assert_difference -> { @card.reactions.count }, 1 do
|
||||
post card_reactions_path(@card), params: { reaction: { content: "👍" } }, as: :json
|
||||
end
|
||||
|
||||
assert_response :created
|
||||
end
|
||||
|
||||
test "destroy as JSON" do
|
||||
reaction = reactions(:logo_card_david)
|
||||
|
||||
assert_difference -> { @card.reactions.count }, -1 do
|
||||
delete card_reaction_path(@card, reaction), as: :json
|
||||
end
|
||||
|
||||
assert_response :no_content
|
||||
end
|
||||
end
|
||||
Vendored
+14
@@ -11,3 +11,17 @@ david:
|
||||
content: "👍"
|
||||
reactable: logo_agreement_jz_uuid (Comment)
|
||||
reacter: david_uuid
|
||||
|
||||
logo_card_kevin:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("logo_card_kevin_reaction", :uuid) %>
|
||||
account: 37s_uuid
|
||||
content: "🎯"
|
||||
reactable: logo_uuid (Card)
|
||||
reacter: kevin_uuid
|
||||
|
||||
logo_card_david:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_reaction", :uuid) %>
|
||||
account: 37s_uuid
|
||||
content: "👍"
|
||||
reactable: logo_uuid (Card)
|
||||
reacter: david_uuid
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
require "test_helper"
|
||||
|
||||
class CardPreviewBoostCountTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in_as :kevin
|
||||
@card_with_reactions = cards(:logo)
|
||||
@card_without_reactions = cards(:layout)
|
||||
@column = @card_with_reactions.column
|
||||
end
|
||||
|
||||
test "card preview displays boost count when card has reactions" do
|
||||
get board_column_path(@column.board, @column)
|
||||
assert_response :success
|
||||
|
||||
# Check that boost count is displayed for cards with reactions
|
||||
assert_select ".card__boosts", text: /2/
|
||||
end
|
||||
|
||||
test "card preview does not display boost count when card has no reactions" do
|
||||
# Ensure layout card is in the same column for this test
|
||||
@card_without_reactions.update!(column: @column)
|
||||
|
||||
get board_column_path(@column.board, @column)
|
||||
assert_response :success
|
||||
|
||||
# Find the card without reactions and verify no boost count is shown
|
||||
# We check the overall page doesn't have a boost count for zero reactions
|
||||
# (This is an imperfect test but reasonable given the structure)
|
||||
assert @card_without_reactions.reactions.none?
|
||||
end
|
||||
end
|
||||
@@ -199,4 +199,18 @@ class CardTest < ActiveSupport::TestCase
|
||||
assert card.watched_by?(kevin), "Kevin's watch should remain (has board access)"
|
||||
assert_not card.watched_by?(david), "David's watch should be deleted (no board access)"
|
||||
end
|
||||
|
||||
test "card has reactions association" do
|
||||
card = cards(:logo)
|
||||
user = users(:david)
|
||||
|
||||
assert_difference "card.reactions.count", +1 do
|
||||
card.reactions.create!(content: "👍", reacter: user)
|
||||
end
|
||||
|
||||
reaction = card.reactions.last
|
||||
assert_equal "👍", reaction.content
|
||||
assert_equal user, reaction.reacter
|
||||
assert_equal card, reaction.reactable
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,9 +5,40 @@ class ReactionTest < ActiveSupport::TestCase
|
||||
Current.session = sessions(:david)
|
||||
end
|
||||
|
||||
test "creating a reaction touches the card activity" do
|
||||
test "creating a comment reaction touches the card activity" do
|
||||
assert_changes -> { cards(:logo).reload.last_active_at } do
|
||||
comments(:logo_1).reactions.create!(content: "Nice!")
|
||||
end
|
||||
end
|
||||
|
||||
test "reactions are deleted when comment is destroyed" do
|
||||
comment = comments(:logo_1)
|
||||
comment.reactions.create!(content: "👍")
|
||||
reaction_ids = comment.reactions.pluck(:id)
|
||||
|
||||
assert reaction_ids.any?, "Expected comment to have reactions"
|
||||
|
||||
comment.destroy
|
||||
|
||||
assert_empty Reaction.where(id: reaction_ids)
|
||||
end
|
||||
|
||||
test "creating a card reaction touches the card activity" do
|
||||
card = cards(:logo)
|
||||
|
||||
assert_changes -> { card.reload.last_active_at } do
|
||||
card.reactions.create!(content: "🎉")
|
||||
end
|
||||
end
|
||||
|
||||
test "reactions are deleted when card is destroyed" do
|
||||
card = cards(:logo)
|
||||
reaction_ids = card.reactions.pluck(:id)
|
||||
|
||||
assert reaction_ids.any?, "Expected card to have reactions"
|
||||
|
||||
card.destroy
|
||||
|
||||
assert_empty Reaction.where(id: reaction_ids)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user