From e8d1f295501fe94fbb09a8171e7a27c3878912a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20B=C4=82LTEANU?= Date: Thu, 17 Sep 2026 01:02:01 +0300 Subject: [PATCH 1/3] Show the first members of each role with avatars and an overflow badge in the project overview (#44601). --- app/assets/stylesheets/application.css | 3 ++ app/controllers/projects_controller.rb | 2 +- app/helpers/application_helper.rb | 10 +++- app/helpers/projects_helper.rb | 17 ++++++ app/models/project.rb | 22 ++++++++ app/views/projects/_members_box.html.erb | 10 ++-- config/locales/en.yml | 3 ++ test/functional/projects_controller_test.rb | 42 +++++++++++++++ test/helpers/application_helper_test.rb | 15 ++++++ test/unit/project_test.rb | 60 +++++++++++++++++++++ 10 files changed, 179 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 302cdba14..99b8553f9 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -795,6 +795,9 @@ body.controller-gantts fieldset#options > div > div { td.center {text-align:center;} +.members.box .avatar {margin-block: 0 2px; margin-inline: 0 4px;} +.members.box .badge {inset-block-end: 0;} +.members.box a.badge-count, .members.box a.badge-count:hover {color: var(--oc-white); text-decoration: none;} #watchers select {inline-size: 95%; display: block;} #watchers .avatar {margin-block: 0 2px; margin-inline: 0 4px;} #watchers svg.icon-svg {margin-block: 0 2px; margin-inline: 0 2px;} diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 2a42c99ed..b95d1e883 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_principals = @project.roles_with_principals @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? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ea533c776..4c5f1c35a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -76,7 +76,15 @@ module ApplicationHelper end css_classes += " #{options[:class]}" if css_classes && options[:class].present? - url ? link_to(principal_icon(principal).to_s + name, url, :class => css_classes) : h(name) + return h(name) unless url + + icon = + if options[:avatar] + avatar(principal, :size => (options[:avatar] == true ? 16 : options[:avatar])) + else + principal_icon(principal) + end + link_to(safe_join([icon, name].compact), url, :class => css_classes) end def link_to_mention(user, object, options={}) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 9ec6a76fa..5b2acdda0 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -18,6 +18,23 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. module ProjectsHelper + # Returns a badge with the number of members of the role that are not listed + # in the project members box (ie. beyond +displayed_count+ out of + # +total_count+), linked to the members settings tab filtered on that role + # for the users allowed to manage the members of the project. Returns nil if + # every member of the role is already displayed. + def members_not_displayed_badge(project, role, total_count, displayed_count) + remaining_count = total_count - displayed_count + return if remaining_count <= 0 + + options = {:class => 'badge badge-count', :title => l(:label_x_more_members, :count => remaining_count)} + if User.current.allowed_to?(:manage_members, project) + link_to("+#{remaining_count}", settings_project_path(project, :tab => 'members', :member_role_id => role.id), options) + else + content_tag('span', "+#{remaining_count}", options) + end + end + def project_settings_tabs tabs = [ diff --git a/app/models/project.rb b/app/models/project.rb index ebeefa53e..a648fd79f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -590,6 +590,28 @@ class Project < ApplicationRecord end end + # Returns an array of [Role, principals, count] for the active members of the + # project, ordered by role position. Only the first +limit+ principals are + # returned for each role, count being the total number of members with the role. + def roles_with_principals(limit: 10) + # One row per member and role: enough to count the members of each role and + # to pick the first ones of each, without loading every member of the project + role_and_user_ids = + memberships.active.joins(:member_roles). + reorder(Principal.fields_for_order_statement). + pluck("#{MemberRole.table_name}.role_id", "#{Member.table_name}.user_id").uniq + return [] if role_and_user_ids.empty? + + user_ids_by_role_id = role_and_user_ids.group_by(&:first).transform_values {|rows| rows.map(&:last)} + principals = + Principal.where(:id => user_ids_by_role_id.values.flat_map {|ids| ids.first(limit)}).index_by(&:id) + + Role.where(:id => user_ids_by_role_id.keys).sorted.map do |role| + user_ids = user_ids_by_role_id[role.id] + [role, user_ids.first(limit).filter_map {|user_id| principals[user_id]}, user_ids.size] + end + 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 b6a388114..3a63840a3 100644 --- a/app/views/projects/_members_box.html.erb +++ b/app/views/projects/_members_box.html.erb @@ -1,8 +1,12 @@ - <% if @principals_by_role.any? %> + <% if @roles_with_principals.any? %>

<%= sprite_icon('group', l(:label_member_plural)) %>

- <% @principals_by_role.keys.sort.each do |role| %> -

<%= 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_principals.each do |role, principals, count| %> +

+ <%= role %>: + <%= safe_join(principals.collect {|principal| link_to_principal(principal, :avatar => true)}, ', ') %> + <%= members_not_displayed_badge(@project, role, count, principals.size) %> +

<% end %> <% if User.current.allowed_to?(:view_members, @project) %>

<%= link_to l(:label_member_view_all), project_members_path(@project) %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index 77e7de2db..914973ace 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -675,6 +675,9 @@ en: label_member_new: New member label_member_plural: Members label_member_view_all: View all members + label_x_more_members: + one: 1 more member + other: "%{count} more members" label_tracker: Tracker label_tracker_plural: Trackers label_tracker_all: All trackers diff --git a/test/functional/projects_controller_test.rb b/test/functional/projects_controller_test.rb index 1f38350dc..c44e5edb3 100644 --- a/test/functional/projects_controller_test.rb +++ b/test/functional/projects_controller_test.rb @@ -802,6 +802,48 @@ class ProjectsControllerTest < Redmine::ControllerTest assert_select '#main.nosidebar' end + def test_show_should_display_members_box_with_avatars + @request.session[:user_id] = 2 + get(:show, :params => {:id => 'ecookbook'}) + assert_response :success + + assert_select 'div.members.box' do + assert_select 'p span.label', :text => 'Manager:' + assert_select 'p a.user[href=?]', '/users/2' do + assert_select '.avatar' + end + end + end + + def test_show_should_link_the_number_of_members_not_displayed_of_a_role + # 12 active members with the role, 10 of them displayed + 11.times {User.add_to_project(User.generate!, Project.find(1), Role.find(2))} + @request.session[:user_id] = 2 + get(:show, :params => {:id => 'ecookbook'}) + assert_response :success + + assert_select 'div.members.box' do + assert_select 'p a.user', :count => 10 + 1 + assert_select( + 'p a.badge[href=?][title=?]', + '/projects/ecookbook/settings/members?member_role_id=2', '2 more members', + :text => '+2' + ) + end + end + + def test_show_should_not_link_the_number_of_members_not_displayed_without_manage_members_permission + 11.times {User.add_to_project(User.generate!, Project.find(1), Role.find(2))} + @request.session[:user_id] = 7 + get(:show, :params => {:id => 'ecookbook'}) + assert_response :success + + assert_select 'div.members.box' do + assert_select 'p span.badge[title=?]', '2 more members', :text => '+2' + assert_select 'p a[href*=?]', 'settings/members', :count => 0 + end + end + def test_show_should_display_visible_custom_fields ProjectCustomField.find_by_name('Development status').update_attribute :visible, true get(:show, :params => {:id => 'ecookbook'}) diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index ca60d176f..ea64592b2 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -1808,6 +1808,21 @@ class ApplicationHelperTest < Redmine::HelperTest assert_equal result, link_to_principal(user) end + def test_link_to_principal_with_avatar_should_replace_the_icon_with_an_avatar + html = link_to_principal(User.find(2), :avatar => true) + assert_select_in html, 'a.user.active[href=?]', '/users/2' do + assert_select '.avatar' + assert_select 'svg', :count => 0 + end + end + + def test_link_to_principal_with_avatar_size + html = link_to_principal(Group.find(10), :avatar => 24) + assert_select_in html, 'a.group[href=?]', '/groups/10' do + assert_select 'img.avatar[width=?]', '24' + end + end + def test_link_to_principal_should_link_to_group group = Group.find(10) result = %r{A Team} diff --git a/test/unit/project_test.rb b/test/unit/project_test.rb index 83582ccdb..9fc4f3c30 100644 --- a/test/unit/project_test.rb +++ b/test/unit/project_test.rb @@ -480,6 +480,66 @@ class ProjectTest < ActiveSupport::TestCase assert_not principals_by_role.values.flatten.include?(locked_user) end + def test_roles_with_principals + group = Group.find(10) + Member.create!(:principal => group, :project_id => 1, :role_ids => [1]) + + roles_with_principals = Project.find(1).roles_with_principals + assert_kind_of Array, roles_with_principals + + role, principals, count = roles_with_principals.detect {|r, _, _| r == Role.find(1)} + assert_equal Role.find(1), role + # The group, and the users it gives the role to, are members with the role too + assert_equal 3, count + assert_include User.find(2), principals + assert_include group, principals + # Groups are listed after the users + assert_equal group, principals.last + end + + def test_roles_with_principals_should_be_ordered_by_role_position + roles = Project.find(1).roles_with_principals.map(&:first) + assert_equal roles.sort_by(&:position), roles + end + + def test_roles_with_principals_should_only_return_active_principals + locked_user = User.find(5) + assert_include locked_user, Project.find(1).memberships.map(&:principal) + + roles_with_principals = Project.find(1).roles_with_principals + assert_not_include locked_user, roles_with_principals.flat_map {|_, principals, _| principals} + end + + def test_roles_with_principals_should_limit_the_principals_but_not_the_count + project = Project.find(1) + role = Role.find(2) + 11.times {User.add_to_project(User.generate!, project, role)} + + _, principals, count = project.roles_with_principals.detect {|r, _, _| r == role} + assert_equal 10, principals.size + assert_equal 12, count + end + + def test_roles_with_principals_should_list_a_member_under_each_of_its_roles + member = Member.find(1) + member.update!(:role_ids => [1, 2]) + + roles_with_principals = Project.find(1).roles_with_principals + [1, 2].each do |role_id| + _, principals, = roles_with_principals.detect {|r, _, _| r == Role.find(role_id)} + assert_include member.principal, principals + end + end + + def test_roles_with_principals_should_not_include_the_members_of_other_projects + principals = Project.find(1).roles_with_principals.flat_map {|_, p, _| p} + assert_not_include User.find(8), principals + end + + def test_roles_with_principals_should_return_an_empty_array_without_members + assert_equal [], Project.generate!.roles_with_principals + end + def test_rolled_up_trackers parent = Project.find(1) parent.trackers = Tracker.find([1, 2]) -- 2.54.0 (Apple Git-157)