Skip to content
Draft
24 changes: 16 additions & 8 deletions app/controllers/cards_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class CardsController < ApplicationController
wrap_parameters :card, include: %i[ title description image created_at last_active_at ]
wrap_parameters :card, include: %i[ title description image created_at last_active_at tag_ids ]

include FilterScoped

Expand All @@ -20,8 +20,13 @@ def create
end

format.json do
@card = @board.cards.create! card_params.merge(creator: Current.user, status: "published")
render :show, status: :created, location: card_path(@card, format: :json)
@card = @board.cards.new card_params.merge(creator: Current.user, status: "published")

if @card.save
render :show, status: :created, location: card_path(@card, format: :json)
else
render json: @card.errors, status: :unprocessable_entity
end
end
end
end
Expand All @@ -33,11 +38,14 @@ def edit
end

def update
@card.update! card_params

respond_to do |format|
format.turbo_stream
format.json { render :show }
if @card.update(card_params)
format.turbo_stream
format.json { render :show }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @card.errors, status: :unprocessable_entity }
end
end
end

Expand Down Expand Up @@ -68,6 +76,6 @@ def ensure_permission_to_administer_card
end

def card_params
params.expect(card: [ :title, :description, :image, :created_at, :last_active_at ])
params.expect(card: [ :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ])
end
end
9 changes: 9 additions & 0 deletions app/models/card/taggable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module Card::Taggable
has_many :tags, through: :taggings

scope :tagged_with, ->(tags) { joins(:taggings).where(taggings: { tag: tags }) }

validate :tags_belong_to_account
end

def toggle_tag_with(title)
Expand All @@ -23,4 +25,11 @@ def toggle_tag_with(title)
def tagged_with?(tag)
tags.include? tag
end

private
def tags_belong_to_account
if tags.any? { it.account_id != account_id }
errors.add(:tags, "must belong to the card account")
end
end
end
28 changes: 28 additions & 0 deletions test/controllers/api/flat_json_params_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ class FlatJsonParamsTest < ActionDispatch::IntegrationTest
assert_equal "Flat description", card.description.to_plain_text
end

test "create card with flat JSON and tag_ids" do
tag = tags(:mobile)

assert_difference -> { Card.count }, +1 do
post board_cards_path(boards(:writebook)),
params: { title: "Flat tagged card", tag_ids: [ tag.id ] },
as: :json
end

assert_response :created
card = Card.last
assert_equal [ tag ], card.reload.tags
assert_equal [ tag.title ], @response.parsed_body["tags"]
end

test "update card with flat JSON" do
card = cards(:logo)

Expand All @@ -88,6 +103,19 @@ class FlatJsonParamsTest < ActionDispatch::IntegrationTest
assert_equal "Updated flat", card.description.to_plain_text
end

test "update card with flat JSON and tag_ids" do
card = cards(:logo)
tag = tags(:mobile)

put card_path(card),
params: { tag_ids: [ tag.id ] },
as: :json

assert_response :success
assert_equal [ tag ], card.reload.tags
assert_equal [ tag.title ], @response.parsed_body["tags"]
end

test "create board with flat JSON" do
assert_difference -> { Board.count }, +1 do
post boards_path, params: { name: "Flat board" }, as: :json
Expand Down
111 changes: 111 additions & 0 deletions test/controllers/cards_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
assert_no_match "reactions", response.body, "Draft card should not show reactions/boost button"
end

test "update as HTML with invalid tag_ids renders edit with unprocessable entity" do
card = cards(:logo)
foreign_tag = accounts(:initech).tags.create!(title: "foreign")

patch card_path(card), params: { card: { tag_ids: [ foreign_tag.id ] } }

assert_response :unprocessable_entity
assert_equal [ tags(:web) ], card.reload.tags
assert_match "Close editor and discard changes", response.body
end

test "users can only see cards in boards they have access to" do
get card_path(cards(:logo))
assert_response :success
Expand Down Expand Up @@ -207,6 +218,43 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
assert_equal "Big if true", card.description.to_plain_text
end

test "create as JSON with tag_ids applies tags to the created card" do
tag = tags(:mobile)

assert_difference -> { Card.count }, +1 do
post board_cards_path(boards(:writebook)),
params: { card: { title: "Tagged card", tag_ids: [ tag.id ] } },
as: :json
assert_response :created
end

