From 87e92d958261577942d5f198f401cafffb1a5321 Mon Sep 17 00:00:00 2001 From: Takenori Takaki Date: Mon, 8 Sep 2025 17:58:10 +0900 Subject: [PATCH 1/2] Enable to issue-card manual ordering --- .github/workflows/test.yml | 2 + README.md | 12 +++++ app/controllers/issues_panel_controller.rb | 5 ++ app/models/issue_card.rb | 13 +++-- app/models/issue_card_position.rb | 16 ++++++ app/views/issues_panel/_query_form.html.erb | 12 ++++- app/views/issues_panel/index.html.erb | 51 +++++++++++++++---- app/views/issues_panel/move_issue_card.js.erb | 15 ++++-- config/locales/en.yml | 2 + config/locales/ja.yml | 2 + ...50901151155_create_issue_card_positions.rb | 11 ++++ init.rb | 1 + lib/redmine/helpers/issues_panel.rb | 1 + lib/redmine_issues_panel/issue_patch.rb | 13 +++++ lib/redmine_issues_panel/issue_query_patch.rb | 45 ++++++++++++++++ lib/redmine_issues_panel/view_hook.rb | 17 +++++++ .../issues_panel_controller_test.rb | 7 ++- test/unit/issue_card_position_test.rb | 34 +++++++++++++ 18 files changed, 235 insertions(+), 24 deletions(-) create mode 100644 app/models/issue_card_position.rb create mode 100644 db/migrate/20250901151155_create_issue_card_positions.rb create mode 100644 lib/redmine_issues_panel/issue_patch.rb create mode 100644 test/unit/issue_card_position_test.rb diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70472bb..544bdcd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,4 +35,6 @@ jobs: with: path: plugins/redmine_issues_panel + - run: bin/rails redmine:plugins:migrate NAME=redmine_issues_panel RAILS_ENV=test + - run: bin/rails redmine:plugins:test NAME=redmine_issues_panel diff --git a/README.md b/README.md index 392338a..4122b2d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ This is a plugin for Redmine to display issues by statuses and change it's statu $ git clone https://github.com/redmica/redmine_issues_panel.git /path/to/redmine/plugins/redmine_issues_panel ``` +#### Migration (to add the new table) + +``` +$ bin/rails redmine:plugins:migrate NAME=redmine_issues_panel +``` + ## How to activate the Issues Panel #### Check the 'Issues Panel' checkbox on the Project->Settings->Modules and save it. @@ -35,6 +41,12 @@ $ bundle exec rake redmine:plugins:test NAME=redmine_issues_panel RAILS_ENV=test ## Uninstall +#### Run the migration to remove the table. + +``` +$ bin/rails redmine:plugins:migrate NAME=redmine_issues_panel VERSION=0 +``` + #### Remove the plugin directory. ``` diff --git a/app/controllers/issues_panel_controller.rb b/app/controllers/issues_panel_controller.rb index 912d939..de577a6 100644 --- a/app/controllers/issues_panel_controller.rb +++ b/app/controllers/issues_panel_controller.rb @@ -71,6 +71,11 @@ def retrieve_issue_panel(params={}) elsif params[:query_id].blank? && session[session_key][:issues_num_per_row] @query.issues_num_per_row = session[session_key][:issues_num_per_row] end + if params[:set_filter] && params[:query] && params[:query][:enable_manual_ordering] + session[session_key][:enable_manual_ordering] = @query.enable_manual_ordering + elsif params[:query_id].blank? && session[session_key][:enable_manual_ordering] + @query.enable_manual_ordering = session[session_key][:enable_manual_ordering] + end end @issues_panel.query = @query end diff --git a/app/models/issue_card.rb b/app/models/issue_card.rb index fc325e1..85f0b1e 100644 --- a/app/models/issue_card.rb +++ b/app/models/issue_card.rb @@ -1,4 +1,5 @@ class IssueCard < Issue + attribute :reordered, :boolean, default: false validate do (@move_attributes.keys - self.changes_to_save.keys).each do |attr| self.errors.add attr, l('activerecord.errors.messages.can_t_be_changed') @@ -23,9 +24,15 @@ def move!(attributes={}) end end end - if @move_attributes.any? || @custom_field_attributes.any? - self.safe_attributes = @move_attributes.merge(@custom_field_attributes) - self.save! + self.transaction do + if @move_attributes.any? || @custom_field_attributes.any? + self.safe_attributes = @move_attributes.merge(@custom_field_attributes) + self.save! + end + if attributes[:ordered_issue_ids].is_a?(Array) && attributes[:ordered_issue_ids].any? + IssueCardPosition.update_positions!(attributes[:ordered_issue_ids]) + self.reordered = true + end end end diff --git a/app/models/issue_card_position.rb b/app/models/issue_card_position.rb new file mode 100644 index 0000000..17a09cb --- /dev/null +++ b/app/models/issue_card_position.rb @@ -0,0 +1,16 @@ +class IssueCardPosition < ActiveRecord::Base + class << self + def update_positions!(ordered_issue_ids=[]) + return if ordered_issue_ids.blank? + ordered_issue_positions = ordered_issue_ids.each_with_index.map { |id, i| { issue_id: id, position: i } } + opts = { update_only: %i[position] } + # :unique_by option is supported only by PostgreSQL and SQLite3 + opts[:unique_by] = 'index_issue_card_positions_on_issue_id' if Redmine::Database.postgresql? || Redmine::Database.sqlite? + self.upsert_all(ordered_issue_positions, **opts) + end + end + + def to_s + position.to_s + end +end diff --git a/app/views/issues_panel/_query_form.html.erb b/app/views/issues_panel/_query_form.html.erb index 12050ec..ce31a30 100644 --- a/app/views/issues_panel/_query_form.html.erb +++ b/app/views/issues_panel/_query_form.html.erb @@ -45,8 +45,15 @@ <%= hidden_field_tag 'selected_query_issues_num_per_row', @query.issues_num_per_row %> <%= select_tag 'query[issues_num_per_row]', options_for_select([1, 2, 3], @query.issues_num_per_row) %> +
+
<%= l(:field_enable_manual_ordering) %>
+ +
<% if @query.sortable_columns.any? %> -
+
<% 3.times do |i| %> @@ -100,7 +107,8 @@ $(function ($) { } else { $('table#list-definition').hide(); } - }) + }); + toggleSortSelection(); }); <% end %> diff --git a/app/views/issues_panel/index.html.erb b/app/views/issues_panel/index.html.erb index b98c88e..1f825d3 100644 --- a/app/views/issues_panel/index.html.erb +++ b/app/views/issues_panel/index.html.erb @@ -63,6 +63,20 @@ function loadDraggableSettings() { }); }); } +function moveIssueCard(issue_card, receiver, ordered_issue_ids=[]) { + $.ajax({ + url: '<%= move_issue_card_path(:format => 'js') %>', + type: 'put', + data: { + 'id': issue_card.attr('data-issue-id'), + <%= @project ? ("'project_id': #{@project.id},").html_safe : '' %> + 'status_id': receiver.attr('data-status-id'), + 'group_key': receiver.attr('data-group-key'), + 'group_value': receiver.attr('data-group-value'), + 'ordered_issue_ids': ordered_issue_ids + } + }); +} function loadDroppableSetting() { $(".issue-card-receiver").droppable({ accept: ".issue-card", @@ -78,22 +92,33 @@ function loadDroppableSetting() { (typeof(new_group_value) === "undefiend" || org_group_value==new_group_value)) { return $(ui.draggable).addClass('drag-revert'); } else { - $.ajax({ - url: '<%= move_issue_card_path(:format => 'js') %>', - type: 'put', - data: { - 'id': ui.draggable.attr('data-issue-id'), - <%= @project ? ("'project_id': #{@project.id},").html_safe : '' %> - 'status_id': $(this).attr('data-status-id'), - 'group_key': $(this).attr('data-group-key'), - 'group_value': $(this).attr('data-group-value') - } - }); + moveIssueCard(ui.draggable, $(this)); } } } }); } +function loadSortableSetting() { + $(".issue-card-receiver").sortable({ + items: ".issue-card:not(.add-issue-card)", + connectWith: '.issue-card-receiver', + revert: true, + start: function(event, ui) { + ui.item.css({ opacity: 0.9 }); + ui.placeholder.height(ui.item.height()); + }, + activate: function() { $('.hascontextmenu').removeClass('context-menu-selection cm-last');contextMenuHide();hideIssueDescription(); }, + update: function(event, ui) { + var current_receiver = ui.item.parents('.issue-card-receiver')[0]; + if (this !== current_receiver) return; // do nothing if the update event is not fired in the current receiver + var ordered_issue_ids = []; + $(this).find('.issue-card:not(.add-issue-card)').each(function(_, elem) { + ordered_issue_ids.push($(elem).attr('data-issue-id')); + }); + moveIssueCard(ui.item, $(this), ordered_issue_ids); + } + }).disableSelection(); +} function showIssueDescription(issue_element, description_element) { var issue_id = issue_element.attr('href').split('/').at(-1) var mouse_x = issue_element.offset().left; @@ -193,8 +218,12 @@ function loadCardFunctions(){ }); } $(document).ready(function(){ + <% if @query.enable_manual_ordering? %> + loadSortableSetting(); + <% else %> loadDraggableSettings(); loadDroppableSetting(); + <% end %> loadCardFunctions(); hideIssueDescription(); }); diff --git a/app/views/issues_panel/move_issue_card.js.erb b/app/views/issues_panel/move_issue_card.js.erb index 0a908c8..d123564 100644 --- a/app/views/issues_panel/move_issue_card.js.erb +++ b/app/views/issues_panel/move_issue_card.js.erb @@ -1,5 +1,5 @@ <% @issues_panel.view = self %> -<% if @issue_card.saved_changes? %> +<% if @issue_card.saved_changes? || @issue_card.reordered? %> /// remove issue card $('#issue-card-<%= @issue_card.id %>').remove(); // refresh status total @@ -16,11 +16,18 @@ // restore background-color contextMenuAddSelection($('#issue-<%= @issue_card.id %>')); loadCardFunctions(); - loadDraggableSettings(); + <% unless @issues_panel.query.enable_manual_ordering? %> + loadDraggableSettings(); + <% end %> <% else %> <% if flash[:error].present? %> alert('<%= raw(escape_javascript(flash[:error])) %>'); <% end %> - // revart issue card - $('#issue-card-<%= @issue_card.id %>').animate( {left: 0, top: 0}, 500 ); + <% if @issues_panel.query.enable_manual_ordering? %> + // cancel card reordering + $('.issue-card-receiver').sortable('cancel'); + <% else %> + // revart issue card + $('#issue-card-<%= @issue_card.id %>').animate( {left: 0, top: 0}, 500 ); + <% end %> <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index e0ddaed..7e2ac40 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -6,6 +6,8 @@ en: notice_failed_to_move_issue: "Failed to move issue." label_assign_to_me: "Assign to me" field_issues_num_per_row: "Issues num per row" + field_issue_card_position: "Issue card position" + field_enable_manual_ordering: "Enable manual ordering" project_module_issues_panel: "Issues Panel" diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 719b9e0..c3532cf 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -6,6 +6,8 @@ ja: notice_failed_to_move_issue: "チケットの移動に失敗しました。" label_assign_to_me: "自分に割り当て" field_issues_num_per_row: "一行ごとのチケット数" + field_issue_card_position: "カードの位置" + field_enable_manual_ordering: "手動で並び替え" project_module_issues_panel: "チケットパネル" diff --git a/db/migrate/20250901151155_create_issue_card_positions.rb b/db/migrate/20250901151155_create_issue_card_positions.rb new file mode 100644 index 0000000..6fe2326 --- /dev/null +++ b/db/migrate/20250901151155_create_issue_card_positions.rb @@ -0,0 +1,11 @@ +class CreateIssueCardPositions < ActiveRecord::Migration[7.2] + def change + create_table :issue_card_positions do |t| + t.column :issue_id, :integer, null: false + t.column :position, :integer, null: false, default: 0 + t.column :created_on, :datetime, null: false + t.column :updated_on, :datetime, null: false + end + add_index :issue_card_positions, :issue_id, name: 'index_issue_card_positions_on_issue_id', unique: true + end +end diff --git a/init.rb b/init.rb index b4ab33d..a3ef203 100644 --- a/init.rb +++ b/init.rb @@ -1,5 +1,6 @@ require File.expand_path('../lib/redmine_issues_panel/queries_controller_patch', __FILE__) require File.expand_path('../lib/redmine_issues_panel/view_hook', __FILE__) +require File.expand_path('../lib/redmine_issues_panel/issue_patch', __FILE__) require File.expand_path('../lib/redmine_issues_panel/issue_query_patch', __FILE__) Redmine::Plugin.register :redmine_issues_panel do diff --git a/lib/redmine/helpers/issues_panel.rb b/lib/redmine/helpers/issues_panel.rb index a4f741a..9abf9f3 100644 --- a/lib/redmine/helpers/issues_panel.rb +++ b/lib/redmine/helpers/issues_panel.rb @@ -24,6 +24,7 @@ def initialize(options={}) def query=(query) @query = query query.available_columns.delete_if { |c| c.name == :tracker } + query.use_on_issues_panel = true @truncated = @query.issue_count.to_i > @issues_limit.to_i end diff --git a/lib/redmine_issues_panel/issue_patch.rb b/lib/redmine_issues_panel/issue_patch.rb new file mode 100644 index 0000000..dfb367a --- /dev/null +++ b/lib/redmine_issues_panel/issue_patch.rb @@ -0,0 +1,13 @@ +require 'issue' + +module RedmineIssuesPanel + module IssuePatch + def self.included(base) + base.class_eval do + has_one :issue_card_position, dependent: :destroy, foreign_key: :issue_id + end + end + end +end + +Issue.include RedmineIssuesPanel::IssuePatch diff --git a/lib/redmine_issues_panel/issue_query_patch.rb b/lib/redmine_issues_panel/issue_query_patch.rb index 53cfb97..090a289 100644 --- a/lib/redmine_issues_panel/issue_query_patch.rb +++ b/lib/redmine_issues_panel/issue_query_patch.rb @@ -6,6 +6,8 @@ def self.included(base) base.send(:prepend, InstanceMethods) base.class_eval do #unloadable + attribute :use_on_issues_panel, :boolean, default: false + self.available_columns << QueryColumn.new(:issue_card_position, :sortable => "#{IssueCardPosition.table_name}.position") end end @@ -16,9 +18,35 @@ def build_from_params(params, defaults={}) params[:issues_num_per_row] || (params[:query] && params[:query][:issues_num_per_row]) || options[:issues_num_per_row] + self.enable_manual_ordering = + params[:enable_manual_ordering] || + (params[:query] && params[:query][:enable_manual_ordering]) || + options[:enable_manual_ordering] self end + def base_scope + s = super + if (self.use_on_issues_panel? && self.enable_manual_ordering?) || + (self.column_names && self.column_names.include?(:issue_card_position)) + s = s.includes(:issue_card_position) + end + s + end + + def issues(options={}) + if self.enable_manual_ordering? + options[:order] = [] + if Redmine::Database.postgresql? + options[:order] << Arel.sql("#{IssueCardPosition.table_name}.position ASC NULLS FIRST") + else + options[:order] << Arel.sql("#{IssueCardPosition.table_name}.position ASC") + end + options[:order] << Arel.sql("#{Issue.table_name}.id DESC") + end + super(options) + end + def issues_num_per_row r = options[:issues_num_per_row] r.to_i == 0 ? 1 : r.to_i @@ -27,6 +55,23 @@ def issues_num_per_row def issues_num_per_row=(arg) options[:issues_num_per_row] = arg ? arg.to_i : 1 end + + def enable_manual_ordering + options[:enable_manual_ordering] + end + + def enable_manual_ordering? + return false unless self.use_on_issues_panel? + if self.enable_manual_ordering.nil? + self.new_record? ? true : false + else + self.enable_manual_ordering.to_s == '1' + end + end + + def enable_manual_ordering=(arg) + options[:enable_manual_ordering] = (arg.to_s == '0' ? '0' : '1') + end end end end diff --git a/lib/redmine_issues_panel/view_hook.rb b/lib/redmine_issues_panel/view_hook.rb index d96422f..4f4e1f9 100644 --- a/lib/redmine_issues_panel/view_hook.rb +++ b/lib/redmine_issues_panel/view_hook.rb @@ -3,8 +3,19 @@ class ViewHook < Redmine::Hook::ViewListener def view_layouts_base_html_head(context={}) query = context[:controller].instance_variable_get(:'@query') html = +'' + js = <<~JS + function toggleSortSelection() { + if ($('input#query_enable_manual_ordering').is(':checked')) { + $('#sort').hide(); + } else { + $('#sort').show(); + } + } + JS + html << javascript_tag(js) if (context[:controller] && context[:controller].is_a?(QueriesController)) && (context[:request] && context[:request].try(:params).is_a?(Hash) && context[:request].params['issues_panel']) + query.use_on_issues_panel = true js = <<~JS $(document).ready(function(){ var selector = '#{select_tag('query[issues_num_per_row]', options_for_select([1, 2, 3], (query && query.issues_num_per_row)).gsub("\n",'').html_safe)}'; @@ -12,6 +23,12 @@ def view_layouts_base_html_head(context={}) $('p.block_columns').remove(); $('p.totable_columns').remove(); $('p#group_by').after('

' + selector + '

'); + var checkbox = '#{hidden_field_tag('query[enable_manual_ordering]', 0, id: nil)}#{check_box_tag('query[enable_manual_ordering]', 1, (query && query.enable_manual_ordering?))}'; + $('p#issues_num_per_row').after('

' + checkbox + '

'); + toggleSortSelection(); + $('input#query_enable_manual_ordering').on('change', function(){ + toggleSortSelection(); + }); }); JS html << javascript_tag(js) diff --git a/test/functional/issues_panel_controller_test.rb b/test/functional/issues_panel_controller_test.rb index 1940f9b..354e45a 100644 --- a/test/functional/issues_panel_controller_test.rb +++ b/test/functional/issues_panel_controller_test.rb @@ -94,7 +94,6 @@ def test_move_issue_card assert_match "$('#issues-count-on-status-5').html('4')", response.body assert_match "$('.issues-count-on-group').html('0');", response.body assert_match "$('#issues-count-on-group-').html('4');", response.body - assert_match "loadDraggableSettings();", response.body end def test_move_issue_card_but_record_not_found @@ -103,7 +102,7 @@ def test_move_issue_card_but_record_not_found } assert_response :success assert_match "alert('#{I18n.t(:error_issue_not_found_in_project)}')", response.body - assert_match "('#issue-card-').animate( {left: 0, top: 0}, 500 );", response.body + assert_match "$('.issue-card-receiver').sortable('cancel');", response.body end def test_move_issue_card_but_unauthorized @@ -113,7 +112,7 @@ def test_move_issue_card_but_unauthorized } assert_response :success assert_match "alert('#{I18n.t(:notice_not_authorized_to_change_this_issue)}')", response.body - assert_match "('#issue-card-1').animate( {left: 0, top: 0}, 500 );", response.body + assert_match "$('.issue-card-receiver').sortable('cancel');", response.body end def test_move_issue_card_but_exception_raised @@ -124,7 +123,7 @@ def test_move_issue_card_but_exception_raised } assert_response :success assert_match "alert('#{error_message_on_move}')", response.body - assert_match "('#issue-card-1').animate( {left: 0, top: 0}, 500 );", response.body + assert_match "$('.issue-card-receiver').sortable('cancel');", response.body end def assert_modal_issue_card() diff --git a/test/unit/issue_card_position_test.rb b/test/unit/issue_card_position_test.rb new file mode 100644 index 0000000..ba5a6b4 --- /dev/null +++ b/test/unit/issue_card_position_test.rb @@ -0,0 +1,34 @@ +require File.expand_path('../../test_helper', __FILE__) + +class IssueCardPositionTest < ActiveSupport::TestCase + def setup + User.current = User.find(1) + end + + def test_update_positions + IssueCardPosition.delete_all + assert_equal 0, IssueCardPosition.count + + # insert new positions + IssueCardPosition.update_positions!([3, 1, 2]) + assert_equal 3, IssueCardPosition.count + assert_equal 0, IssueCardPosition.find_by(issue_id: 3).position + assert_equal 1, IssueCardPosition.find_by(issue_id: 1).position + assert_equal 2, IssueCardPosition.find_by(issue_id: 2).position + + # update existing positions + IssueCardPosition.update_positions!([2, 3, 1]) + assert_equal 3, IssueCardPosition.count + assert_equal 0, IssueCardPosition.find_by(issue_id: 2).position + assert_equal 1, IssueCardPosition.find_by(issue_id: 3).position + assert_equal 2, IssueCardPosition.find_by(issue_id: 1).position + + # mixed existing and new positions + IssueCardPosition.update_positions!([4, 1, 3, 5]) + assert_equal 5, IssueCardPosition.count + assert_equal 0, IssueCardPosition.find_by(issue_id: 4).position + assert_equal 1, IssueCardPosition.find_by(issue_id: 1).position + assert_equal 2, IssueCardPosition.find_by(issue_id: 3).position + assert_equal 3, IssueCardPosition.find_by(issue_id: 5).position + end +end From 6545bf6227a93bdddb1a3c04591a8c4ef4425ddc Mon Sep 17 00:00:00 2001 From: Takenori Takaki Date: Thu, 25 Sep 2025 12:40:55 +0900 Subject: [PATCH 2/2] Add index on issue_card_positions.position to improve ORDER BY performance --- db/migrate/20250901151155_create_issue_card_positions.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/db/migrate/20250901151155_create_issue_card_positions.rb b/db/migrate/20250901151155_create_issue_card_positions.rb index 6fe2326..3b5ec66 100644 --- a/db/migrate/20250901151155_create_issue_card_positions.rb +++ b/db/migrate/20250901151155_create_issue_card_positions.rb @@ -7,5 +7,6 @@ def change t.column :updated_on, :datetime, null: false end add_index :issue_card_positions, :issue_id, name: 'index_issue_card_positions_on_issue_id', unique: true + add_index :issue_card_positions, :position, name: 'index_issue_card_positions_on_position' end end