├── .github └── workflows │ └── test.yml ├── README.md ├── app ├── controllers │ └── issues_panel_controller.rb ├── helpers │ └── issues_panel_helper.rb ├── models │ └── issue_card.rb └── views │ └── issues_panel │ ├── _modal_form.html.erb │ ├── _query_form.html.erb │ ├── _show_issue_description.html.erb │ ├── index.html.erb │ ├── move_issue_card.js.erb │ └── new_issue_card.js.erb ├── assets └── stylesheets │ └── redmine_issues_panel.css ├── config ├── locales │ ├── en.yml │ └── ja.yml └── routes.rb ├── images └── how_to_activate.png ├── init.rb ├── lib ├── redmine │ └── helpers │ │ └── issues_panel.rb └── redmine_issues_panel │ ├── issue_query_patch.rb │ ├── queries_controller_patch.rb │ └── view_hook.rb └── test ├── fixtures └── queries.yml ├── functional └── issues_panel_controller_test.rb ├── test_helper.rb └── unit ├── issue_card_test.rb ├── issue_query_test.rb └── lib └── redmine └── helpers └── issues_panel_test.rb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | include: 14 | - redmine-repository: 'redmica/redmica' 15 | redmine-version: 'stable-3.1' 16 | ruby-version: '3.3' 17 | - redmine-repository: 'redmica/redmica' 18 | redmine-version: 'master' 19 | ruby-version: '3.3' 20 | - redmine-repository: 'redmine/redmine' 21 | redmine-version: 'master' 22 | ruby-version: '3.3' 23 | 24 | steps: 25 | - uses: hidakatsuya/action-setup-redmine@v1 26 | with: 27 | repository: ${{ matrix.redmine-repository }} 28 | version: ${{ matrix.redmine-version }} 29 | ruby-version: ${{ matrix.ruby-version }} 30 | database: 'postgres:14' 31 | 32 | - uses: actions/checkout@v4 33 | with: 34 | path: plugins/redmine_issues_panel 35 | 36 | - run: bin/rails redmine:plugins:test NAME=redmine_issues_panel 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redmine Issues Panel 2 | 3 | This is a plugin for Redmine to display issues by statuses and change it's status by DnD. 4 | 5 | ## Features 6 | 7 | * Filter and group issues with custom queries 8 | * Drag and drop to change status 9 | * Update issues via the context menu 10 | * Add a new Issue from the panel 11 | 12 | ## Install 13 | 14 | #### Place the plugin source at Redmine plugins directory. 15 | 16 | `git clone` or copy an unarchived plugin to 17 | `plugins/redmine_issues_panel` on your Redmine installation path. 18 | 19 | ``` 20 | $ git clone https://github.com/redmica/redmine_issues_panel.git /path/to/redmine/plugins/redmine_issues_panel 21 | ``` 22 | 23 | ## How to activate the Issues Panel 24 | 25 | #### Check the 'Issues Panel' checkbox on the Project->Settings->Modules and save it. 26 | 27 | ![How to activate the Issues Panel](images/how_to_activate.png?raw=true "Check the Issues Panel checkbox on the Project->Settings->Modules") 28 | 29 | ## Test 30 | 31 | ``` 32 | $ cd /path/to/redmine 33 | $ bundle exec rake redmine:plugins:test NAME=redmine_issues_panel RAILS_ENV=test 34 | ``` 35 | 36 | ## Uninstall 37 | 38 | #### Remove the plugin directory. 39 | 40 | ``` 41 | $ cd /path/to/redmine 42 | $ rm -rf plugins/redmine_issues_panel 43 | ``` 44 | 45 | ## Licence 46 | 47 | This plugin is licensed under the GNU General Public License, version 2 (GPLv2) 48 | 49 | ## Author 50 | 51 | [Takenori Takaki (Far End Technologies)](https://www.farend.co.jp) 52 | -------------------------------------------------------------------------------- /app/controllers/issues_panel_controller.rb: -------------------------------------------------------------------------------- 1 | class IssuesPanelController < ApplicationController 2 | before_action :find_optional_project, :except => [:show_issue_description] 3 | before_action :find_issue_card, :only => [:show_issue_description, :move_issue_card] 4 | 5 | rescue_from Query::StatementInvalid, :with => :query_statement_invalid 6 | 7 | helper :projects 8 | helper :issues 9 | helper :queries 10 | helper :watchers 11 | helper :custom_fields 12 | include QueriesHelper 13 | 14 | def index 15 | retrieve_issue_panel(params) 16 | end 17 | 18 | def show_issue_description 19 | if flash[:error].nil? 20 | render json: { description: render_to_string(partial: 'issues_panel/show_issue_description', locals: { issue_card: @issue_card }) } 21 | else 22 | render json: { error_message: flash[:error] } 23 | end 24 | end 25 | 26 | def move_issue_card 27 | if flash[:error].nil? 28 | @issue_card.init_journal(User.current) 29 | @issue_card.move!(params) 30 | end 31 | rescue Exception => e 32 | flash.now[:error] = e.message 33 | ensure 34 | retrieve_issue_panel 35 | render :layout => false 36 | end 37 | 38 | def new_issue_card 39 | @issue_card = IssueCard.new 40 | @issue_card.project = @project 41 | @issue_card.project ||= @issue_card.allowed_target_projects.first 42 | @issue_card.author ||= User.current 43 | @issue_card.start_date ||= User.current.today if Setting.default_issue_start_date_to_creation_date? 44 | attrs = (params[:issue] || {}).deep_dup 45 | @issue_card.instance_variable_set(:@safe_attribute_names, ['project_id', 'tracker_id', 'status_id', 'category_id', 'assigned_to_id', 'priority_id', 'fixed_version_id', 'subject', 'is_private']) 46 | @issue_card.safe_attributes = attrs 47 | @allowed_statuses = @issue_card.new_statuses_allowed_to(User.current) 48 | @priorities = IssuePriority.active 49 | end 50 | 51 | private 52 | 53 | def find_issue_card 54 | @issue_card = IssueCard.find(params[:id]) 55 | raise Unauthorized unless @issue_card.visible? 56 | rescue ActiveRecord::RecordNotFound 57 | @issue_card = IssueCard.new 58 | flash.now[:error] = l(:error_issue_not_found_in_project) 59 | rescue Unauthorized 60 | flash.now[:error] = l(:notice_not_authorized_to_change_this_issue) 61 | end 62 | 63 | def retrieve_issue_panel(params={}) 64 | @issues_panel = Redmine::Helpers::IssuesPanel.new(params) 65 | retrieve_query 66 | # retrieve optional query filter in session 67 | session_key = IssueQuery.name.underscore.to_sym 68 | if session[session_key] 69 | if params[:set_filter] && params[:query] && params[:query][:issues_num_per_row] 70 | session[session_key][:issues_num_per_row] = @query.issues_num_per_row 71 | elsif params[:query_id].blank? && session[session_key][:issues_num_per_row] 72 | @query.issues_num_per_row = session[session_key][:issues_num_per_row] 73 | end 74 | end 75 | @issues_panel.query = @query 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /app/helpers/issues_panel_helper.rb: -------------------------------------------------------------------------------- 1 | module IssuesPanelHelper 2 | 3 | def _project_issues_panel_path(project, *args) 4 | if project 5 | project_issues_panel_path(project, *args) 6 | else 7 | issues_panel_path(*args) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/models/issue_card.rb: -------------------------------------------------------------------------------- 1 | class IssueCard < Issue 2 | validate do 3 | (@move_attributes.keys - self.changes_to_save.keys).each do |attr| 4 | self.errors.add attr, l('activerecord.errors.messages.can_t_be_changed') 5 | end 6 | validate_custom_field_values 7 | end 8 | 9 | def move!(attributes={}) 10 | @move_attributes, @custom_field_attributes = {}, {} 11 | if attributes[:status_id] && self.status_id.to_s != attributes[:status_id].to_s 12 | @move_attributes['status_id'] = attributes[:status_id].to_s 13 | end 14 | if attributes[:group_key] && attributes[:group_value] 15 | if attributes[:group_key] == 'custom_field_values' 16 | group_values = attributes[:group_value].to_s.split(',') 17 | if self.custom_field_value(group_values[0]) != group_values[1] 18 | @custom_field_attributes['custom_field_values'] = Hash[*[group_values[0], group_values[1]]] 19 | end 20 | else 21 | if self.attributes[attributes[:group_key]].to_s != attributes[:group_value].to_s 22 | @move_attributes[attributes[:group_key]] = attributes[:group_value].to_s 23 | end 24 | end 25 | end 26 | if @move_attributes.any? || @custom_field_attributes.any? 27 | self.safe_attributes = @move_attributes.merge(@custom_field_attributes) 28 | self.save! 29 | end 30 | end 31 | 32 | # Override Issue#css_classes 33 | def css_classes(user=User.current) 34 | s = super 35 | s << ' icon icon-checked' if closed? 36 | s 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /app/views/issues_panel/_modal_form.html.erb: -------------------------------------------------------------------------------- 1 |

<%= l(:label_issue_new) %>

2 | <%= labelled_form_for @issue_card, :url => _project_issues_path(@project), :as => 'issue', 3 | :html => {:id => 'issue-form', :multipart => true} do |f| %> 4 | <%= error_messages_for 'issue' %> 5 | <%= hidden_field_tag 'project_id', params[:project_id] if params[:project_id] %> 6 | <%= hidden_field_tag 'back_url', params[:back_url] if params[:back_url] %> 7 |
8 |
9 |
10 | <% if @issue_card.safe_attribute? 'is_private' %> 11 |

