From 941aa68e69a17e98f331c6cda894a8fe1c556b49 Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 30 Apr 2026 15:38:00 +0300 Subject: [PATCH 01/18] Add move to list option in card --- app/controllers/cards_controller.rb | 19 +++++++++++++++++++ app/views/cards/_actions.html.erb | 14 ++++++++++++++ app/views/cards/show.html.erb | 2 +- config/routes.rb | 5 ++++- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 8b5a381d8b..9c6acf9e66 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -52,6 +52,25 @@ def update end end + def move_to_list + target_list = @board.lists.find(params[:new_list_id]) + + Card.transaction do + # Repair the source list chain before changing list_id, + # since next_card scopes to self.list + if (next_card = @card.next_card) + next_card.update_attribute(:previous_id, @card.previous_id) + end + + @card.list_id = target_list.id + @card.previous_id = target_list.last_card&.id + @card.save + end + + track_updated(@card) + redirect_to [current_project, @board, target_list, @card], notice: 'Task moved.' + end + def move List.move(@card, prev_item: @prev_item, next_item: @next_item) diff --git a/app/views/cards/_actions.html.erb b/app/views/cards/_actions.html.erb index fc4a033fd9..6213a7180c 100644 --- a/app/views/cards/_actions.html.erb +++ b/app/views/cards/_actions.html.erb @@ -19,6 +19,20 @@ <%= link_to(project_board_list_card_revisions_path(current_project, @board, @list, @card), class: 'dropdown-item') do %> View History <% end %> + <% other_lists = @board.ordered_lists.reject { |l| l.id == @list.id } %> + <% if other_lists.any? %> + + Move to list + <% other_lists.each do |list| %> + <%= link_to( + move_to_list_project_board_list_card_path(current_project, @board, @list, @card, new_list_id: list.id), + method: :post, + class: 'dropdown-item' + ) do %> + <%= list.name %> + <% end %> + <% end %> + <% end %> diff --git a/app/views/cards/show.html.erb b/app/views/cards/show.html.erb index 362db0a04c..0711a689ab 100644 --- a/app/views/cards/show.html.erb +++ b/app/views/cards/show.html.erb @@ -22,7 +22,7 @@
- <% cache ['card-information-tab', @card] do %> + <% cache ['card-information-tab', @card, @board.lists.maximum(:updated_at)] do %>

