cb61b36715
* 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>
217 lines
6.7 KiB
Ruby
217 lines
6.7 KiB
Ruby
require "test_helper"
|
|
|
|
class CardTest < ActiveSupport::TestCase
|
|
setup do
|
|
Current.session = sessions(:david)
|
|
end
|
|
|
|
test "create assigns a number to the card" do
|
|
user = users(:david)
|
|
board = boards(:writebook)
|
|
account = board.account
|
|
card = nil
|
|
|
|
assert_difference -> { account.reload.cards_count }, +1 do
|
|
card = Card.create!(title: "Test", board: board, creator: user)
|
|
end
|
|
|
|
assert_equal account.reload.cards_count, card.number
|
|
end
|
|
|
|
test "assignment states" do
|
|
assert cards(:logo).assigned_to?(users(:kevin))
|
|
assert_not cards(:logo).assigned_to?(users(:david))
|
|
end
|
|
|
|
test "assignment toggling" do
|
|
assert cards(:logo).assigned_to?(users(:kevin))
|
|
|
|
assert_difference({ -> { cards(:logo).assignees.count } => -1, -> { Event.count } => +1 }) do
|
|
cards(:logo).toggle_assignment users(:kevin)
|
|
end
|
|
assert_not cards(:logo).reload.assigned_to?(users(:kevin))
|
|
unassign_event = Event.last
|
|
assert_equal "card_unassigned", unassign_event.action
|
|
assert_equal [ users(:kevin) ], unassign_event.assignees
|
|
|
|
assert_difference %w[ cards(:logo).assignees.count Event.count ], +1 do
|
|
cards(:logo).toggle_assignment users(:kevin)
|
|
end
|
|
assert cards(:logo).assigned_to?(users(:kevin))
|
|
assign_event = Event.last
|
|
assert_equal "card_assigned", assign_event.action
|
|
assert_equal [ users(:kevin) ], assign_event.assignees
|
|
end
|
|
|
|
test "tagged states" do
|
|
assert cards(:logo).tagged_with?(tags(:web))
|
|
assert_not cards(:logo).tagged_with?(tags(:mobile))
|
|
end
|
|
|
|
test "tag toggling" do
|
|
assert cards(:logo).tagged_with?(tags(:web))
|
|
|
|
assert_difference "cards(:logo).taggings.count", -1 do
|
|
cards(:logo).toggle_tag_with tags(:web).title
|
|
end
|
|
assert_not cards(:logo).tagged_with?(tags(:web))
|
|
|
|
assert_difference "cards(:logo).taggings.count", +1 do
|
|
cards(:logo).toggle_tag_with tags(:web).title
|
|
end
|
|
assert cards(:logo).tagged_with?(tags(:web))
|
|
|
|
assert_difference %w[ cards(:logo).taggings.count Tag.count ], +1 do
|
|
cards(:logo).toggle_tag_with "prioritized"
|
|
end
|
|
assert_equal "prioritized", cards(:logo).taggings.last.tag.title
|
|
end
|
|
|
|
test "closed" do
|
|
assert_equal [ cards(:shipping) ], Card.closed
|
|
end
|
|
|
|
test "open" do
|
|
assert_equal cards(:logo, :layout, :text, :buy_domain).to_set, accounts("37s").cards.open.to_set
|
|
assert_equal cards(:radio, :paycheck, :unfinished_thoughts).to_set, accounts("initech").cards.open.to_set
|
|
end
|
|
|
|
test "card_unassigned" do
|
|
assert_equal cards(:shipping, :text, :buy_domain).to_set, accounts("37s").cards.unassigned.to_set
|
|
end
|
|
|
|
test "assigned to" do
|
|
assert_equal cards(:logo, :layout).to_set, Card.assigned_to(users(:jz)).to_set
|
|
end
|
|
|
|
test "assigned by" do
|
|
assert_equal cards(:layout, :logo).to_set, Card.assigned_by(users(:david)).to_set
|
|
end
|
|
|
|
test "in board" do
|
|
new_board = Board.create! name: "New Board", creator: users(:david)
|
|
assert_equal cards(:logo, :shipping, :layout, :text, :buy_domain).to_set, Card.where(board: boards(:writebook)).to_set
|
|
assert_empty Card.where(board: new_board)
|
|
end
|
|
|
|
test "tagged with" do
|
|
assert_equal cards(:layout, :text), Card.tagged_with(tags(:mobile))
|
|
end
|
|
|
|
test "for published cards, it should set the default title 'Untitiled' when not provided" do
|
|
card = boards(:writebook).cards.create!
|
|
assert_nil card.title
|
|
|
|
card.publish
|
|
assert_equal "Untitled", card.reload.title
|
|
end
|
|
|
|
test "send back to triage when moved to a new board" do
|
|
cards(:logo).update! column: columns(:writebook_in_progress)
|
|
|
|
assert_changes -> { cards(:logo).reload.triaged? }, from: true, to: false do
|
|
cards(:logo).update! board: boards(:private)
|
|
end
|
|
end
|
|
|
|
test "grants access to assignees when moved to a new board" do
|
|
card = cards(:logo)
|
|
assignee = users(:david)
|
|
card.toggle_assignment(assignee)
|
|
|
|
board = boards(:private)
|
|
assert_not_includes board.users, assignee
|
|
|
|
card.update!(board: board)
|
|
assert_includes board.users.reload, assignee
|
|
end
|
|
|
|
test "move cards to a different board" do
|
|
card = cards(:logo)
|
|
old_board = boards(:writebook)
|
|
new_board = boards(:private)
|
|
|
|
assert_equal old_board, card.board
|
|
|
|
assert card.events.where(board: old_board).exists?
|
|
|
|
card.move_to(new_board)
|
|
|
|
assert_equal new_board, card.reload.board
|
|
|
|
events_in_old_board = card.events.where(board: old_board)
|
|
events_in_new_board = card.events.where(board: new_board)
|
|
|
|
assert_empty events_in_old_board
|
|
assert events_in_new_board.exists?
|
|
|
|
board_changed_event = events_in_new_board.find { |event| event.action == "card_board_changed" }
|
|
assert board_changed_event
|
|
end
|
|
|
|
test "a card is filled if it has either the title or the description set" do
|
|
assert Card.new(title: "Some title").filled?
|
|
assert Card.new(description: "Some description").filled?
|
|
|
|
assert_not Card.new.filled?
|
|
end
|
|
|
|
test "pins are deleted when card moves to a board user cannot access" do
|
|
card = cards(:logo)
|
|
kevin = users(:kevin)
|
|
david = users(:david)
|
|
|
|
# David pins the card (Kevin already has it pinned via fixture)
|
|
card.pin_by(david)
|
|
|
|
assert card.pinned_by?(kevin)
|
|
assert card.pinned_by?(david)
|
|
|
|
# Kevin has access to the private board, David does not
|
|
assert boards(:private).accessible_to?(kevin)
|
|
assert_not boards(:private).accessible_to?(david)
|
|
|
|
perform_enqueued_jobs only: Card::CleanInaccessibleDataJob do
|
|
card.move_to(boards(:private))
|
|
end
|
|
|
|
assert card.pinned_by?(kevin), "Kevin's pin should remain (has board access)"
|
|
assert_not card.pinned_by?(david), "David's pin should be deleted (no board access)"
|
|
end
|
|
|
|
test "watches are deleted when card moves to a board user cannot access" do
|
|
card = cards(:logo)
|
|
kevin = users(:kevin)
|
|
david = users(:david)
|
|
|
|
# Both watch the card via fixtures
|
|
assert card.watched_by?(kevin)
|
|
assert card.watched_by?(david)
|
|
|
|
# Kevin has access to the private board, David does not
|
|
assert boards(:private).accessible_to?(kevin)
|
|
assert_not boards(:private).accessible_to?(david)
|
|
|
|
perform_enqueued_jobs only: Card::CleanInaccessibleDataJob do
|
|
card.move_to(boards(:private))
|
|
end
|
|
|
|
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
|