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:
Mike Dalessio
2026-01-23 13:25:55 -05:00
committed by GitHub
parent ffb2a0b82a
commit cb61b36715
41 changed files with 1177 additions and 94 deletions
+29 -6
View File
@@ -536,16 +536,33 @@
/* Set lower limit for font size */ /* Set lower limit for font size */
font-size: clamp(0.6rem, 0.85cqi, 100px); font-size: clamp(0.6rem, 0.85cqi, 100px);
.card__comments { .card__counts {
--column-gap: 0.8ch; --gap: 0.5ch;
align-items: flex-end;
display: flex; display: flex;
margin-block-end: calc(var(--block-space) * -1.7); flex-shrink: 0;
margin-inline-end: calc(var(--card-padding-inline) * -0.5); gap: calc(2 * var(--gap));
margin-inline: auto calc(var(--card-padding-inline) * -0.5);
padding-inline-start: var(--gap);
}
.icon { .card__boosts,
--icon-size: 1.6em; .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); color: var(--card-color);
} }
} }
@@ -584,11 +601,16 @@
.local-time-value { .local-time-value {
font-weight: inherit; font-weight: inherit;
} }
@media (max-width: 639px) {
inline-size: auto;
}
} }
&:has(.card__background img:not([src=""])) { &:has(.card__background img:not([src=""])) {
.card__content, .card__content,
.card__meta, .card__meta,
.card__boosts,
.card__comments, .card__comments,
.card__column-name:not(.card__column-name--current) { .card__column-name:not(.card__column-name--current) {
opacity: 0; opacity: 0;
@@ -599,6 +621,7 @@
&:hover { &:hover {
.card__content, .card__content,
.card__footer, .card__footer,
.card__boosts,
.card__comments, .card__comments,
.card__column-name:not(.card__column-name--current) { .card__column-name:not(.card__column-name--current) {
opacity: 1; opacity: 1;
+89 -17
View File
@@ -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 {
--card-aspect-ratio: 2 / 0.95; --card-aspect-ratio: 2 / 0.95;
--lexxy-bg-color: var(--card-bg-color); --lexxy-bg-color: var(--card-bg-color);
@@ -47,6 +64,8 @@
} }
.card__body { .card__body {
position: relative;
@media (max-width: 639px) { @media (max-width: 639px) {
flex-direction: column; flex-direction: column;
padding-block-end: calc(var(--card-padding-block) * 1.5); 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 { .card__stages {
max-inline-size: 32ch; 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 {
--bubble-number-max: 42px; --bubble-number-max: 42px;
--bubble-size: 6rem; --bubble-size: 6rem;
@@ -132,23 +221,6 @@
inset: calc(var(--bubble-size) / 1.5) 0 auto auto; 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 /* Child items
+4 -2
View File
@@ -252,9 +252,10 @@
padding-block: var(--block-space-half); padding-block: var(--block-space-half);
} }
/* Meta /* Footer
/* ------------------------------------------------------------------------ */ /* ------------------------------------------------------------------------ */
/* Card metadata */
.card__meta { .card__meta {
--meta-spacer-block: 0.5ch; --meta-spacer-block: 0.5ch;
--meta-spacer-inline: 0.75ch; --meta-spacer-inline: 0.75ch;
@@ -376,11 +377,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-weight: bold; font-weight: bold;
inset: auto 1ch 1ch auto; inset: auto 0 -1lh auto;
justify-content: center; justify-content: center;
max-inline-size: 25ch; max-inline-size: 25ch;
min-inline-size: 16ch; min-inline-size: 16ch;
padding: 1ch; padding: 1ch;
pointer-events: none;
position: absolute; position: absolute;
rotate: 5deg; rotate: 5deg;
transform-origin: top right; transform-origin: top right;
+5
View File
@@ -80,6 +80,11 @@
display: none !important; display: none !important;
} }
} }
.reactions {
margin-block-start: var(--block-space-half);
margin-inline: calc(var(--column-gap) / -1);
}
} }
.comment__author { .comment__author {
+1 -6
View File
@@ -10,8 +10,6 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--inline-space-half); gap: var(--inline-space-half);
inline-size: 100%; inline-size: 100%;
margin-block-start: var(--block-space-half);
margin-inline: calc(var(--column-gap) / -1);
&:has([open]) { &:has([open]) {
z-index: var(--z-popup); z-index: var(--z-popup);
@@ -34,11 +32,8 @@
.reactions__trigger { .reactions__trigger {
--btn-border-color: var(--color-ink-lightest); --btn-border-color: var(--color-ink-lightest);
background-color: var(--color-ink-lightest);
&:not(:hover) { background-color: var(--color-ink-lightest);
opacity: 0.66;
}
} }
} }
} }
+1
View File
@@ -440,6 +440,7 @@
.card__meta-text:not(.card__meta-text--updated), .card__meta-text:not(.card__meta-text--updated),
.card__stages, .card__stages,
.card__steps, .card__steps,
.card__boosts,
.card__comments, .card__comments,
.card__closed { .card__closed {
display: none; display: none;
@@ -2,20 +2,26 @@ class Cards::Comments::ReactionsController < ApplicationController
include CardScoped include CardScoped
before_action :set_comment before_action :set_comment
before_action :set_reaction, only: %i[ destroy ] before_action :set_reactable
before_action :ensure_permision_to_administer_reaction, only: %i[ destroy ]
with_options only: :destroy do
before_action :set_reaction
before_action :ensure_permission_to_administer_reaction
end
def index def index
render "reactions/index"
end end
def new def new
render "reactions/new"
end end
def create def create
@reaction = @comment.reactions.create!(params.expect(reaction: :content)) @reaction = @reactable.reactions.create!(params.expect(reaction: :content))
respond_to do |format| respond_to do |format|
format.turbo_stream format.turbo_stream { render "reactions/create" }
format.json { head :created } format.json { head :created }
end end
end end
@@ -24,7 +30,7 @@ class Cards::Comments::ReactionsController < ApplicationController
@reaction.destroy @reaction.destroy
respond_to do |format| respond_to do |format|
format.turbo_stream format.turbo_stream { render "reactions/destroy" }
format.json { head :no_content } format.json { head :no_content }
end end
end end
@@ -34,11 +40,15 @@ class Cards::Comments::ReactionsController < ApplicationController
@comment = @card.comments.find(params[:comment_id]) @comment = @card.comments.find(params[:comment_id])
end end
def set_reaction def set_reactable
@reaction = @comment.reactions.find(params[:id]) @reactable = @comment
end 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 head :forbidden if Current.user != @reaction.reacter
end end
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
+10
View File
@@ -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
View File
@@ -7,6 +7,7 @@ class Card < ApplicationRecord
belongs_to :board belongs_to :board
belongs_to :creator, class_name: "User", default: -> { Current.user } 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_one_attached :image, dependent: :purge_later
has_rich_text :description has_rich_text :description
@@ -22,7 +23,7 @@ class Card < ApplicationRecord
scope :chronologically, -> { order created_at: :asc, id: :asc } scope :chronologically, -> { order created_at: :asc, id: :asc }
scope :latest, -> { order last_active_at: :desc, id: :desc } scope :latest, -> { order last_active_at: :desc, id: :desc }
scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) } 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 scope :indexed_by, ->(index) do
case index case index
+4 -1
View File
@@ -21,11 +21,14 @@
<% if card.published? %> <% if card.published? %>
<%= render "cards/triage/columns", card: card %> <%= render "cards/triage/columns", card: card %>
<% end %> <% end %>
<%= render "cards/display/common/stamp", card: card %>
</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/meta", card: card %>
<%= render "cards/display/perma/background", card: card %> <%= render "cards/display/perma/background", card: card %>
<%= render "reactions/reactions", reactable: card %>
</footer> </footer>
<% end %> <% end %>
+1 -1
View File
@@ -36,7 +36,7 @@
<%= comment.body %> <%= comment.body %>
</div> </div>
<%= render "cards/comments/reactions/reactions", comment: comment %> <%= render "reactions/reactions", reactable: comment %>
</div> </div>
</div> </div>
<% end %> <% 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
+5 -1
View File
@@ -41,12 +41,16 @@
</div> </div>
<%= render "cards/display/preview/columns", card: card %> <%= render "cards/display/preview/columns", card: card %>
<%= render "cards/display/common/stamp", card: card %>
</div> </div>
</div> </div>
<footer class="card__footer flex gap-half"> <footer class="card__footer flex gap-half">
<%= render "cards/display/preview/meta", card: card, preview: true %> <%= 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 %> <%= render "cards/display/common/background", card: card %>
</footer> </footer>
@@ -22,6 +22,7 @@
</div> </div>
<%= render "cards/display/public_preview/columns", card: card if card.triaged? %> <%= render "cards/display/public_preview/columns", card: card if card.triaged? %>
<%= render "cards/display/common/stamp", card: card %>
</div> </div>
</div> </div>
@@ -5,19 +5,3 @@
<%= yield %> <%= yield %>
<% end %> <% 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 %> <%= 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" %> <%= icon_tag "picture-zoom" %>
<span class="for-screen-reader">Zoom background image</span> <span class="for-screen-reader">Zoom background image</span>
<% end %> <% 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 %> <% comments = card.comments.by_user %>
<% if comments.any? %> <% 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" %> <%= icon_tag "comment" %>
<strong><%= comments.count %></strong> <span><%= comments.count %></span>
</div> </div>
<% end %> <% end %>
+1 -1
View File
@@ -18,7 +18,7 @@
</div> </div>
</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/meta", card: card %>
<%= render "cards/display/perma/background", card: card %> <%= render "cards/display/perma/background", card: card %>
</footer> </footer>
+2 -1
View File
@@ -30,11 +30,12 @@
<div class="card__body justify-space-between"> <div class="card__body justify-space-between">
<%= render "public/cards/show/content", card: @card %> <%= render "public/cards/show/content", card: @card %>
<%= render "cards/display/public_preview/columns", card: @card if @card.open? %> <%= render "cards/display/public_preview/columns", card: @card if @card.open? %>
<%= render "cards/display/common/stamp", card: @card %>
</div> </div>
<%= render "public/cards/show/steps", card: @card %> <%= 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/public_preview/meta", card: @card %>
<%= render "cards/display/perma/background", card: @card %> <%= render "cards/display/perma/background", card: @card %>
</footer> </footer>
@@ -15,7 +15,7 @@
class: [ "txt-small", { "txt-medium": reaction.all_emoji? } ], class: [ "txt-small", { "txt-medium": reaction.all_emoji? } ],
data: { action: "click->reaction-delete#reveal keydown.enter->reaction-delete#reveal:prevent", reaction_delete_target: "content" } %> 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, method: :delete,
class: "reaction__delete btn btn--negative flex-item-justify-end", class: "reaction__delete btn btn--negative flex-item-justify-end",
data: { action: "reaction-delete#perform", reaction_delete_target: "button" } do %> data: { action: "reaction-delete#perform", reaction_delete_target: "button" } do %>
@@ -1,5 +1,5 @@
json.cache! reaction do json.cache! reaction do
json.(reaction, :id, :content) json.(reaction, :id, :content)
json.reacter reaction.reacter, partial: "users/user", as: :user 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 end
+16
View File
@@ -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 %>
+1
View File
@@ -0,0 +1 @@
<%= render "reactions/reactions", reactable: @reactable %>
+1
View File
@@ -0,0 +1 @@
json.array! @reactable.reactions.ordered, partial: "reactions/reaction", as: :reaction
@@ -1,8 +1,8 @@
<%= turbo_frame_tag @comment, :new_reaction do %> <%= turbo_frame_tag @reactable, :new_reaction do %>
<%= form_with model: [ @comment.card, @comment, Reaction.new ], <%= form_with model: [ *reaction_path_prefix_for(@reactable), Reaction.new ],
class: "reaction reaction__form expanded", class: "reaction reaction__form expanded",
html: { aria: { label: "New reaction" } }, 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;"> <label class="reaction__form-label flex gap" style="--column-gap: 0.4ch;">
<figure class="reaction__avatar margin-none flex-item-no-shrink"> <figure class="reaction__avatar margin-none flex-item-no-shrink">
<%= avatar_tag Current.user %> <%= 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" } %> 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> </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 %> <%= 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> <%= icon_tag "check-circle" %> <span class="for-screen-reader">Submit</span>
<% end %> <% end %>
<%= link_to card_comment_reactions_path(@comment.card, @comment), role: "button", <%= link_to polymorphic_path([ *reaction_path_prefix_for(@reactable), :reactions ]), role: "button",
data: { turbo_frame: dom_id(@comment, :reacting), form_target: "cancel" }, class: "reaction__cancel-btn btn btn--circle borderless" do %> 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> <%= icon_tag "close-circle" %> <span class="for-screen-reader">Cancel</span>
<% end %> <% end %>
<% end %> <% end %>
+2
View File
@@ -86,6 +86,8 @@ Rails.application.routes.draw do
resource :watch resource :watch
resource :reading resource :reading
resources :reactions
resources :assignments resources :assignments
resource :self_assignment, only: :create resource :self_assignment, only: :create
resources :steps resources :steps
+741
View File
@@ -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
+14
View File
@@ -11,3 +11,17 @@ david:
content: "👍" content: "👍"
reactable: logo_agreement_jz_uuid (Comment) reactable: logo_agreement_jz_uuid (Comment)
reacter: david_uuid 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
+14
View File
@@ -199,4 +199,18 @@ class CardTest < ActiveSupport::TestCase
assert card.watched_by?(kevin), "Kevin's watch should remain (has board access)" 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)" assert_not card.watched_by?(david), "David's watch should be deleted (no board access)"
end 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 end
+32 -1
View File
@@ -5,9 +5,40 @@ class ReactionTest < ActiveSupport::TestCase
Current.session = sessions(:david) Current.session = sessions(:david)
end 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 assert_changes -> { cards(:logo).reload.last_active_at } do
comments(:logo_1).reactions.create!(content: "Nice!") comments(:logo_1).reactions.create!(content: "Nice!")
end end
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 end