<%= @card.name %> diff --git a/config/routes.rb b/config/routes.rb index c7b3057e88..01591a6789 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -49,7 +49,10 @@ resources :lists, except: [:index] do member { post :move } resources :cards, except: [:index] do - member { post :move } + member do + post :move + post :move_to_list + end resources :revisions, only: [:index, :show] end end From abf51e42c2a4189c873648444c338fe95df6fe0e Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 30 Apr 2026 15:49:19 +0300 Subject: [PATCH 02/18] Add specs --- spec/requests/cards/move_to_list_spec.rb | 69 ++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 spec/requests/cards/move_to_list_spec.rb diff --git a/spec/requests/cards/move_to_list_spec.rb b/spec/requests/cards/move_to_list_spec.rb new file mode 100644 index 0000000000..3688b71191 --- /dev/null +++ b/spec/requests/cards/move_to_list_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +describe 'cards#move_to_list' do + before { login_to_project_as_user } + + let(:board) { create(:board, node: current_project.methodology_library, project: current_project) } + let(:source_list) { create(:list, board: board) } + let(:target_list) { create(:list, board: board, previous_id: source_list.id) } + + let(:card_a) { create(:card, list: source_list) } + let(:card_b) { create(:card, list: source_list, previous_id: card_a.id) } + let(:card_c) { create(:card, list: source_list, previous_id: card_b.id) } + + let(:last_card_in_target) { create(:card, list: target_list) } + + let(:submit) do + post move_to_list_project_board_list_card_path(current_project, board, source_list, card_b), + params: { new_list_id: target_list.id } + end + + before do + card_a + card_b + card_c + last_card_in_target + end + + it 'moves the card to the target list' do + submit + expect(card_b.reload.list).to eq(target_list) + end + + it 'appends the card to the end of the target list' do + submit + expect(card_b.reload.previous_id).to eq(last_card_in_target.id) + end + + it 'repairs the source list chain' do + submit + expect(card_c.reload.previous_id).to eq(card_a.id) + end + + it 'redirects to the card in its new list with a notice' do + submit + expect(response).to redirect_to(project_board_list_card_path(current_project, board, target_list, card_b)) + expect(flash[:notice]).to eq('Task moved.') + end + + it 'creates an activity' do + expect { submit }.to have_enqueued_job(ActivityTrackingJob).with( + action: 'update', + project_id: current_project.id, + trackable_id: card_b.id, + trackable_type: 'Card', + user_id: @logged_in_as.id + ) + end + + context 'when the target list is empty' do + let(:last_card_in_target) { nil } + + before { target_list } + + it 'makes the card the first item in the target list' do + submit + expect(card_b.reload.previous_id).to be_nil + end + end +end From d67902ab018d6af92df07292ea5b331d395f9fe0 Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 30 Apr 2026 15:51:03 +0300 Subject: [PATCH 03/18] Add CHANGELOG entry --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 3a805aa5f4..0810071a4e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ [v#.#.#] ([month] [YYYY]) - [entity]: - [future tense verb] [feature] + - Cards: add option to move a task to a different list - Mail: add support for SMTP configuration via environment variables for Docker deployments; smtp.yml remains supported for VM deployments during the deprecation transition - Upgraded gems: - [gem] From 7b8fab700126191f3e8656264df7ae1a8b51aecd Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 7 May 2026 15:06:44 +0300 Subject: [PATCH 04/18] Move inline with other actions --- app/assets/stylesheets/hera/modules.scss | 4 --- app/views/cards/_actions.html.erb | 31 +++++++++++++----------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/app/assets/stylesheets/hera/modules.scss b/app/assets/stylesheets/hera/modules.scss index bf45692858..c23cbfc2ab 100644 --- a/app/assets/stylesheets/hera/modules.scss +++ b/app/assets/stylesheets/hera/modules.scss @@ -198,10 +198,6 @@ margin-left: auto; .action { - &:not(:first-child)::before { - content: '-'; - } - &:first-child { margin-left: auto; } diff --git a/app/views/cards/_actions.html.erb b/app/views/cards/_actions.html.erb index 6213a7180c..6400a28c09 100644 --- a/app/views/cards/_actions.html.erb +++ b/app/views/cards/_actions.html.erb @@ -1,4 +1,21 @@
+ <% other_lists = @board.ordered_lists.reject { |l| l.id == @list.id } %> + <% if other_lists.any? %> + + <%= @list.name %> + + + <% end %> <%= link_to edit_project_board_list_card_path(current_project, @board, @list, @card) do %> Edit @@ -19,20 +36,6 @@ <%= link_to(project_board_list_card_revisions_path(current_project, @board, @list, @card), class: 'dropdown-item') do %> View History <% end %> - <% other_lists = @board.ordered_lists.reject { |l| l.id == @list.id } %> - <% if other_lists.any? %> - - Move to list - <% other_lists.each do |list| %> - <%= link_to( - move_to_list_project_board_list_card_path(current_project, @board, @list, @card, new_list_id: list.id), - method: :post, - class: 'dropdown-item' - ) do %> - <%= list.name %> - <% end %> - <% end %> - <% end %>

From 4b709e47309e84d6181e5fa0ac8d6ef8b41aab9f Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 7 May 2026 15:35:48 +0300 Subject: [PATCH 05/18] Address APR findings --- app/controllers/cards_controller.rb | 10 ++++-- app/models/card.rb | 11 ++++++ config/initializers/activity_service.rb | 1 + spec/requests/cards/move_to_list_spec.rb | 45 +++++++++++++++++++++++- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 9c6acf9e66..a851ecc6b5 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -1,6 +1,7 @@ class CardsController < AuthenticatedController include ActivityTracking include ContentFromTemplate + include EventPublisher include Mentioned include NotificationsReader include ProjectScoped @@ -55,6 +56,11 @@ def update def move_to_list target_list = @board.lists.find(params[:new_list_id]) + if target_list.id == @card.list_id + redirect_to [current_project, @board, @list, @card], alert: 'Task is already in that list.' + return + end + Card.transaction do # Repair the source list chain before changing list_id, # since next_card scopes to self.list @@ -64,10 +70,10 @@ def move_to_list @card.list_id = target_list.id @card.previous_id = target_list.last_card&.id - @card.save + @card.save! end - track_updated(@card) + publish_event('card.updated', @card.to_event_payload) redirect_to [current_project, @board, target_list, @card], notice: 'Task moved.' end diff --git a/app/models/card.rb b/app/models/card.rb index b0dd2ac557..b162935817 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,5 +1,6 @@ class Card < ApplicationRecord include Commentable + include Eventable include HasFields include RevisionTracking include Subscribable @@ -84,6 +85,16 @@ def to_xml(xml_builder, includes: [], version: 3) end end + def local_event_payload + { + project: { + id: project.id, + name: project.name + }, + title: name + } + end + def local_fields { 'List' => list.name.parameterize(preserve_case: true, separator: '_'), diff --git a/config/initializers/activity_service.rb b/config/initializers/activity_service.rb index 99a3f56bfe..383ef9b623 100644 --- a/config/initializers/activity_service.rb +++ b/config/initializers/activity_service.rb @@ -1,5 +1,6 @@ Rails.application.reloader.to_prepare do ActivityService.configure do |activity_service| + activity_service.subscribe_namespace 'card' activity_service.subscribe_namespace 'inline_thread' activity_service.subscribe_namespace 'issue' end diff --git a/spec/requests/cards/move_to_list_spec.rb b/spec/requests/cards/move_to_list_spec.rb index 3688b71191..08ed17b4bb 100644 --- a/spec/requests/cards/move_to_list_spec.rb +++ b/spec/requests/cards/move_to_list_spec.rb @@ -48,7 +48,7 @@ it 'creates an activity' do expect { submit }.to have_enqueued_job(ActivityTrackingJob).with( - action: 'update', + action: 'move_to_list', project_id: current_project.id, trackable_id: card_b.id, trackable_type: 'Card', @@ -56,6 +56,49 @@ ) end + context 'when moving the first card in the source list' do + let(:submit) do + post move_to_list_project_board_list_card_path(current_project, board, source_list, card_a), + params: { new_list_id: target_list.id } + end + + it 'promotes the next card to list head' do + submit + expect(card_b.reload.previous_id).to be_nil + end + end + + context 'when moving the last card in the source list' do + let(:submit) do + post move_to_list_project_board_list_card_path(current_project, board, source_list, card_c), + params: { new_list_id: target_list.id } + end + + it 'leaves the remaining chain intact' do + submit + expect(card_b.reload.previous_id).to eq(card_a.id) + end + end + + context 'when the target list is the same as the source list' do + let(:submit) do + post move_to_list_project_board_list_card_path(current_project, board, source_list, card_c), + params: { new_list_id: source_list.id } + end + + it 'does not change the card' do + original_previous_id = card_c.previous_id + submit + expect(card_c.reload.previous_id).to eq(original_previous_id) + end + + it 'redirects with an alert' do + submit + expect(response).to redirect_to(project_board_list_card_path(current_project, board, source_list, card_c)) + expect(flash[:alert]).to eq('Task is already in that list.') + end + end + context 'when the target list is empty' do let(:last_card_in_target) { nil } From 746bb5141764a1abb3cc3cc2d95ae6cd3e59720e Mon Sep 17 00:00:00 2001 From: nicolachr Date: Tue, 12 May 2026 15:14:23 +0300 Subject: [PATCH 06/18] track card list moves in the activity feed Activities were silently dropped because 'move_to_list' was not in Activity::VALID_ACTIONS, causing ActivityTrackingJob to fail validation. --- app/models/activity.rb | 2 +- app/presenters/activity_presenter.rb | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/activity.rb b/app/models/activity.rb index 25acbdc277..e365f67601 100644 --- a/app/models/activity.rb +++ b/app/models/activity.rb @@ -17,7 +17,7 @@ def project=(new_project); end validates_presence_of :action, :trackable_id, :trackable_type, :user - VALID_ACTIONS = %w[create destroy download recover reopen resolve state_change update] + VALID_ACTIONS = %w[create destroy download move_to_list recover reopen resolve state_change update] validates_inclusion_of :action, in: VALID_ACTIONS diff --git a/app/presenters/activity_presenter.rb b/app/presenters/activity_presenter.rb index f406aef18f..4981389acd 100644 --- a/app/presenters/activity_presenter.rb +++ b/app/presenters/activity_presenter.rb @@ -83,6 +83,8 @@ def verb case activity.action when 'destroy' 'deleted' + when 'move_to_list' + 'moved' when 'state_change' 'updated' else From c9e6ed0f11e65aafd4466d8f21a6d9214cc680f0 Mon Sep 17 00:00:00 2001 From: nicolachr Date: Tue, 12 May 2026 15:24:41 +0300 Subject: [PATCH 07/18] move list filtering out of the card actions partial --- app/controllers/cards_controller.rb | 1 + app/views/cards/_actions.html.erb | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index a851ecc6b5..0b07c22dfd 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -18,6 +18,7 @@ class CardsController < AuthenticatedController layout 'cards' def show + @other_lists = @board.ordered_lists.reject { |l| l.id == @list.id } render layout: !request.xhr? end diff --git a/app/views/cards/_actions.html.erb b/app/views/cards/_actions.html.erb index 6400a28c09..5621d08c65 100644 --- a/app/views/cards/_actions.html.erb +++ b/app/views/cards/_actions.html.erb @@ -1,10 +1,9 @@
- <% other_lists = @board.ordered_lists.reject { |l| l.id == @list.id } %> - <% if other_lists.any? %> + <% if @other_lists.any? %> <%= @list.name %>