12 | <%= f.check_box :is_private, :no_label => true %> 13 |

14 | <% end %> 15 | 16 | <% projects = @issue_card.allowed_target_projects(User.current, @project) %> 17 | <% if @issue_card.safe_attribute?('project_id') && (@project.nil? || projects.length > 1) %> 18 |

19 | <%= f.select :project_id, project_tree_options_for_select(projects, :selected => @issue_card.project), {:required => true}, 20 | :onchange => "updateIssueFrom('#{escape_javascript new_issue_card_path}', this)" %> 21 |

22 | <% end %> 23 | 24 | <% if @issue_card.safe_attribute?('tracker_id') || (@issue_card.persisted? && @issue_card.tracker_id_changed?) %> 25 |

26 | <%= f.select :tracker_id, trackers_options_for_select(@issue_card), {:required => true}, 27 | :onchange => "updateIssueFrom('#{escape_javascript new_issue_card_path}', this)" %> 28 |

29 | <% end %> 30 | 31 | <% if @issue_card.safe_attribute? 'subject' %> 32 |

<%= f.text_field :subject, :size => 80, :maxlength => 255, :required => true %>

33 | <% end %> 34 | 35 | <% if @issue_card.safe_attribute?('status_id') && @allowed_statuses.present? %> 36 |

37 | <%= f.select :status_id, (@allowed_statuses.collect {|p| [p.name, p.id]}), {:required => true}, 38 | :onchange => "updateIssueFrom('#{escape_javascript new_issue_card_path}', this)" %> 39 |

40 | <% else %> 41 |

<%= @issue_card.status %>

42 | <% end %> 43 | 44 | <% if @issue_card.safe_attribute? 'priority_id' %> 45 |

<%= f.select :priority_id, (@priorities.collect {|p| [p.name, p.id]}), {:required => true} %>

46 | <% end %> 47 | 48 | <% if @issue_card.safe_attribute? 'assigned_to_id' %> 49 |

50 | <%= f.select :assigned_to_id, principals_options_for_select(@issue_card.assignable_users, @issue_card.assigned_to), 51 | :include_blank => true, :required => @issue_card.required_attribute?('assigned_to_id') %> 52 | <% if @issue_card.assignable_users.include?(User.current) %> 53 | <%= l(:label_assign_to_me) %> 54 | <% end %> 55 |

56 | <% end %> 57 | 58 | <% if @issue_card.safe_attribute?('category_id') && @issue_card.project.issue_categories.any? %> 59 |

60 | <%= f.select :category_id, (@issue_card.project.issue_categories.collect {|c| [c.name, c.id]}), 61 | {:include_blank => true, :required => @issue_card.required_attribute?('category_id')}, 62 | :onchange => "updateIssueFrom('#{escape_javascript new_issue_card_path}', this)" %> 63 | <%= link_to(sprite_icon('add',l(:label_issue_category_new)), 64 | new_project_issue_category_path(@issue_card.project), 65 | :remote => true, 66 | :method => 'get', 67 | :title => l(:label_issue_category_new), 68 | :tabindex => 200, 69 | :class => 'icon-only icon-add' 70 | ) if User.current.allowed_to?(:manage_categories, @issue_card.project) %> 71 |

72 | <% end %> 73 | 74 | <% if @issue_card.safe_attribute?('fixed_version_id') && @issue_card.assignable_versions.any? %> 75 |

76 | <%= f.select :fixed_version_id, version_options_for_select(@issue_card.assignable_versions, @issue_card.fixed_version), 77 | :include_blank => true, :required => @issue_card.required_attribute?('fixed_version_id') %> 78 | <%= link_to(sprite_icon('add', l(:label_version_new)), 79 | new_project_version_path(@issue_card.project), 80 | :remote => true, 81 | :method => 'get', 82 | :title => l(:label_version_new), 83 | :tabindex => 200, 84 | :class => 'icon-only icon-add' 85 | ) if User.current.allowed_to?(:manage_versions, @issue_card.project) %> 86 |

87 | <% end %> 88 | 89 | <% custom_field_values = @issue_card.editable_custom_field_values %> 90 | <% if custom_field_values.present? %> 91 | <% custom_field_values.each do |value| %> 92 | <% if value.custom_field.is_required? || (params[:issue].is_a?(Hash) && params[:issue][:custom_field_values].is_a?(Hash) && params[:issue][:custom_field_values].keys.include?(value.custom_field.id.to_s)) %> 93 |

<%= custom_field_tag_with_label :issue, value, :required => @issue_card.required_attribute?(value.custom_field_id) %>

94 | <% end %> 95 | <% end -%> 96 | <% end %> 97 |
98 |
99 |
100 | 101 |

102 | <%= submit_tag l(:button_create), :name => nil %> 103 | <%= link_to_function l(:button_cancel), "hideModal(this);" %> 104 |

105 | <% end %> 106 | 107 | <% content_for :header_tags do %> 108 | <%= robot_exclusion_tag %> 109 | <% end %> 110 | 111 | <%= javascript_tag do %> 112 | $(document).ready(function(){ 113 | $("#issue_tracker_id, #issue_status_id").each(function(){ 114 | $(this).val($(this).find("option[selected=selected]").val()); 115 | }); 116 | $(".assign-to-me-link").click(function(event){ 117 | event.preventDefault(); 118 | var element = $(event.target); 119 | $('#issue_assigned_to_id').val(element.data('id')); 120 | element.hide(); 121 | }); 122 | $('#issue_assigned_to_id').change(function(event){ 123 | var assign_to_me_link = $(".assign-to-me-link"); 124 | 125 | if (assign_to_me_link.length > 0) { 126 | var user_id = $(event.target).val(); 127 | var current_user_id = assign_to_me_link.data('id'); 128 | 129 | if (user_id == current_user_id) { 130 | assign_to_me_link.hide(); 131 | } else { 132 | assign_to_me_link.show(); 133 | } 134 | } 135 | }); 136 | }); 137 | <% end %> 138 | -------------------------------------------------------------------------------- /app/views/issues_panel/_query_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= hidden_field_tag 'set_filter', '1' %> 2 | <%= hidden_field_tag 'issues_panel', '1' %> 3 | <%= hidden_field_tag 'type', @query.type, :disabled => true, :id => 'query_type' %> 4 | 5 |
6 |
7 |
"> 8 | "> 9 | <%= sprite_icon(@query.new_record? ? "angle-down" : "angle-right") %> 10 | <%= l(:label_filter_plural) %> 11 | 12 |
"> 13 | <%= render :partial => 'queries/filters', :locals => {:query => @query} %> 14 |
15 |
16 | 17 | <% if @query.available_columns.any? %> 18 | 71 | <% end %> 72 |
73 | 74 |

75 | <%= link_to_function sprite_icon('checked', l(:button_apply)), '$("#query_form").submit()', :class => 'icon icon-checked' %> 76 | <%= link_to sprite_icon('reload', l(:button_clear)), { :set_filter => 1, :sort => '', :project_id => @project }, :class => 'icon icon-reload' %> 77 | <% if @query.new_record? %> 78 | <% if User.current.allowed_to?(:save_queries, @project, :global => true) %> 79 | <%= link_to_function sprite_icon('save', l(:button_save_object, object_name: l(:label_query).downcase)), 80 | "$('#query_type').prop('disabled',false);$('#query_form').attr('action', '#{ @project ? new_project_query_path(@project) : new_query_path }').submit()", 81 | :class => 'icon icon-save' %> 82 | <% end %> 83 | <% else %> 84 | <% if @query.editable_by?(User.current) %> 85 | <% redirect_params = {:issues_panel => 1} %> 86 | <%= link_to sprite_icon('edit', l(:button_edit_object, object_name: l(:label_query).downcase)), edit_query_path(@query, redirect_params), :class => 'icon icon-edit' %> 87 | <%= delete_link query_path(@query, redirect_params), {}, l(:button_delete_object, object_name: l(:label_query).downcase) %> 88 | <% end %> 89 | <% end %> 90 |

91 |
92 | 93 | <%= error_messages_for @query %> 94 | 95 | <%= javascript_tag do %> 96 | $(function ($) { 97 | $('input[name=display_type]').change(function (e) { 98 | if ($("#display_type_list").is(':checked')) { 99 | $('table#list-definition').show(); 100 | } else { 101 | $('table#list-definition').hide(); 102 | } 103 | }) 104 | }); 105 | 106 | <% end %> 107 | -------------------------------------------------------------------------------- /app/views/issues_panel/_show_issue_description.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | <%= link_to_issue(issue_card, :tracker => false, :subject => true) %> 4 |
5 |
6 |
7 |
8 | <%= textilizable(issue_card.description&.truncate(200), :headings => false) %> 9 |
10 |
11 |
12 | -------------------------------------------------------------------------------- /app/views/issues_panel/index.html.erb: -------------------------------------------------------------------------------- 1 | <% @issues_panel.view = self %> 2 | 3 | <%= stylesheet_link_tag "redmine_issues_panel", plugin: "redmine_issues_panel" %> 4 | 5 |
6 | <% if User.current.allowed_to?(:add_issues, @project, :global => true) && (@project.nil? || Issue.allowed_target_trackers(@project).any?) %> 7 | <%= link_to sprite_icon('add', l(:label_issue_new)), _new_project_issue_path(@project, {:params => { :back_url => _project_issues_panel_path(@project) } }), :class => 'icon icon-add new-issue' %> 8 | <% end %> 9 |
10 | 11 |

