diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 2a42c99ed0..a7bda382ce 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -175,7 +175,7 @@ class ProjectsController < ApplicationController respond_to do |format| format.html do - @principals_by_role = @project.principals_by_role + @roles_with_active_members = @project.roles_with_active_members @subprojects = @project.leaf? ? [] : @project.children.visible.to_a @news = @project.news.limit(5).includes(:author, :project).reorder("#{News.table_name}.created_on DESC").to_a with_subprojects = Setting.display_subprojects_issues? @@ -197,6 +197,18 @@ class ProjectsController < ApplicationController end end + # Lazily renders the members of a role that were omitted from the overview's members box (#88737). + def show_all_members + @role = @project.roles_with_active_members.detect {|role| role.id == params[:role_id].to_i} + unless @role + render_404 + return + end + + @principals, _more = @project.principals_for_role(@role, :offset => params[:offset].to_i, :limit => nil) + render :layout => false + end + def settings @issue_custom_fields = IssueCustomField.sorted.to_a @issue_category ||= IssueCategory.new diff --git a/app/javascript/controllers/project_members_box_expand_controller.js b/app/javascript/controllers/project_members_box_expand_controller.js new file mode 100644 index 0000000000..fb3f1c427e --- /dev/null +++ b/app/javascript/controllers/project_members_box_expand_controller.js @@ -0,0 +1,30 @@ +import { Controller } from '@hotwired/stimulus' +import { get } from '@rails/request.js' + +// Connects to data-controller="project-members-box-expand" +export default class extends Controller { + static targets = ['trigger'] + + async showAll(event) { + event.preventDefault() + + // Ignore clicks while a request is in flight, so the members can't be inserted twice. + const trigger = this.triggerTarget + if (trigger.dataset.loading) { + return + } + trigger.dataset.loading = 'true' + + try { + const response = await get(event.currentTarget.href, { responseKind: 'html' }) + if (!response.ok) { + return + } + + trigger.insertAdjacentHTML('beforebegin', await response.html) + trigger.remove() + } finally { + delete trigger.dataset.loading + } + } +} diff --git a/app/models/project.rb b/app/models/project.rb index d21999d579..816a44a889 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -30,6 +30,9 @@ class Project < ApplicationRecord # Maximum length for project identifiers IDENTIFIER_MAX_LENGTH = 100 + # Number of principals initially listed per role in the overview page's members box + MEMBERS_BOX_PRINCIPALS_PER_ROLE = 50 unless const_defined?(:MEMBERS_BOX_PRINCIPALS_PER_ROLE) + has_many :memberships, :class_name => 'Member', :inverse_of => :project # Memberships of active users only has_many :members, @@ -565,6 +568,7 @@ class Project < ApplicationRecord end # Returns a hash of project users/groups grouped by role + # Kept for backward compatibility; no longer used by Redmine core. def principals_by_role memberships.active.includes(:principal, :roles).inject({}) do |h, m| m.roles.each do |r| @@ -575,6 +579,30 @@ class Project < ApplicationRecord end end + # Returns the roles that have at least one active member, without loading any principal. + def roles_with_active_members + Role.where(:id => memberships.active.joins(:roles).select("#{Role.table_name}.id")).sorted + end + + # Returns [principals, more]: a page of the project's active members having the + # given role. A nil limit returns everything from the offset on, and more is false. + def principals_for_role(role, offset: 0, limit: MEMBERS_BOX_PRINCIPALS_PER_ROLE) + offset = 0 if offset < 0 + # A subquery avoids DISTINCT + ORDER BY on non-selected columns, which PostgreSQL rejects. + scope = Principal.where( + :id => memberships.active.joins(:roles).where(:roles => {:id => role.id}).select(:user_id) + ).sorted.offset(offset) + if limit + principals = scope.limit(limit + 1).to_a + more = principals.size > limit + principals.pop if more + else + principals = scope.to_a + more = false + end + [principals, more] + end + # Adds user as a project member with the default role # Used for when a non-admin user creates a project def add_default_member(user) diff --git a/app/views/projects/_members_box.html.erb b/app/views/projects/_members_box.html.erb index 94a037c25d..88c7cb08fa 100644 --- a/app/views/projects/_members_box.html.erb +++ b/app/views/projects/_members_box.html.erb @@ -1,8 +1,8 @@ - <% if @principals_by_role.any? %> + <% if @roles_with_active_members.any? %>
<%= role %>: <%= @principals_by_role[role].sort.collect{|p| link_to_principal(p, :class => p.is_a?(Group) ? 'icon icon-group' : nil)}.join(", ").html_safe %>
+ <% @roles_with_active_members.each do |role| %> + <%= render :partial => 'members_box_role', :locals => {:role => role} %> <% end %>+ <%= role %>: + <%= render :partial => 'members_box_principals', :locals => {:principals => principals} %><% if more %> <%= link_to(sprite_icon('angle-right', l(:label_show_all), rtl: true), show_all_members_project_path(@project, :role_id => role.id, :offset => principals.size), :class => 'icon icon-angle-right', :data => {:action => 'click->project-members-box-expand#showAll'}) %><% end %> +
diff --git a/app/views/projects/show_all_members.html.erb b/app/views/projects/show_all_members.html.erb new file mode 100644 index 0000000000..437c38c1eb --- /dev/null +++ b/app/views/projects/show_all_members.html.erb @@ -0,0 +1 @@ +<% if @principals.any? %>, <%= render :partial => 'members_box_principals', :locals => {:principals => @principals} %><% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index bdbad19a0c..2ae928610a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -792,6 +792,7 @@ en: label_nobody: nobody label_next: Next label_previous: Previous + label_show_all: Show all label_used_by: Used by label_details: Details label_add_note: Add a note diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 5ec0fa6c6d..6dafc0ec3a 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -602,6 +602,7 @@ ja: label_nobody: 無記名 label_next: 次 label_previous: 前 + label_show_all: すべて表示 label_used_by: 使用中 label_details: 詳細 label_add_note: コメントを追加 diff --git a/config/routes.rb b/config/routes.rb index ce15da4205..3f4679daf8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -154,6 +154,7 @@ Rails.application.routes.draw do match 'reopen', :via => [:post, :put] match 'copy', :via => [:get, :post] match 'bookmark', :via => [:delete, :post] + get 'show_all_members' end shallow do diff --git a/lib/redmine/preparation.rb b/lib/redmine/preparation.rb index fdd7133033..34229053b0 100644 --- a/lib/redmine/preparation.rb +++ b/lib/redmine/preparation.rb @@ -34,7 +34,7 @@ module Redmine # Permissions AccessControl.map do |map| - map.permission :view_project, {:projects => [:show, :bookmark], :activities => [:index]}, :public => true, :read => true + map.permission :view_project, {:projects => [:show, :bookmark, :show_all_members], :activities => [:index]}, :public => true, :read => true map.permission :search_project, {:search => :index}, :public => true, :read => true map.permission :add_project, {:projects => [:new, :create]}, :require => :loggedin map.permission :edit_project, {:projects => [:settings, :edit, :update]}, :require => :member diff --git a/test/functional/projects_controller_test.rb b/test/functional/projects_controller_test.rb index d3ed25a2fd..3ad9bcbb29 100644 --- a/test/functional/projects_controller_test.rb +++ b/test/functional/projects_controller_test.rb @@ -906,6 +906,101 @@ class ProjectsControllerTest < Redmine::ControllerTest end end + def test_show_should_display_members_box_grouped_by_role + get(:show, :params => {:id => 'ecookbook'}) + assert_response :success + assert_select 'div.members.box' do + assert_select 'p.member-role', :count => 2 + assert_select 'p.member-role:nth-of-type(1) span.label', :text => 'Manager:' + assert_select 'p.member-role:nth-of-type(1) a', :text => 'John Smith' + assert_select 'p.member-role:nth-of-type(2) span.label', :text => 'Developer:' + end + end + + def test_show_should_not_display_locked_users_in_members_box + get(:show, :params => {:id => 'ecookbook'}) + assert_response :success + # User 5 is a locked member with the Developer role (see test/fixtures/members.yml) + assert_select 'div.members.box a', :text => User.find(5).name, :count => 0 + end + + def test_show_should_not_display_more_link_when_role_has_few_members + project = Project.find(1) + role = Role.find(1) # Manager, already has 1 active member (John Smith) + 5.times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])} + + get(:show, :params => {:id => 'ecookbook'}) + assert_response :success + assert_select 'div.members.box p.member-role:nth-of-type(1)' do + assert_select 'a[href^=?]', '/users/', :count => 6 + assert_select 'span.show-all-members-trigger', :count => 0 + end + end + + def test_show_should_limit_members_box_principals_per_role_and_display_more_link + project = Project.find(1) + role = Role.find(1) + (Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE + 5).times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])} + + get(:show, :params => {:id => 'ecookbook'}) + assert_response :success + assert_select 'div.members.box p.member-role:nth-of-type(1)' do + assert_select 'a[href^=?]', '/users/', :count => Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE + assert_select 'span.show-all-members-trigger a[data-action=?][href*=?]', + 'click->project-members-box-expand#showAll', "offset=#{Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE}", + :text => 'Show all' + end + end + + def test_show_all_members_should_return_every_member_from_the_given_offset + project = Project.find(1) + role = Role.find(1) + 5.times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])} + + get(:show_all_members, :params => {:id => 'ecookbook', :role_id => role.id, :offset => 1}, :xhr => true) + assert_response :success + assert_select 'a[href^=?]', '/users/', :count => 5 + assert_select 'span.show-all-members-trigger', :count => 0 + end + + def test_show_all_members_should_return_every_remaining_member_without_a_further_trigger + project = Project.find(1) + role = Role.find(1) + (Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE + 5).times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])} + + get( + :show_all_members, + :params => {:id => 'ecookbook', :role_id => role.id, :offset => Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE}, + :xhr => true + ) + assert_response :success + assert_select 'a[href^=?]', '/users/', :count => 6 + assert_select 'span.show-all-members-trigger', :count => 0 + end + + def test_show_all_members_should_not_include_locked_users + get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 2, :offset => 0}, :xhr => true) + assert_response :success + assert_select 'a', :text => User.find(5).name, :count => 0 + end + + def test_show_all_members_should_not_render_a_stray_comma_when_nothing_remains + # Role 1 (Manager) on project 1 has only 1 active member, so offset 999 lands past the end. + get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 1, :offset => 999}, :xhr => true) + assert_response :success + assert_equal '', response.body.strip + end + + def test_show_all_members_should_respond_with_not_found_for_a_role_without_active_members + get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 999}, :xhr => true) + assert_response :not_found + end + + def test_show_all_members_should_not_require_login_for_a_public_project + get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 1}, :xhr => true) + assert_response :success + end + def test_settings @request.session[:user_id] = 2 # manager get(:settings, :params => {:id => 1}) diff --git a/test/unit/project_test.rb b/test/unit/project_test.rb index 83582ccdbb..ef27ec0110 100644 --- a/test/unit/project_test.rb +++ b/test/unit/project_test.rb @@ -480,6 +480,101 @@ class ProjectTest < ActiveSupport::TestCase assert_not principals_by_role.values.flatten.include?(locked_user) end + def test_roles_with_active_members + # Project 1 has active members with role 1 (Manager) and role 2 (Developer) + assert_equal [Role.find(1), Role.find(2)], Project.find(1).roles_with_active_members.to_a + end + + def test_roles_with_active_members_should_not_include_roles_without_members + project = Project.generate! + assert_equal [], project.roles_with_active_members.to_a + end + + def test_roles_with_active_members_should_not_include_roles_with_only_locked_members + project = Project.find(1) + role = Role.find(2) + # Role 2 (Developer) only has active member 3 besides the locked member 5 + Member.find_by(:project_id => 1, :user_id => 3).destroy + assert_not project.reload.roles_with_active_members.to_a.include?(role) + end + + def test_principals_for_role + principals, more = Project.find(1).principals_for_role(Role.find(1)) + assert_kind_of Array, principals + assert principals.include?(User.find(2)) + assert_not more + end + + def test_principals_for_role_should_only_return_active_users + # Role 2 (Developer) on project 1 has active member 3 and locked member 5 + principals, _more = Project.find(1).principals_for_role(Role.find(2)) + assert_equal [User.find(3)], principals + end + + def test_principals_for_role_should_include_groups + group = Group.find(10) + Member.create!(:principal => group, :project_id => 1, :role_ids => [1]) + + principals, _more = Project.find(1).principals_for_role(Role.find(1)) + assert principals.include?(group) + end + + def test_principals_for_role_should_not_duplicate_a_user_with_an_inherited_role_matching_a_direct_role + project = Project.find(1) + role = Role.find(1) + user = User.generate! + User.add_to_project(user, project, role) + group = Group.generate! + group.users << user + # Also grants role via the group: the user now has 2 member_roles for the + # same role (one direct, one inherited, see MemberRole#add_role_to_group_users) + User.add_to_project(group, project, role) + + principals, _more = project.principals_for_role(role, :limit => 100) + assert_equal 1, principals.count {|p| p == user} + end + + def test_principals_for_role_should_paginate + project = Project.find(1) + role = Role.find(1) + 5.times {|i| Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])} + + principals, more = project.principals_for_role(role, :limit => 2) + assert_equal 2, principals.size + assert more + + more_principals, more = project.principals_for_role(role, :offset => 2, :limit => 2) + assert_equal 2, more_principals.size + assert more + assert_empty principals & more_principals + + rest, more = project.principals_for_role(role, :offset => 4, :limit => 2) + assert_equal 2, rest.size + assert_not more + end + + def test_principals_for_role_should_treat_a_negative_offset_as_zero + project = Project.find(1) + role = Role.find(1) + + assert_equal project.principals_for_role(role), project.principals_for_role(role, :offset => -5) + end + + def test_principals_for_role_with_a_nil_limit_should_return_every_remaining_member + project = Project.find(1) + role = Role.find(1) + 5.times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])} + + first_page, more = project.principals_for_role(role, :limit => 2) + assert_equal 2, first_page.size + assert more + + rest, more = project.principals_for_role(role, :offset => first_page.size, :limit => nil) + assert_equal 4, rest.size + assert_not more + assert_empty first_page & rest + end + def test_rolled_up_trackers parent = Project.find(1) parent.trackers = Tracker.find([1, 2])