card = Card.last
assert_equal [ tag ], card.reload.tags
assert_equal [ tag.title ], @response.parsed_body["tags"]
end

test "create as JSON with nonexistent tag_ids returns not found" do
assert_no_difference -> { Card.count } do
post board_cards_path(boards(:writebook)),
params: { card: { title: "Tagged card", tag_ids: [ "does-not-exist" ] } },
as: :json
end

assert_response :not_found
end

test "create as JSON with foreign-account tag_ids returns unprocessable entity" do
foreign_tag = accounts(:initech).tags.create!(title: "foreign")

assert_no_difference -> { Card.count } do
post board_cards_path(boards(:writebook)),
params: { card: { title: "Tagged card", tag_ids: [ foreign_tag.id ] } },
as: :json
end

assert_response :unprocessable_entity
end

test "create as JSON with custom created_at" do
custom_time = Time.utc(2024, 1, 15, 10, 30, 0)

Expand Down Expand Up @@ -293,6 +341,69 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
assert_equal "Update test", card.reload.title
end

test "update as JSON with tag_ids updates tags on the card" do
card = cards(:logo)
tag = tags(:mobile)

put card_path(card, format: :json), params: { card: { tag_ids: [ tag.id ] } }
assert_response :success

assert_equal [ tag ], card.reload.tags
assert_equal [ tag.title ], @response.parsed_body["tags"]
end

test "update as JSON without tag_ids preserves existing tags" do
Comment thread
robzolkos marked this conversation as resolved.
card = cards(:logo)

put card_path(card, format: :json), params: { card: { title: "Updated title" } }
assert_response :success

assert_equal [ tags(:web) ], card.reload.tags
assert_equal [ tags(:web).title ], @response.parsed_body["tags"]
end

test "update as JSON with empty tag_ids clears existing tags" do
card = cards(:logo)
assert_equal [ tags(:web) ], card.tags

put card_path(card, format: :json), params: { card: { tag_ids: [] } }
assert_response :success

assert_empty card.reload.tags
assert_empty @response.parsed_body["tags"]
end

test "update as JSON with foreign-account tag_ids returns unprocessable entity" do
card = cards(:logo)
foreign_tag = accounts(:initech).tags.create!(title: "foreign")

put card_path(card, format: :json), params: { card: { tag_ids: [ foreign_tag.id ] } }

assert_response :unprocessable_entity
assert_equal [ tags(:web) ], card.reload.tags
end

test "update as JSON with description and tag_ids busts the card cache key" do
card = cards(:logo)
original_cache_key = card.cache_key_with_version
tag = tags(:mobile)

travel 1.minute do
put card_path(card, format: :json), params: {
card: {
description: "Updated description",
tag_ids: [ tag.id ]
}
}
end

assert_response :success
assert_not_equal original_cache_key, card.reload.cache_key_with_version
assert_equal "Updated description", card.description.to_plain_text.strip
assert_equal [ tag ], card.tags
assert_equal [ tag.title ], @response.parsed_body["tags"]
end

test "delete as JSON" do
card = cards(:logo)

Expand Down
36 changes: 36 additions & 0 deletions test/models/card/taggable_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,40 @@ class Card::TaggableTest < ActiveSupport::TestCase

assert_not_equal cards(:logo).tags.last, cards(:paycheck).tags.last
end

test "updating just tag_ids touches the card and board" do
board = @card.board

travel 1.minute do
assert_changes -> { @card.reload.updated_at } do
assert_changes -> { board.reload.updated_at } do
@card.update!(tag_ids: [ tags(:web).id, tags(:mobile).id ])
end
end
end
end

test "updating tag_ids with an empty array clears tags" do
assert_equal [ tags(:web) ], @card.tags

@card.update!(tag_ids: [])

assert_empty @card.reload.tags
end

test "updating tag_ids raises when a tag does not exist" do
assert_raises(ActiveRecord::RecordNotFound) do
@card.update!(tag_ids: [ "does-not-exist" ])
end
end

test "updating tag_ids is invalid when the tag belongs to another account" do
foreign_tag = accounts(:initech).tags.create!(title: "foreign")

error = assert_raises(ActiveRecord::RecordInvalid) do
@card.update!(tag_ids: [ foreign_tag.id ])
end

assert_includes error.record.errors[:tags], "must belong to the card account"
end
end