<%= @query.new_record? ? l(:label_issues_panel_plural) : @query.name %>

12 | <% html_title(@query.new_record? ? l(:label_issues_panel_plural) : @query.name) %> 13 | 14 | <%= form_tag(_project_issues_panel_path(@project), :method => :get, :id => 'query_form') do %> 15 | <%= render :partial => 'issues_panel/query_form' %> 16 | <% end %> 17 | 18 | <% if @query.valid? %> 19 | <% if @issues_panel.truncated %> 20 |

<%= l(:notice_issues_panel_truncated, :max => @issues_panel.issues_limit) %>

21 | <% end %> 22 | <%= form_tag({}, :data => {:cm_url => issues_context_menu_path}) do %> 23 | <%= hidden_field_tag('back_url', url_for(:params => request.query_parameters), :id => nil) %> 24 |
25 | <%= @issues_panel.render_issues_panel %> 26 |
27 | <% end %> 28 | <% end %> 29 | 30 | <% content_for :sidebar do %> 31 | <%= render :partial => 'issues/sidebar' %> 32 | <% end %> 33 | 34 | <%= context_menu %> 35 | 36 |
37 | 38 | 39 | 40 | <%= javascript_tag do %> 41 | function loadDraggableSettings() { 42 | $(".issue-card").each(function() { 43 | var id = '#' + $(this).attr('id'); 44 | var movable_area = $(id).parent('td').attr('data-movable-area'); 45 | $(id).draggable({ 46 | containment: movable_area, 47 | snap: ".issue-card-receiver", 48 | snapMode: "inner", 49 | revert: function(ui) { 50 | if (ui==false) { return true }; 51 | // revert if card does not drop int the droppable area. 52 | if (ui.hasClass('issue-card-receiver')==false) { return true }; 53 | if ($(this).hasClass('drag-revert')) { 54 | $(this).removeClass('drag-revert'); 55 | return true; 56 | } 57 | }, 58 | opacity: 0.9, 59 | zIndex: 10, 60 | start: function() { $(this).css({ transform: 'rotate(10deg)', zIndex: 10 }); }, 61 | drag: function() { $('.hascontextmenu').removeClass('context-menu-selection cm-last');contextMenuHide();hideIssueDescription(); }, 62 | stop: function() { $(this).css({ transform: 'rotate(0deg)', zIndex: 10 }); } 63 | }); 64 | }); 65 | } 66 | function loadDroppableSetting() { 67 | $(".issue-card-receiver").droppable({ 68 | accept: ".issue-card", 69 | hoverClass: 'ui-droppable-hover', 70 | drop: function(event, ui) { 71 | if (ui.draggable.length > 0) { 72 | var org_status_id = ui.draggable.attr('data-status-id'); 73 | var org_group_value = ui.draggable.attr('data-group-value'); 74 | var new_status_id = $(this).attr('data-status-id'); 75 | var new_group_value = $(this).attr('data-group-value'); 76 | // revert if there is no change in status or group 77 | if (org_status_id==new_status_id && 78 | (typeof(new_group_value) === "undefiend" || org_group_value==new_group_value)) { 79 | return $(ui.draggable).addClass('drag-revert'); 80 | } else { 81 | $.ajax({ 82 | url: '<%= move_issue_card_path(:format => 'js') %>', 83 | type: 'put', 84 | data: { 85 | 'id': ui.draggable.attr('data-issue-id'), 86 | <%= @project ? ("'project_id': #{@project.id},").html_safe : '' %> 87 | 'status_id': $(this).attr('data-status-id'), 88 | 'group_key': $(this).attr('data-group-key'), 89 | 'group_value': $(this).attr('data-group-value') 90 | } 91 | }); 92 | } 93 | } 94 | } 95 | }); 96 | } 97 | function showIssueDescription(issue_element, description_element) { 98 | var issue_id = issue_element.attr('href').split('/').at(-1) 99 | var mouse_x = issue_element.offset().left; 100 | var mouse_y = issue_element.offset().top; 101 | 102 | description_element.css('left', (mouse_x + 'px')); 103 | description_element.css('top', (mouse_y + 'px')); 104 | description_element.html(''); 105 | 106 | $.ajax({ 107 | url: '<%= show_issue_description_path %>', 108 | data: { 'id': issue_id}, 109 | success: function(data) { 110 | if (data['description'] !== undefined && data['description'] != '') { 111 | description_element.html(data['description']); 112 | var description_width = description_element.width(); 113 | var description_height = description_element.height(); 114 | 115 | // modify position if the description element is out of window 116 | var render_x = mouse_x - 90; 117 | var render_y = mouse_y - description_height - 16; 118 | 119 | var window_width = window_size().width; 120 | if ((render_x + description_width) > window_width) { 121 | render_x = window_width - description_width - 18; 122 | } 123 | if (render_y < $(window).scrollTop()) { 124 | render_y = mouse_y + 18; 125 | } 126 | if (render_x <= 0) { render_x = 1; } 127 | if (render_y <= 0) { render_y = 1; } 128 | 129 | // show description element 130 | description_element.css('left', (render_x + 'px')); 131 | description_element.css('top', (render_y + 'px')); 132 | description_element.show(); 133 | } else { 134 | if (data['error_message'] !== undefined && data['error_message'] != '') { alert(data['error_message']); } 135 | } 136 | } 137 | }); 138 | } 139 | function hideIssueDescription() { 140 | $('#issue_panel_issue_description').html(''); 141 | $('#issue_panel_issue_description').hide(); 142 | } 143 | function loadCardFunctions(){ 144 | var timer; 145 | $('.issue-card .card-content .subject a').mouseover(function(event) { 146 | clearTimeout(timer); 147 | showIssueDescription($(this), $('#issue_panel_issue_description')); 148 | }); 149 | $('.issue-card .card-content .subject a').mouseout(function() { 150 | timer = setTimeout(function() { 151 | if (!$('#issue_panel_issue_description').is(':hover')) { 152 | hideIssueDescription(); 153 | } 154 | }, 400); 155 | }); 156 | $('#issue_panel_issue_description').mouseleave(function() { 157 | hideIssueDescription(); 158 | }); 159 | $('.link-issue').dblclick(function() { 160 | var issue_id = $(this).attr('data-issue-id'); 161 | window.location.href='<%= issues_path %>/' + issue_id; 162 | }); 163 | $('.add-issue-card').click(function(e) { 164 | e.preventDefault(); 165 | $.ajax({ 166 | dataType: "jsonp", 167 | url: $(this).data('url'), 168 | timeout: 10000, 169 | beforeSend: function(){ 170 | $('#ajax-indicator').show(); 171 | }, 172 | success: function(data){ 173 | $('#ajax-indicator').hide(); 174 | }, 175 | error: function(){ 176 | $('#ajax-indicator').hide(); 177 | } 178 | }); 179 | }); 180 | } 181 | $(document).ready(function(){ 182 | loadDraggableSettings(); 183 | loadDroppableSetting(); 184 | loadCardFunctions(); 185 | hideIssueDescription(); 186 | }); 187 | $(document).on("click", function(e) { 188 | if (!$(e.target).closest('#issue_panel_issue_description').length) { 189 | hideIssueDescription(); 190 | } 191 | }); 192 | $(document).on("contextmenu", function(e) { 193 | if (!$(e.target).closest('#issue_panel_issue_description').length) { 194 | hideIssueDescription(); 195 | } 196 | }); 197 | <% end %> 198 | -------------------------------------------------------------------------------- /app/views/issues_panel/move_issue_card.js.erb: -------------------------------------------------------------------------------- 1 | <% @issues_panel.view = self %> 2 | <% if @issue_card.saved_changes? %> 3 | /// remove issue card 4 | $('#issue-card-<%= @issue_card.id %>').remove(); 5 | // refresh status total 6 | $('#issues-count-on-status-<%= @issue_card.status_id_before_last_save %>').html('<%= @issues_panel.issues.select { |i| i.status_id == @issue_card.status_id_before_last_save }.count %>'); 7 | $('#issues-count-on-status-<%= @issue_card.status_id %>').html('<%= @issues_panel.issues.select { |i| i.status_id == @issue_card.status_id }.count %>'); 8 | // clear group total 9 | $('.issues-count-on-group').html('0'); 10 | <% @issues_panel.grouped_issues do |group_value, _, group_css, group_count, issues_in_group_by_status| %> 11 | // refresh group total 12 | $('#issues-count-on-group-<%= group_css %>').html('<%= group_count %>'); 13 | // refresh issue cards in status 14 | $('#issue-card-receiver-<%= group_css %>-<%= @issue_card.status_id %>').html('<%= @issues_panel.render_issue_cards(issues_in_group_by_status[@issue_card.status], @issue_card.status, group_value) %>'); 15 | <% end %> 16 | // restore background-color 17 | contextMenuAddSelection($('#issue-<%= @issue_card.id %>')); 18 | loadCardFunctions(); 19 | loadDraggableSettings(); 20 | <% else %> 21 | <% if flash[:error].present? %> 22 | alert('<%= raw(escape_javascript(flash[:error])) %>'); 23 | <% end %> 24 | // revart issue card 25 | $('#issue-card-<%= @issue_card.id %>').animate( {left: 0, top: 0}, 500 ); 26 | <% end %> 27 | -------------------------------------------------------------------------------- /app/views/issues_panel/new_issue_card.js.erb: -------------------------------------------------------------------------------- 1 | $('#new-issue-card-modal').html('<%= escape_javascript(render(:partial => 'issues_panel/modal_form')) %>'); 2 | showModal('new-issue-card-modal', '450px'); 3 | $('#new-issue-card-modal').addClass('new-issue-card'); 4 | -------------------------------------------------------------------------------- /assets/stylesheets/redmine_issues_panel.css: -------------------------------------------------------------------------------- 1 | table.issues-panel { 2 | border: 1px solid #e4e4e4; 3 | width: 100%; 4 | margin-bottom: 4px; 5 | border-radius: 3px; 6 | border-spacing: 0; 7 | overflow: hidden; 8 | table-layout: fixed; 9 | } 10 | 11 | table.issues-panel th { 12 | background-color:#EEEEEE; 13 | padding: 4px; 14 | white-space:nowrap; 15 | font-weight:bold; 16 | } 17 | 18 | table.issues-panel th span.badge-count { 19 | top: -1px; 20 | } 21 | 22 | table.issues-panel th a.add-issue-card { 23 | display: inline; 24 | position: relative; 25 | top: -2px; 26 | } 27 | 28 | table.issues-panel td { 29 | vertical-align: top; 30 | background-color: #fffffb; 31 | border: 1px solid #e4e4e4; 32 | text-align: left; 33 | } 34 | 35 | #issue_panel_issue_description { 36 | background-color: #ffffee; 37 | padding: 3px; 38 | position: absolute; 39 | z-index: 1000; 40 | width: 380px; 41 | border: 1px solid #ddd; 42 | border-radius: 3px; 43 | box-shadow: 0 0 1px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.2); 44 | display: none; 45 | } 46 | 47 | #issue_panel_issue_description .issue { 48 | background-color: #ffffee; 49 | border: none; 50 | padding: 3px; 51 | margin: 3px; 52 | } 53 | 54 | #issue_panel_issue_description .issue .subject { 55 | font-size: 0.8em; 56 | font-weight: bold; 57 | } 58 | 59 | #issue_panel_issue_description .issue .subject a { 60 | padding-left: 0px; 61 | margin-left: 0px; 62 | } 63 | 64 | #issue_panel_issue_description .issue .description .wiki * { 65 | font-size: 0.9em; 66 | font-weight: normal; 67 | border: none; 68 | margin: 2px; 69 | font-style: normal; 70 | } 71 | 72 | #issue_panel_issue_description .issue .description .wiki blockquote { 73 | padding-left: 0px; 74 | } 75 | 76 | #issue_panel_issue_description .issue .description .wiki ol, 77 | #issue_panel_issue_description .issue .description .wiki ul { 78 | padding-inline-start: 18px; 79 | font-size: 1em; 80 | } 81 | 82 | #issue_panel_issue_description .issue .description .wiki pre, 83 | #issue_panel_issue_description .issue .description .wiki pre code { 84 | padding: 0px; 85 | margin: 0px; 86 | background: transparent; 87 | white-space: pre-wrap; 88 | hyphens: auto; 89 | } 90 | 91 | #issue_panel_issue_description .issue .description .wiki pre code span { 92 | margin: 0px; 93 | color: inherit; 94 | background: transparent; 95 | } 96 | 97 | #issue_panel_issue_description .issue .description .wiki table, 98 | #issue_panel_issue_description .issue .description .wiki tr, 99 | #issue_panel_issue_description .issue .description .wiki td { 100 | padding: 2px; 101 | font-size: 1em; 102 | } 103 | 104 | #issue_panel_issue_description .issue .description .wiki p img, 105 | #issue_panel_issue_description .issue .description .wiki p img+br { 106 | display: none; 107 | } 108 | 109 | .issue-card-receiver { 110 | border:1px dashed #fff; 111 | padding: 15px 4px 2px 4px!important; 112 | } 113 | 114 | .ui-droppable-hover { 115 | border:2px dashed #aaa!important; 116 | } 117 | 118 | .issue-cards { 119 | display: -webkit-flex; 120 | display: -ms-flexbox; 121 | display: flex; 122 | -ms-flex-wrap: wrap; 123 | flex-wrap: wrap; 124 | -webkit-box-align: start; 125 | -ms-flex-align: start; 126 | align-items: flex-start; 127 | } 128 | 129 | .issue-card { 130 | border: 1px solid #ddd; 131 | color: #505050; 132 | border-radius: 3px; 133 | background-color: #ffffdd; 134 | word-break: break-all; 135 | vertical-align: top; 136 | } 137 | 138 | .issue-card-1-1 { 139 | margin: 2px; 140 | width: 98%; 141 | } 142 | 143 | .issue-card-1-2 { 144 | margin: 2px 1px; 145 | flex: 1 1 calc(100% / 2.2); 146 | } 147 | 148 | .issue-card-1-3 { 149 | margin: 2px 1px; 150 | flex: 1 1 calc(100% / 3.3); 151 | } 152 | 153 | .issue-cards>.add-issue-card { 154 | margin: 2px; 155 | width: 98%; 156 | text-align: center; 157 | } 158 | 159 | .issue-card>.contextual { 160 | opacity: 0.001; 161 | transition: opacity 0.2s; 162 | } 163 | 164 | .issue-card:hover>.contextual { 165 | opacity: 1; 166 | } 167 | 168 | .issue-card[class~=ui-sortable-helper] { 169 | transform: rotate(10deg); 170 | } 171 | 172 | .issue-card .overdue .due_date .value { 173 | color: #c22; 174 | } 175 | 176 | .issue-card div.priority-lowest { 177 | box-shadow: 4px 0px 0px 0px #81D4FA inset; 178 | } 179 | .issue-card div.priority-high3 { 180 | box-shadow: 4px 0px 0px 0px #EF9A9A inset; 181 | } 182 | .issue-card div.priority-high2 { 183 | box-shadow: 4px 0px 0px 0px #E53935 inset; 184 | } 185 | .issue-card div.priority-highest { 186 | box-shadow: 4px 0px 0px 0px #E53935 inset; 187 | } 188 | 189 | .issue-card .card-content { 190 | padding: 8px; 191 | text-align: left; 192 | font-size: 0.9em; 193 | display: block; 194 | } 195 | .issue-card .card-content a { 196 | background-color: transparent; 197 | border: none; 198 | } 199 | .issue-card .closed { 200 | background-color: #fffff0; 201 | } 202 | .card-content .caption { 203 | float: left; 204 | padding-right: 2px; 205 | } 206 | .card-content .header .js-contextmenu { 207 | float: right; 208 | } 209 | .card-content .footer { 210 | padding-top: 6px; 211 | } 212 | .card-content .footer a { 213 | float: right; 214 | } 215 | .card-content .footer a.show-issue-description { 216 | float: left; 217 | } 218 | th > .issue-cards > .add-issue-card { 219 | background-color: transparent; 220 | } 221 | td > .issue-cards > .add-issue-card { 222 | background-color: #fff; 223 | } 224 | td > .issue-cards > .add-issue-card:hover { 225 | cursor: pointer; 226 | } 227 | 228 | .issue-card-form { 229 | padding: 5px; 230 | } 231 | .issue-card-form > #all_attributes > .splitcontentleft { 232 | flex: none; 233 | } 234 | .issue-card-form > #all_attributes > .splitcontentleft > p { 235 | padding-left: 100px; 236 | } 237 | .issue-card-form > #all_attributes > .splitcontentleft > p#issue_is_private_wrap { 238 | padding: 0px; 239 | } 240 | .issue-card-form > #all_attributes > .splitcontentleft p > label { 241 | margin-left: -100px; 242 | margin-right: 10px; 243 | width: 100px; 244 | } 245 | .issue-card-form > #all_attributes > .splitcontentleft > p > select#issue_project_id,select#issue_tracker_id,select#issue_category_id,select#issue_fixed_version_id { 246 | max-width: 85%; 247 | } 248 | .issue-card-form > #all_attributes > .splitcontentleft a { 249 | display: inline-block; 250 | } 251 | 252 | body.controller-issues_panel .ui-widget-overlay+.ui-dialog+.ui-widget-overlay, 253 | body.controller-issues_panel .ui-widget-overlay+.ui-widget-overlay { 254 | display: none; 255 | } 256 | 257 | #ajax-indicator { 258 | z-index: 120; 259 | } 260 | 261 | @media screen and (max-width: 899px) { 262 | .issue-card-form > #all_attributes > .splitcontentleft > p { 263 | padding-left: 0px; 264 | } 265 | .issue-card-form > #all_attributes > .splitcontentleft > p > label { 266 | margin-left: 0px; 267 | width: 100%; 268 | } 269 | .issue-card-form > div#all_attributes > div.splitcontentleft > p > select#issue_project_id,select#issue_tracker_id { 270 | width: 100%; 271 | max-width: 100%; 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here for Rails i18n 2 | en: 3 | label_issues_panel_plural: "Issues Panel" 4 | notice_issues_panel_truncated: "The IssuesPanel was truncated because it exceeds the maximum number of items that can be displayed (%{max})" 5 | notice_not_authorized_to_change_this_issue: "You are not authorized to change this issue." 6 | notice_failed_to_move_issue: "Failed to move issue." 7 | label_assign_to_me: "Assign to me" 8 | field_issues_num_per_row: "Issues num per row" 9 | 10 | project_module_issues_panel: "Issues Panel" 11 | 12 | activerecord: 13 | errors: 14 | messages: 15 | can_t_be_changed: "cannot be changed." 16 | -------------------------------------------------------------------------------- /config/locales/ja.yml: -------------------------------------------------------------------------------- 1 | # Japanese strings go here for Rails i18n 2 | ja: 3 | label_issues_panel_plural: "チケットパネル" 4 | notice_issues_panel_truncated: "チケットパネルは、最大表示件数(%{max})を超えたため切り捨てられました。" 5 | notice_not_authorized_to_change_this_issue: "このチケットの変更は許可されていません。" 6 | notice_failed_to_move_issue: "チケットの移動に失敗しました。" 7 | label_assign_to_me: "自分に割り当て" 8 | field_issues_num_per_row: "一行ごとのチケット数" 9 | 10 | project_module_issues_panel: "チケットパネル" 11 | 12 | activerecord: 13 | errors: 14 | messages: 15 | can_t_be_changed: "は変更できません。" 16 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # Plugin's routes 2 | # See: http://guides.rubyonrails.org/routing.html 3 | get '/issues_panel', :to => 'issues_panel#index', :as => 'issues_panel' 4 | get '/projects/:project_id/issues_panel', :to => 'issues_panel#index', :as => 'project_issues_panel' 5 | get '/show_issue_description', :to => 'issues_panel#show_issue_description', :as => 'show_issue_description' 6 | put '/move_issue_card', :to => 'issues_panel#move_issue_card', :as => 'move_issue_card' 7 | match '/new_issue_card', :to => 'issues_panel#new_issue_card', :as => 'new_issue_card', :via => [:get, :post] 8 | -------------------------------------------------------------------------------- /images/how_to_activate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redmica/redmine_issues_panel/c3085b8839ba00c90919e783aa77b1eab24cba9e/images/how_to_activate.png -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../lib/redmine_issues_panel/queries_controller_patch', __FILE__) 2 | require File.expand_path('../lib/redmine_issues_panel/view_hook', __FILE__) 3 | require File.expand_path('../lib/redmine_issues_panel/issue_query_patch', __FILE__) 4 | 5 | Redmine::Plugin.register :redmine_issues_panel do 6 | name 'Redmine Issues Panel plugin' 7 | author 'Takenori Takaki (Far End Technologies)' 8 | description 'A plugin for Redmine to display issues by statuses and change it\'s status by DnD.' 9 | requires_redmine version_or_higher: '6.0' 10 | version '1.1.2' 11 | url 'https://github.com/redmica/redmine_issues_panel' 12 | author_url 'https://hosting.redmine.jp/' 13 | 14 | # permission setting 15 | project_module :issues_panel do 16 | permission :use_issues_panel, { :issues_panel => [:index, :move_issue_card, :new_issue_card] }, :public => true, :require => :member 17 | end 18 | 19 | # menu setting 20 | menu :project_menu, :issues_panel, { :controller => 'issues_panel', :action => 'index' }, :caption => :label_issues_panel_plural, :after => :issues, :param => :project_id 21 | menu :application_menu, :issues_panel, { :controller => 'issues_panel', :action => 'index' }, :caption => :label_issues_panel_plural, :after => :issues, :if => proc { User.current.allowed_to?(:view_issues, nil, :global => true) && EnabledModule.exists?(:project => Project.visible, :name => :issues_panel) } 22 | end 23 | -------------------------------------------------------------------------------- /lib/redmine/helpers/issues_panel.rb: -------------------------------------------------------------------------------- 1 | module Redmine 2 | module Helpers 3 | # Simple class to handle isses panel data 4 | class IssuesPanel 5 | include ERB::Util 6 | include Rails.application.routes.url_helpers 7 | include Redmine::I18n 8 | include IssuesPanelHelper 9 | include ActionView::Helpers::UrlHelper 10 | 11 | attr_reader :truncated, :issues_limit 12 | attr_accessor :query, :view 13 | 14 | def initialize(options={}) 15 | options = options.dup 16 | if options.has_key?(:issues_limit) 17 | @issues_limit = options[:issues_limit] 18 | else 19 | @issues_limit = Setting.gantt_items_limit.blank? ? nil : Setting.gantt_items_limit.to_i 20 | end 21 | @truncated = false 22 | end 23 | 24 | def query=(query) 25 | @query = query 26 | query.available_columns.delete_if { |c| c.name == :tracker } 27 | @truncated = @query.issue_count.to_i > @issues_limit.to_i 28 | end 29 | 30 | def panel_statuses 31 | # IssueStatus.where(:id => @query.issues.pluck(:status_id).uniq) 32 | if @query.project 33 | statuses = @query.project.rolled_up_statuses 34 | else 35 | statuses = IssueStatus.all.sorted 36 | end 37 | if filterd_trackers = @query.filters["tracker_id"] 38 | operator = filterd_trackers[:operator].to_s 39 | values = filterd_trackers[:values].reject(&:blank?) 40 | status_ids = case operator 41 | when "=" 42 | Tracker.where(id: values) 43 | when "!" 44 | Tracker.where.not(id: values) 45 | else 46 | [] 47 | end.map { |t| t.issue_status_ids }.flatten.uniq 48 | # Filter statuses if statuses can be retrieved from the workflow configured in the tracker. 49 | if status_ids.any? 50 | statuses = statuses.where(id: status_ids) 51 | end 52 | end 53 | if filterd_statuses = @query.filters["status_id"] 54 | operator = filterd_statuses[:operator].to_s 55 | values = filterd_statuses[:values].reject(&:blank?) 56 | case operator 57 | when "o" 58 | statuses = statuses.where(:is_closed => false) 59 | when "c" 60 | statuses = statuses.where(:is_closed => true) 61 | when "=" 62 | statuses = statuses.select{|status| values.include?(status.id.to_s) } 63 | when "!" 64 | statuses = statuses.select{|status| values.exclude?(status.id.to_s) } 65 | #when "*" 66 | # nothing to change statuses 67 | end 68 | end 69 | statuses 70 | end 71 | 72 | def issues 73 | @query.issues(:limit => @issues_limit) 74 | end 75 | 76 | def grouped? 77 | @query && @query.grouped? && @query.group_by_column.name != :status 78 | end 79 | 80 | def grouped_issues 81 | if self.grouped? 82 | self.issues.group_by { |issue| query.group_by_column.group_value(issue) } 83 | else 84 | {nil => self.issues} 85 | end.each do |group, issues_in_group| 86 | if group.nil? 87 | group_value = '' 88 | group_label = "(#{l(:label_blank_value)})" 89 | else 90 | if @query.group_by_column.instance_of?(QueryColumn) && 91 | [:project, :tracker, :status, :priority, :assigned_to, :category, :fixed_version, :author].include?(@query.group_by_column.name) 92 | group_value = group.try(:id).to_s 93 | elsif @query.group_by_column.instance_of?(QueryCustomFieldColumn) && 94 | [:user, :version, :enumeration].include?(@query.group_by_column.custom_field.field_format.to_sym) 95 | group_value = group.try(:id).to_s 96 | else 97 | group_value = group.to_s 98 | end 99 | group_label = view.format_object(group) 100 | end 101 | group_css = group_value.gsub(' ', '-') 102 | yield group_value, group_label, group_css, issues_in_group.count, issues_in_group.group_by { |issue| issue.status } 103 | end 104 | end 105 | 106 | def move_params(group_value, status) 107 | params = { :status_id => status.id } 108 | if self.grouped? 109 | if @query.group_by_column.instance_of?(QueryColumn) 110 | case @query.group_by_column.name 111 | when :project, :tracker, :status, :priority, :assigned_to, :category, :fixed_version 112 | params.merge!({ :group_key => "#{@query.group_by}_id", :group_value => group_value }) 113 | when :author 114 | # can't move between groups (because author can't change). 115 | else 116 | params.merge!({ :group_key => @query.group_by, :group_value => group_value }) 117 | end 118 | elsif @query.group_by_column.instance_of?(QueryCustomFieldColumn) 119 | params.merge!({ :group_key => :custom_field_values, :group_value => "#{@query.group_by_column.custom_field.id},#{group_value}" }) 120 | else # eg: TimestampQueryColumn 121 | # can't move between groups (because created_at, updated_on and closed_on can't change). 122 | end 123 | 124 | if @query.group_by_column.instance_of?(TimestampQueryColumn) || @query.group_by_column.name == :author 125 | # can't move to other groups 126 | params.merge!({ :movable_area => ".issue-card-receivers-#{group_value}" }) 127 | else 128 | # enable to move other groups 129 | params.merge!({ :movable_area => ".issue-panel" }) 130 | end 131 | else 132 | # enable to move other groups 133 | params.merge!({ :movable_area => ".issue-panel" }) 134 | end 135 | params 136 | end 137 | 138 | 139 | def render_column_content(column, issue) 140 | return '' if column.name == :id || column.name == :status || column == @query.group_by_column 141 | caption = "#{column.caption}: " 142 | value = +'' 143 | case column.name 144 | when :author 145 | value = view.avatar(issue.author, :size => "13") + " " + view.link_to_user(issue.author) 146 | when :assigned_to, :last_updated_by 147 | user = issue.send(column.name) 148 | value = user ? view.avatar(user, :size => "13") + " " + view.link_to_user(user) : "-" 149 | when :due_date 150 | value = view.issue_due_date_details(issue) || '' 151 | else 152 | value = view.column_content(column, issue) || '' 153 | end 154 | view.content_tag('div', 155 | view.content_tag('div', caption.html_safe, :class => "caption").html_safe + 156 | view.content_tag('div', value.html_safe, :class => "value").html_safe, 157 | :class => "#{column.css_classes} clear" 158 | ).html_safe 159 | end 160 | 161 | def render_card_content(issue) 162 | issue = issue.becomes(IssueCard) 163 | view.content_tag('div', 164 | view.content_tag('div', 165 | view.content_tag('input', nil, :type => 'checkbox', :name => 'ids[]', :value => issue.id, :style => 'display:none;', :class => 'toggle-selection').html_safe + 166 | view.content_tag('div', 167 | view.link_to_context_menu.html_safe + 168 | view.link_to("#{issue.tracker} ##{issue.id}", issue_url(issue, :only_path => true), :class => issue.css_classes).html_safe, 169 | :class => 'header clear' 170 | ).html_safe + 171 | @query.inline_columns.collect do |column| 172 | render_column_content(column, issue) 173 | end.join.html_safe + 174 | view.content_tag('div', 175 | view.watcher_link(issue.becomes(Issue), User.current), 176 | :class => 'footer clear').html_safe, 177 | :class => "card-content"), 178 | :id => "issue-#{issue.id}", 179 | :class => "hascontextmenu #{issue.priority.try(:css_classes)} #{issue.overdue? ? 'overdue' : ''} #{issue.closed? ? 'closed' : ''}" 180 | ).html_safe 181 | end 182 | 183 | def render_issue_cards(issues_in_status, status, group_value) 184 | issue_cards = +'' 185 | (issues_in_status || []).each do |issue| 186 | issue_cards << view.content_tag('div', 187 | render_card_content(issue), 188 | :class => "issue-card issue-card-1-#{@query.issues_num_per_row} link-issue", 189 | :id => "issue-card-#{issue.id}", 190 | :data => { :issue_id => issue.id, :status_id => status.id, :group_value => group_value } 191 | ) 192 | end 193 | if issue = Issue.new(:project => @query.project) 194 | issue.project ||= issue.allowed_target_projects.first 195 | issue.tracker ||= issue.allowed_target_trackers.first 196 | if issue.new_statuses_allowed_to(User.current).include?(status) 197 | new_issue_params = {:status_id => status.id} 198 | #new_issue_params[:"#{@query.group_by}_id"] = group_value if @query.grouped? 199 | if self.grouped? 200 | if @query.group_by_column.instance_of?(QueryColumn) 201 | case @query.group_by_column.name 202 | when :project 203 | if group_value.present? 204 | project = Project.find(group_value) rescue nil 205 | # 当該プロジェクトのチケット一覧画面で「新しいチケット」のリンクを表示する基準 206 | if User.current.allowed_to?(:add_issues, project, :global => true) && (project.nil? || Issue.allowed_target_trackers(project).any?) 207 | new_issue_params.merge!({ :"#{@query.group_by}_id" => group_value }) 208 | else 209 | # do not display + button 210 | new_issue_params = {} 211 | end 212 | else 213 | # do not display + button 214 | new_issue_params = {} 215 | end 216 | when :fixed_version 217 | new_issue_params.merge!({ :"#{@query.group_by}_id" => group_value }) 218 | if group_value.present? 219 | version = Version.find(group_value) 220 | # バージョン詳細画面(views/versions/show.html.erb)で「新しいチケット」のリンクを表示する基準 221 | # enable link to new issue? 222 | if version.open? && User.current.allowed_to?(:add_issues, version.project) 223 | new_issue_params.merge!({ :project_id => version.project_id }) 224 | else 225 | # do not display + button 226 | new_issue_params = {} 227 | end 228 | end 229 | when :tracker, :status, :priority, :assigned_to, :category 230 | new_issue_params.merge!({ :"#{@query.group_by}_id" => group_value }) 231 | when :is_private 232 | new_issue_params.merge!({ :"#{@query.group_by}" => group_value }) 233 | else 234 | # author, created_on, updated_on, closed_on, start_date, due_date, done_ratio 235 | # do not display + button 236 | new_issue_params = {} 237 | end 238 | elsif @query.group_by_column.instance_of?(QueryCustomFieldColumn) 239 | # do not display + button 240 | new_issue_params = {} 241 | else 242 | # do not display + button 243 | new_issue_params = {} 244 | end 245 | end 246 | if new_issue_params.any? 247 | issue_cards << view.content_tag('div', 248 | view.link_to(view.sprite_icon('add', l(:label_issue_new)), '', :class => 'icon icon-add new-issue'), 249 | :class => "issue-card add-issue-card", 250 | :data => { :url => view.new_issue_card_path({ :project_id => @query.project.try(:id), :issue => new_issue_params, :back_url => _project_issues_panel_path(@query.project) }) } 251 | ) 252 | end 253 | end 254 | end 255 | view.content_tag('div', issue_cards.html_safe, :class => 'issue-cards').html_safe 256 | end 257 | 258 | def render_issues_panel 259 | statuses = self.panel_statuses 260 | 261 | # issue data for add issue card link 262 | new_issue = Issue.new(:project => @query.project) 263 | new_issue.project ||= new_issue.allowed_target_projects.first 264 | new_issue.tracker ||= new_issue.allowed_target_trackers.first 265 | 266 | # panel header 267 | thead = +'' 268 | thead << view.content_tag('thead', 269 | view.content_tag('tr', 270 | statuses.collect {|s| 271 | view.content_tag('th', 272 | s.to_s.html_safe + 273 | view.content_tag('span', 274 | issues_count = self.issues.select { |issue| issue.status_id == s.id }.count, 275 | :id => "issues-count-on-status-#{s.id}", 276 | :class => 'badge badge-count count').html_safe + 277 | (new_issue && new_issue.new_statuses_allowed_to(User.current).include?(s) ? 278 | view.link_to(view.sprite_icon('add', ''), '', 279 | :class => 'icon-only icon-add new-issue add-issue-card', 280 | :data => { :url => view.new_issue_card_path({ :project_id => @query.project.try(:id), :issue => { :status_id => s.id }, :back_url => _project_issues_panel_path(@query.project) }) } 281 | ) : '').html_safe 282 | )}.join.html_safe 283 | ) 284 | ) 285 | 286 | tbody = +'' 287 | self.grouped_issues do |group_value, group_label, group_css, group_count, issues_in_group_by_status| 288 | next if issues_in_group_by_status.nil? 289 | 290 | # group label 291 | if self.grouped? 292 | tbody << view.content_tag('tr', 293 | view.content_tag('td', 294 | view.content_tag('span', view.sprite_icon("angle-down"), :class => 'expander icon icon-expanded', :onclick => 'toggleRowGroup(this);').html_safe + 295 | view.content_tag('span', group_label, :class => 'name').html_safe + 296 | view.content_tag('span', group_count, :class => 'badge badge-count count issues-count-on-group', :id => "issues-count-on-group-#{group_css}").html_safe, 297 | :colspan => statuses.count 298 | ), 299 | :class => 'group open' 300 | ).html_safe 301 | end 302 | 303 | # status lanes (in group) 304 | td_tags = +'' 305 | column_names = @query.inline_columns.collect{ |c| c.name } 306 | statuses.each do |status| 307 | td_tags << view.content_tag('td', 308 | render_issue_cards(issues_in_group_by_status[status], status, group_value), 309 | :class => "issue-card-receiver", 310 | :id => "issue-card-receiver-#{group_css}-#{status.id}", 311 | :data => move_params(group_value, status) 312 | ).html_safe 313 | end 314 | 315 | tbody << view.content_tag('tr', td_tags.html_safe, :class => "issue-card-receivers-#{group_css}") 316 | end 317 | 318 | view.content_tag('table', thead.html_safe + tbody.html_safe, :id => 'issues_panel', :class => 'issues-panel list issues').html_safe 319 | end 320 | end 321 | end 322 | end 323 | -------------------------------------------------------------------------------- /lib/redmine_issues_panel/issue_query_patch.rb: -------------------------------------------------------------------------------- 1 | require 'issue_query' 2 | 3 | module RedmineIssuesPanel 4 | module IssueQueryPatch 5 | def self.included(base) 6 | base.send(:prepend, InstanceMethods) 7 | base.class_eval do 8 | #unloadable 9 | end 10 | end 11 | 12 | module InstanceMethods 13 | def build_from_params(params, defaults={}) 14 | super 15 | self.issues_num_per_row = 16 | params[:issues_num_per_row] || 17 | (params[:query] && params[:query][:issues_num_per_row]) || 18 | options[:issues_num_per_row] 19 | self 20 | end 21 | 22 | def issues_num_per_row 23 | r = options[:issues_num_per_row] 24 | r.to_i == 0 ? 1 : r.to_i 25 | end 26 | 27 | def issues_num_per_row=(arg) 28 | options[:issues_num_per_row] = arg ? arg.to_i : 1 29 | end 30 | end 31 | end 32 | end 33 | 34 | IssueQuery.include RedmineIssuesPanel::IssueQueryPatch 35 | -------------------------------------------------------------------------------- /lib/redmine_issues_panel/queries_controller_patch.rb: -------------------------------------------------------------------------------- 1 | require 'queries_controller' 2 | 3 | module RedmineIssuesPanel 4 | module QueriesControllerPatch 5 | def self.included(base) 6 | #base.send(:include, InstanceMethods) 7 | base.send(:prepend, InstanceMethods) 8 | base.class_eval do 9 | #unloadable 10 | end 11 | end 12 | 13 | module InstanceMethods 14 | def redirect_to_issue_query(options) 15 | if params[:issues_panel] 16 | if @project 17 | redirect_to project_issues_panel_path(@project, options) 18 | else 19 | redirect_to issues_panel_path(options) 20 | end 21 | else 22 | super 23 | end 24 | end 25 | end 26 | end 27 | end 28 | 29 | QueriesController.send(:include, RedmineIssuesPanel::QueriesControllerPatch) unless QueriesController.included_modules.include? RedmineIssuesPanel::QueriesControllerPatch 30 | -------------------------------------------------------------------------------- /lib/redmine_issues_panel/view_hook.rb: -------------------------------------------------------------------------------- 1 | module RedmineIssuesPanel 2 | class ViewHook < Redmine::Hook::ViewListener 3 | def view_layouts_base_html_head(context={}) 4 | query = context[:controller].instance_variable_get(:'@query') 5 | html = +'' 6 | if (context[:controller] && context[:controller].is_a?(QueriesController)) && 7 | (context[:request] && context[:request].try(:params).is_a?(Hash) && context[:request].params['issues_panel']) 8 | js = <<~JS 9 | $(document).ready(function(){ 10 | 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)}'; 11 | $('form#query-form').append('#{hidden_field_tag('issues_panel', '1')}'); 12 | $('p.block_columns').remove(); 13 | $('p.totable_columns').remove(); 14 | $('p#group_by').after('

' + selector + '

'); 15 | }); 16 | JS 17 | html << javascript_tag(js) 18 | end 19 | return html 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/fixtures/queries.yml: -------------------------------------------------------------------------------- 1 | --- 2 | queries_100: 3 | id: 100 4 | type: IssueQuery 5 | project_id: 1 6 | visibility: 2 7 | name: Issues Panel Query 8 | filters: | 9 | --- 10 | status_id: 11 | :values: 12 | - "1" 13 | - "2" 14 | - "5" 15 | :operator: "=" 16 | 17 | user_id: 1 18 | column_names: 19 | -------------------------------------------------------------------------------- /test/functional/issues_panel_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class IssuesPanelControllerTest < ActionController::TestCase 4 | def setup 5 | @request.session[:user_id] = 2 6 | end 7 | 8 | def test_index_with_out_query 9 | get :index 10 | assert_response :success 11 | assert_query_form 12 | end 13 | 14 | def test_index_should_forbidden_if_module_disabled 15 | # Enabled Issues Panel 16 | @project = Project.find(1) 17 | @project.enabled_modules = [] #<< EnabledModule.new(name: 'issues_panel') 18 | @project.save! 19 | 20 | get :index, :params => { 21 | :project_id => 1 22 | } 23 | assert_response :forbidden 24 | end 25 | 26 | def test_index_with_issues_panel_query 27 | # Enabled Issues Panel 28 | @project = Project.find(1) 29 | @project.enabled_modules << EnabledModule.new(name: 'issues_panel') 30 | @project.save! 31 | 32 | get :index, :params => { 33 | :project_id => 1, 34 | :query_id => 100 35 | } 36 | assert_response :success 37 | 38 | # query form 39 | assert_query_form 40 | 41 | issue_ids_on_status_1 = [1, 3, 5, 6, 7, 9, 10, 13, 14] 42 | issue_ids_on_status_2 = [2] 43 | issue_ids_on_status_5 = [8, 11, 12] 44 | 45 | # issues panel 46 | assert_select 'table#issues_panel.issues-panel' do 47 | assert_select 'thead' do 48 | assert_select 'tr' do 49 | assert_select 'th', "New#{issue_ids_on_status_1.count}" 50 | assert_select 'th', "Assigned#{issue_ids_on_status_2.count}" 51 | assert_select 'th', "Closed#{issue_ids_on_status_5.count}" 52 | end 53 | end 54 | assert_select 'tr' do 55 | assert_select 'td.issue-card-receiver[data-status-id=1]' do 56 | assert_issue_cards(issue_ids_on_status_1) 57 | end 58 | assert_select 'td.issue-card-receiver[data-status-id=2]' do 59 | assert_issue_cards(issue_ids_on_status_2) 60 | end 61 | assert_select 'td.issue-card-receiver[data-status-id=5]' do 62 | assert_issue_cards(issue_ids_on_status_5) 63 | end 64 | end 65 | end 66 | end 67 | 68 | def assert_query_form 69 | # query form 70 | assert_select 'form#query_form' do 71 | assert_select 'div#query_form_with_buttons.hide-when-print' do 72 | assert_select 'div#query_form_content' do 73 | assert_select 'fieldset#filters.collapsible' 74 | assert_select 'fieldset#options' 75 | end 76 | assert_select 'p.buttons' 77 | end 78 | end 79 | end 80 | 81 | def assert_issue_cards(issue_ids=[]) 82 | issue_ids.each do |issue_id| 83 | assert_select "div#issue-card-#{issue_id}.issue-card[data-issue-id=#{issue_id}]" 84 | end 85 | end 86 | 87 | def test_move_issue_card 88 | put :move_issue_card, :xhr => true, :params => { 89 | :id => 1, :status_id => 5 90 | } 91 | assert_response :success 92 | assert_match "$('#issue-card-1').remove()", response.body 93 | assert_match "$('#issues-count-on-status-1').html('0')", response.body 94 | assert_match "$('#issues-count-on-status-5').html('4')", response.body 95 | assert_match "$('.issues-count-on-group').html('0');", response.body 96 | assert_match "$('#issues-count-on-group-').html('4');", response.body 97 | assert_match "loadDraggableSettings();", response.body 98 | end 99 | 100 | def test_move_issue_card_but_record_not_found 101 | put :move_issue_card, :xhr => true, :params => { 102 | :id => 99999, :status_id => 2 103 | } 104 | assert_response :success 105 | assert_match "alert('#{I18n.t(:error_issue_not_found_in_project)}')", response.body 106 | assert_match "('#issue-card-').animate( {left: 0, top: 0}, 500 );", response.body 107 | end 108 | 109 | def test_move_issue_card_but_unauthorized 110 | IssueCard.any_instance.stubs(:visible?).returns(false) 111 | put :move_issue_card, :xhr => true, :params => { 112 | :id => 1, :status_id => 2 113 | } 114 | assert_response :success 115 | assert_match "alert('#{I18n.t(:notice_not_authorized_to_change_this_issue)}')", response.body 116 | assert_match "('#issue-card-1').animate( {left: 0, top: 0}, 500 );", response.body 117 | end 118 | 119 | def test_move_issue_card_but_exception_raised 120 | error_message_on_move = 'error message on move' 121 | IssueCard.any_instance.stubs(:move!).raises(error_message_on_move) 122 | put :move_issue_card, :xhr => true, :params => { 123 | :id => 1, :status_id => 2 124 | } 125 | assert_response :success 126 | assert_match "alert('#{error_message_on_move}')", response.body 127 | assert_match "('#issue-card-1').animate( {left: 0, top: 0}, 500 );", response.body 128 | end 129 | 130 | def assert_modal_issue_card() 131 | assert_match "showModal('new-issue-card-modal', '450px');", response.body 132 | assert_match "$('#new-issue-card-modal').addClass('new-issue-card');", response.body 133 | end 134 | 135 | def test_new_issue_card 136 | get :new_issue_card, :xhr => true, :params => { 137 | :status_id => 5 138 | } 139 | assert_response :success 140 | assert_modal_issue_card 141 | end 142 | 143 | def test_new_issue_card_method_post 144 | post :new_issue_card, :xhr => true, :params => { 145 | :status_id => 5 146 | } 147 | assert_response :success 148 | assert_modal_issue_card 149 | end 150 | 151 | def test_show_description 152 | issue = Issue.generate!(:description => 'Issue Description', :author => User.current) 153 | get :show_issue_description, :xhr => true, :params => { 154 | :id => issue.id 155 | } 156 | assert_response :success 157 | assert_equal 'application/json', response.media_type 158 | data = ActiveSupport::JSON.decode(response.body) 159 | assert_select(Nokogiri::HTML(data['description']), "div.issue") do 160 | assert_select 'div.subject', "##{issue.id}: #{issue.subject}" 161 | assert_select 'div.description' do 162 | assert_select 'div.wiki', issue.description 163 | end 164 | end 165 | end 166 | 167 | def test_show_description_but_record_not_found 168 | put :show_issue_description, :xhr => true, :params => { 169 | :id => 99999 170 | } 171 | assert_response :success 172 | assert_equal 'application/json', response.media_type 173 | data = ActiveSupport::JSON.decode(response.body) 174 | assert_equal I18n.t(:error_issue_not_found_in_project), data['error_message'] 175 | end 176 | 177 | def test_show_issue_description_but_unauthorized 178 | IssueCard.any_instance.stubs(:visible?).returns(false) 179 | put :show_issue_description, :xhr => true, :params => { 180 | :id => 1 181 | } 182 | 183 | assert_response :success 184 | assert_equal 'application/json', response.media_type 185 | data = ActiveSupport::JSON.decode(response.body) 186 | assert_equal I18n.t(:notice_not_authorized_to_change_this_issue), data['error_message'] 187 | end 188 | 189 | def test_show_issue_description_when_description_is_nil 190 | issue = Issue.generate!(:description => nil, :author => User.current) 191 | get :show_issue_description, :xhr => true, :params => { 192 | :id => issue.id 193 | } 194 | assert_response :success 195 | assert_equal 'application/json', response.media_type 196 | data = ActiveSupport::JSON.decode(response.body) 197 | assert_select(Nokogiri::HTML(data['description']), "div.issue") do 198 | assert_select 'div.subject', "##{issue.id}: #{issue.subject}" 199 | assert_select 'div.description' do 200 | assert_select 'div.wiki', '' 201 | end 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Load the Redmine helper 2 | require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper') 3 | ActiveSupport::TestCase.fixture_paths << File.dirname(__FILE__) + '/fixtures' 4 | 5 | class ActiveSupport::TestCase 6 | # This is not necessary because `fixtures :all` is already used in Redmine trunk, 7 | # but it is set here because RedMica has not yet followed that change. 8 | # If RedMica follows Redmine trunk in the future, this setting will not be necessary. 9 | fixtures :all 10 | end 11 | -------------------------------------------------------------------------------- /test/unit/issue_card_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class IssueCardTest < ActiveSupport::TestCase 4 | def setup 5 | User.current = User.find(1) 6 | end 7 | 8 | def test_move_status 9 | issue_card = IssueCard.find(1) 10 | assert_equal 1, issue_card.status_id 11 | 12 | issue_card.move!({ :status_id => 2 }) 13 | assert_equal 2, issue_card.status_id 14 | end 15 | 16 | def test_move_relation_group 17 | issue_card = IssueCard.find(1) 18 | 19 | assert_equal 1, issue_card.tracker_id 20 | issue_card.move!({ :group_key => 'tracker_id', :group_value => 2 }) 21 | assert_equal 2, issue_card.tracker_id 22 | 23 | assert_equal 1, issue_card.project_id 24 | issue_card.move!({ :group_key => 'project_id', :group_value => 2 }) 25 | assert_equal 2, issue_card.project_id 26 | 27 | assert_equal 4, issue_card.category_id 28 | issue_card.move!({ :group_key => 'category_id', :group_value => 3 }) 29 | assert_equal 3, issue_card.category_id 30 | 31 | assert_nil issue_card.assigned_to_id 32 | issue_card.move!({ :group_key => 'assigned_to_id', :group_value => 2 }) 33 | assert_equal 2, issue_card.assigned_to_id 34 | 35 | assert_equal 4, issue_card.priority_id 36 | issue_card.move!({ :group_key => 'priority_id', :group_value => 7 }) 37 | assert_equal 7, issue_card.priority_id 38 | 39 | assert_nil issue_card.fixed_version_id 40 | issue_card.move!({ :group_key => 'fixed_version_id', :group_value => 5 }) 41 | assert_equal 5, issue_card.fixed_version_id 42 | end 43 | 44 | def test_move_custom_field_group 45 | issue_card = IssueCard.find(1) 46 | field = IssueCustomField.find_by_name('Database') 47 | assert issue_card.available_custom_fields.include?(field) 48 | assert_nil issue_card.custom_field_value(field.id) 49 | 50 | issue_card.move!({ :group_key => 'custom_field_values', :group_value => '1,PostgreSQL'}) 51 | assert_equal 'PostgreSQL', issue_card.custom_field_value(field.id) 52 | end 53 | 54 | def test_css_classes_include_icon_checked 55 | closed_issue_card = IssueCard.find(8) 56 | closed_classes = closed_issue_card.css_classes.split(' ') 57 | assert_include 'icon', closed_classes 58 | assert_include 'icon-checked', closed_classes 59 | 60 | issue_card = IssueCard.find(1) 61 | classes = issue_card.css_classes.split(' ') 62 | assert_not_include 'icon', classes 63 | assert_not_include 'icon-checked', classes 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/unit/issue_query_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class IssueQueryTest < ActiveSupport::TestCase 4 | def test_build_from_params_should_set_issues_num_per_row 5 | q = IssueQuery.create!(:name => 'issue panel', :options => {}) 6 | 7 | q.build_from_params({ :issues_num_per_row => 2 }) 8 | assert_equal 2, q.options[:issues_num_per_row] 9 | 10 | q.build_from_params({ :query => { :issues_num_per_row => 3 } }) 11 | assert_equal 3, q.options[:issues_num_per_row] 12 | end 13 | 14 | def test_issues_num_per_row_should_set_options_value 15 | q = IssueQuery.create!(:name => 'issue panel', :options => {}) 16 | q.issues_num_per_row = 2 17 | assert_equal 2, q.options[:issues_num_per_row] 18 | end 19 | 20 | def test_issues_num_per_row_should_return_default_value 21 | q = IssueQuery.create!(:name => 'issue panel', :options => {}) 22 | assert_equal 1, q.issues_num_per_row 23 | end 24 | 25 | def test_issues_num_per_row_should_return_options_value 26 | q = IssueQuery.create!(:name => 'issue panel', :options => {:issues_num_per_row => 3}) 27 | assert_equal 3, q.issues_num_per_row 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/unit/lib/redmine/helpers/issues_panel_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../../../test_helper', __FILE__) 2 | 3 | class Redmine::Helpers::IssuesPanelHelperTest < Redmine::HelperTest 4 | include ERB::Util 5 | include Rails.application.routes.url_helpers 6 | include Redmine::I18n 7 | 8 | def setup 9 | User.current = User.find(1) 10 | end 11 | 12 | def test_initialize 13 | with_settings :gantt_items_limit => 500 do 14 | issues_panel = Redmine::Helpers::IssuesPanel.new 15 | assert_equal 500, issues_panel.issues_limit 16 | assert_equal false, issues_panel.truncated 17 | end 18 | end 19 | 20 | def test_initialize_with_issues_limit 21 | with_settings :gantt_items_limit => 500 do 22 | issues_panel = Redmine::Helpers::IssuesPanel.new({:issues_limit => 100}) 23 | assert_equal 100, issues_panel.issues_limit 24 | assert_equal false, issues_panel.truncated 25 | end 26 | end 27 | 28 | def test_set_issue_query 29 | issues_panel = Redmine::Helpers::IssuesPanel.new 30 | issue_query = IssueQuery.new 31 | issues_panel.query = issue_query 32 | assert_equal issue_query, issues_panel.query 33 | end 34 | 35 | def test_update_trancated 36 | issues_limit = 100 37 | with_settings :gantt_items_limit => issues_limit do 38 | issues_panel = Redmine::Helpers::IssuesPanel.new({:issues_limit => 100}) 39 | query = IssueQuery.new 40 | 41 | query.stubs(:issue_count).returns(issues_limit - 1) 42 | issues_panel.query = query 43 | assert_equal false, issues_panel.truncated 44 | 45 | query.stubs(:issue_count).returns(issues_limit) 46 | issues_panel.query = query 47 | assert_equal false, issues_panel.truncated 48 | 49 | query.stubs(:issue_count).returns(issues_limit + 1) 50 | issues_panel.query = query 51 | assert_equal true, issues_panel.truncated 52 | end 53 | end 54 | 55 | def test_panel_statuses_with_project 56 | issues_panel = Redmine::Helpers::IssuesPanel.new() 57 | query = IssueQuery.new() 58 | project = Project.find(1) 59 | query.project = project 60 | issues_panel.query = query 61 | 62 | assert_equal project.rolled_up_statuses.where(:is_closed => false), issues_panel.panel_statuses 63 | end 64 | 65 | def test_panel_statuses_without_project 66 | issues_panel = Redmine::Helpers::IssuesPanel.new() 67 | query = IssueQuery.new() 68 | issues_panel.query = query 69 | 70 | assert_equal IssueStatus.all.sorted.where(:is_closed => false), issues_panel.panel_statuses 71 | end 72 | 73 | def test_panel_statuses_with_include_tracker_filter 74 | WorkflowTransition.where(:tracker_id => [1, 2]).delete_all 75 | WorkflowTransition.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 0, :new_status_id => 1) 76 | WorkflowTransition.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 0, :new_status_id => 5) 77 | issues_panel = Redmine::Helpers::IssuesPanel.new() 78 | query = IssueQuery.new() 79 | query.filters = {'tracker_id' => {:operator => "=", :values => [1, 2]}} 80 | issues_panel.query = query 81 | 82 | assert_equal IssueStatus.all.sorted.where(id: [0, 1, 5]), issues_panel.panel_statuses 83 | end 84 | 85 | def test_panel_statuses_with_exclude_tracker_filter 86 | WorkflowTransition.where(:tracker_id => 3).delete_all 87 | WorkflowTransition.create!(:role_id => 1, :tracker_id => 3, :old_status_id => 1, :new_status_id => 2) 88 | issues_panel = Redmine::Helpers::IssuesPanel.new() 89 | query = IssueQuery.new() 90 | query.filters = {'tracker_id' => {:operator => "!", :values => [1, 2]}} 91 | issues_panel.query = query 92 | 93 | assert_equal IssueStatus.all.sorted.where(:id => [1, 2]), issues_panel.panel_statuses 94 | end 95 | 96 | def test_issues 97 | issues_limit = 3 98 | issues_panel = Redmine::Helpers::IssuesPanel.new({:issues_limit => issues_limit}) 99 | query = IssueQuery.new(:filters => { :status_id => {:operator => "*", :values => [""] } } ) 100 | 101 | issues_panel.query = query 102 | assert_equal issues_limit, issues_panel.issues.count 103 | end 104 | end 105 | --------------------------------------------------------------------------------