Project

General

Profile

Defect #44350 » members-box-show-all-v3.patch

Takenori TAKAKI, 2026-09-14 13:53

View differences:

app/controllers/projects_controller.rb
175 175

  
176 176
    respond_to do |format|
177 177
      format.html do
178
        @principals_by_role = @project.principals_by_role
178
        @roles_with_active_members = @project.roles_with_active_members.load
179 179
        @subprojects = @project.leaf? ? [] : @project.children.visible.to_a
180 180
        @news = @project.news.limit(5).includes(:author, :project).reorder("#{News.table_name}.created_on DESC").to_a
181 181
        with_subprojects = Setting.display_subprojects_issues?
......
197 197
    end
198 198
  end
199 199

  
200
  def show_all_members
201
    @role = @project.roles_with_active_members.detect {|role| role.id == params[:role_id].to_i}
202
    unless @role
203
      render_404
204
      return
205
    end
206

  
207
    @principals, _more = @project.principals_for_role(@role, :offset => params[:offset].to_i, :limit => nil)
208
    render :layout => false
209
  end
210

  
200 211
  def settings
201 212
    @issue_custom_fields = IssueCustomField.sorted.to_a
202 213
    @issue_category ||= IssueCategory.new
app/javascript/controllers/project_members_box_expand_controller.js
1
import { Controller } from '@hotwired/stimulus'
2
import { get } from '@rails/request.js'
3

  
4
// Connects to data-controller="project-members-box-expand"
5
export default class extends Controller {
6
  static targets = ['trigger']
7

  
8
  async showAll(event) {
9
    event.preventDefault()
10

  
11
    // Ignore clicks while a request is in flight, so the members can't be inserted twice.
12
    const trigger = this.triggerTarget
13
    if (trigger.dataset.loading) {
14
      return
15
    }
16
    trigger.dataset.loading = 'true'
17

  
18
    try {
19
      const response = await get(event.currentTarget.href, { responseKind: 'html' })
20
      if (!response.ok) {
21
        return
22
      }
23

  
24
      trigger.insertAdjacentHTML('beforebegin', await response.html)
25
      trigger.remove()
26
    } finally {
27
      delete trigger.dataset.loading
28
    }
29
  }
30
}
app/models/project.rb
30 30
  # Maximum length for project identifiers
31 31
  IDENTIFIER_MAX_LENGTH = 100
32 32

  
33
  # Number of principals initially listed per role in the overview page's members box
34
  MEMBERS_BOX_PRINCIPALS_PER_ROLE = 50 unless const_defined?(:MEMBERS_BOX_PRINCIPALS_PER_ROLE)
35

  
33 36
  has_many :memberships, :class_name => 'Member', :inverse_of => :project
34 37
  # Memberships of active users only
35 38
  has_many :members,
......
565 568
  end
566 569

  
567 570
  # Returns a hash of project users/groups grouped by role
571
  # Kept for backward compatibility; no longer used by Redmine core.
568 572
  def principals_by_role
569 573
    memberships.active.includes(:principal, :roles).inject({}) do |h, m|
570 574
      m.roles.each do |r|
......
575 579
    end
576 580
  end
577 581

  
582
  # Returns the roles that have at least one active member, without loading any principal.
583
  def roles_with_active_members
584
    Role.where(:id => memberships.active.joins(:roles).select("#{Role.table_name}.id")).sorted
585
  end
586

  
587
  # Returns [principals, more]: a page of the project's active members having the
588
  # given role. A nil limit returns everything from the offset on, and more is false.
589
  def principals_for_role(role, offset: 0, limit: MEMBERS_BOX_PRINCIPALS_PER_ROLE)
590
    offset = 0 if offset < 0
591
    # A subquery avoids DISTINCT + ORDER BY on non-selected columns, which PostgreSQL rejects.
592
    scope = Principal.where(
593
      :id => memberships.active.joins(:roles).where(:roles => {:id => role.id}).select(:user_id)
594
    ).sorted.offset(offset)
595
    if limit
596
      principals = scope.limit(limit + 1).to_a
597
      more = principals.size > limit
598
      principals.pop if more
599
    else
600
      principals = scope.to_a
601
      more = false
602
    end
603
    [principals, more]
604
  end
605

  
578 606
  # Adds user as a project member with the default role
579 607
  # Used for when a non-admin user creates a project
580 608
  def add_default_member(user)
app/views/projects/_members_box.html.erb
1
  <% if @principals_by_role.any? %>
1
  <% if @roles_with_active_members.any? %>
2 2
  <div class="members box">
3 3
    <h3 class="icon icon-group"><%= sprite_icon('group', l(:label_member_plural)) %></h3>
4
    <% @principals_by_role.keys.sort.each do |role| %>
5
      <p><span class="label"><%= role %>:</span> <%= @principals_by_role[role].sort.collect{|p| link_to_principal(p, :class => p.is_a?(Group) ? 'icon icon-group' : nil)}.join(", ").html_safe %></p>
4
    <% @roles_with_active_members.each do |role| %>
5
      <%= render :partial => 'members_box_role', :locals => {:role => role} %>
6 6
    <% end %>
7 7
  </div>
8 8
  <% end %>
app/views/projects/_members_box_principals.html.erb
1
<%= principals.collect {|p| link_to_principal(p, :class => p.is_a?(Group) ? 'icon icon-group' : nil)}.join(", ").html_safe %>
app/views/projects/_members_box_role.html.erb
1
<%
2
  principals, more = @project.principals_for_role(role)
3
%>
4
<p class="member-role" data-controller="project-members-box-expand">
5
  <span class="label"><%= role %>:</span>
6
  <%= render :partial => 'members_box_principals', :locals => {:principals => principals} %><% if more %><span class="show-all-members-trigger" data-project-members-box-expand-target="trigger"> <%= 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'}) %></span><% end %>
7
</p>
app/views/projects/show_all_members.html.erb
1
<% if @principals.any? %>, <%= render :partial => 'members_box_principals', :locals => {:principals => @principals} %><% end %>
config/locales/en.yml
792 792
  label_nobody: nobody
793 793
  label_next: Next
794 794
  label_previous: Previous
795
  label_show_all: Show all
795 796
  label_used_by: Used by
796 797
  label_details: Details
797 798
  label_add_note: Add a note
config/locales/ja.yml
602 602
  label_nobody: 無記名
603 603
  label_next: 次
604 604
  label_previous: 前
605
  label_show_all: すべて表示
605 606
  label_used_by: 使用中
606 607
  label_details: 詳細
607 608
  label_add_note: コメントを追加
config/routes.rb
154 154
      match 'reopen', :via => [:post, :put]
155 155
      match 'copy', :via => [:get, :post]
156 156
      match 'bookmark', :via => [:delete, :post]
157
      get 'show_all_members'
157 158
    end
158 159

  
159 160
    shallow do
lib/redmine/preparation.rb
34 34

  
35 35
      # Permissions
36 36
      AccessControl.map do |map|
37
        map.permission :view_project, {:projects => [:show, :bookmark], :activities => [:index]}, :public => true, :read => true
37
        map.permission :view_project, {:projects => [:show, :bookmark, :show_all_members], :activities => [:index]}, :public => true, :read => true
38 38
        map.permission :search_project, {:search => :index}, :public => true, :read => true
39 39
        map.permission :add_project, {:projects => [:new, :create]}, :require => :loggedin
40 40
        map.permission :edit_project, {:projects => [:settings, :edit, :update]}, :require => :member
test/functional/projects_controller_test.rb
906 906
    end
907 907
  end
908 908

  
909
  def test_show_should_display_members_box_grouped_by_role
910
    get(:show, :params => {:id => 'ecookbook'})
911
    assert_response :success
912
    assert_select 'div.members.box' do
913
      assert_select 'p.member-role', :count => 2
914
      assert_select 'p.member-role:nth-of-type(1) span.label', :text => 'Manager:'
915
      assert_select 'p.member-role:nth-of-type(1) a', :text => 'John Smith'
916
      assert_select 'p.member-role:nth-of-type(2) span.label', :text => 'Developer:'
917
    end
918
  end
919

  
920
  def test_show_should_load_roles_with_active_members_to_avoid_a_duplicate_query
921
    role_queries = []
922
    subscriber = lambda do |*, payload|
923
      # Matches only #roles_with_active_members's self-referential subquery,
924
      # not other queries in the request that also touch the roles table.
925
      role_queries << payload[:sql] if payload[:sql].match?(/"roles"\."id"\s+IN\s+\(SELECT\s+"roles"\."id"/i)
926
    end
927
    ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
928
      get(:show, :params => {:id => 'ecookbook'})
929
    end
930
    assert_response :success
931
    # An unloaded relation would run this query twice: once for #any?, once for #each.
932
    assert_equal 1, role_queries.size, role_queries.join("\n")
933
  end
934

  
935
  def test_show_should_not_display_locked_users_in_members_box
936
    get(:show, :params => {:id => 'ecookbook'})
937
    assert_response :success
938
    # User 5 is a locked member with the Developer role (see test/fixtures/members.yml)
939
    assert_select 'div.members.box a', :text => User.find(5).name, :count => 0
940
  end
941

  
942
  def test_show_should_not_display_more_link_when_role_has_few_members
943
    project = Project.find(1)
944
    role = Role.find(1) # Manager, already has 1 active member (John Smith)
945
    5.times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])}
946

  
947
    get(:show, :params => {:id => 'ecookbook'})
948
    assert_response :success
949
    assert_select 'div.members.box p.member-role:nth-of-type(1)' do
950
      assert_select 'a[href^=?]', '/users/', :count => 6
951
      assert_select 'span.show-all-members-trigger', :count => 0
952
    end
953
  end
954

  
955
  def test_show_should_limit_members_box_principals_per_role_and_display_more_link
956
    project = Project.find(1)
957
    role = Role.find(1)
958
    (Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE + 5).times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])}
959

  
960
    get(:show, :params => {:id => 'ecookbook'})
961
    assert_response :success
962
    assert_select 'div.members.box p.member-role:nth-of-type(1)' do
963
      assert_select 'a[href^=?]', '/users/', :count => Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE
964
      assert_select 'span.show-all-members-trigger a[data-action=?][href*=?]',
965
                    'click->project-members-box-expand#showAll', "offset=#{Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE}",
966
                    :text => 'Show all'
967
    end
968
  end
969

  
970
  def test_show_should_keep_the_separating_space_inside_the_show_all_trigger
971
    project = Project.find(1)
972
    role = Role.find(1)
973
    (Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE + 5).times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])}
974

  
975
    get(:show, :params => {:id => 'ecookbook'})
976
    assert_response :success
977
    # The separator space must be inside the trigger, not a sibling text node,
978
    # or clicking "Show all" leaves it stranded before the comma.
979
    assert_no_match(/<\/a> <span class="show-all-members-trigger"/, response.body)
980
    assert_match(/<span class="show-all-members-trigger"[^>]*> <a /, response.body)
981
  end
982

  
983
  def test_show_all_members_should_return_every_member_from_the_given_offset
984
    project = Project.find(1)
985
    role = Role.find(1)
986
    5.times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])}
987

  
988
    get(:show_all_members, :params => {:id => 'ecookbook', :role_id => role.id, :offset => 1}, :xhr => true)
989
    assert_response :success
990
    assert_select 'a[href^=?]', '/users/', :count => 5
991
    assert_select 'span.show-all-members-trigger', :count => 0
992
  end
993

  
994
  def test_show_all_members_should_return_every_remaining_member_without_a_further_trigger
995
    project = Project.find(1)
996
    role = Role.find(1)
997
    (Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE + 5).times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])}
998

  
999
    get(
1000
      :show_all_members,
1001
      :params => {:id => 'ecookbook', :role_id => role.id, :offset => Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE},
1002
      :xhr => true
1003
    )
1004
    assert_response :success
1005
    assert_select 'a[href^=?]', '/users/', :count => 6
1006
    assert_select 'span.show-all-members-trigger', :count => 0
1007
  end
1008

  
1009
  def test_show_all_members_should_not_include_locked_users
1010
    get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 2, :offset => 0}, :xhr => true)
1011
    assert_response :success
1012
    assert_select 'a', :text => User.find(5).name, :count => 0
1013
  end
1014

  
1015
  def test_show_all_members_should_not_render_a_stray_comma_when_nothing_remains
1016
    # Role 1 (Manager) on project 1 has only 1 active member, so offset 999 lands past the end.
1017
    get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 1, :offset => 999}, :xhr => true)
1018
    assert_response :success
1019
    assert_equal '', response.body.strip
1020
  end
1021

  
1022
  def test_show_all_members_should_respond_with_not_found_for_a_role_without_active_members
1023
    get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 999}, :xhr => true)
1024
    assert_response :not_found
1025
  end
1026

  
1027
  def test_show_all_members_should_not_require_login_for_a_public_project
1028
    get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 1}, :xhr => true)
1029
    assert_response :success
1030
  end
1031

  
909 1032
  def test_settings
910 1033
    @request.session[:user_id] = 2 # manager
911 1034
    get(:settings, :params => {:id => 1})
test/unit/project_test.rb
480 480
    assert_not principals_by_role.values.flatten.include?(locked_user)
481 481
  end
482 482

  
483
  def test_roles_with_active_members
484
    # Project 1 has active members with role 1 (Manager) and role 2 (Developer)
485
    assert_equal [Role.find(1), Role.find(2)], Project.find(1).roles_with_active_members.to_a
486
  end
487

  
488
  def test_roles_with_active_members_should_not_include_roles_without_members
489
    project = Project.generate!
490
    assert_equal [], project.roles_with_active_members.to_a
491
  end
492

  
493
  def test_roles_with_active_members_should_not_include_roles_with_only_locked_members
494
    project = Project.find(1)
495
    role = Role.find(2)
496
    # Role 2 (Developer) only has active member 3 besides the locked member 5
497
    Member.find_by(:project_id => 1, :user_id => 3).destroy
498
    assert_not project.reload.roles_with_active_members.to_a.include?(role)
499
  end
500

  
501
  def test_principals_for_role
502
    principals, more = Project.find(1).principals_for_role(Role.find(1))
503
    assert_kind_of Array, principals
504
    assert principals.include?(User.find(2))
505
    assert_not more
506
  end
507

  
508
  def test_principals_for_role_should_only_return_active_users
509
    # Role 2 (Developer) on project 1 has active member 3 and locked member 5
510
    principals, _more = Project.find(1).principals_for_role(Role.find(2))
511
    assert_equal [User.find(3)], principals
512
  end
513

  
514
  def test_principals_for_role_should_include_groups
515
    group = Group.find(10)
516
    Member.create!(:principal => group, :project_id => 1, :role_ids => [1])
517

  
518
    principals, _more = Project.find(1).principals_for_role(Role.find(1))
519
    assert principals.include?(group)
520
  end
521

  
522
  def test_principals_for_role_should_not_duplicate_a_user_with_an_inherited_role_matching_a_direct_role
523
    project = Project.find(1)
524
    role = Role.find(1)
525
    user = User.generate!
526
    User.add_to_project(user, project, role)
527
    group = Group.generate!
528
    group.users << user
529
    # Also grants role via the group: the user now has 2 member_roles for the
530
    # same role (one direct, one inherited, see MemberRole#add_role_to_group_users)
531
    User.add_to_project(group, project, role)
532

  
533
    principals, _more = project.principals_for_role(role, :limit => 100)
534
    assert_equal 1, principals.count {|p| p == user}
535
  end
536

  
537
  def test_principals_for_role_should_paginate
538
    project = Project.find(1)
539
    role = Role.find(1)
540
    5.times {|i| Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])}
541

  
542
    principals, more = project.principals_for_role(role, :limit => 2)
543
    assert_equal 2, principals.size
544
    assert more
545

  
546
    more_principals, more = project.principals_for_role(role, :offset => 2, :limit => 2)
547
    assert_equal 2, more_principals.size
548
    assert more
549
    assert_empty principals & more_principals
550

  
551
    rest, more = project.principals_for_role(role, :offset => 4, :limit => 2)
552
    assert_equal 2, rest.size
553
    assert_not more
554
  end
555

  
556
  def test_principals_for_role_should_treat_a_negative_offset_as_zero
557
    project = Project.find(1)
558
    role = Role.find(1)
559

  
560
    assert_equal project.principals_for_role(role), project.principals_for_role(role, :offset => -5)
561
  end
562

  
563
  def test_principals_for_role_with_a_nil_limit_should_return_every_remaining_member
564
    project = Project.find(1)
565
    role = Role.find(1)
566
    5.times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])}
567

  
568
    first_page, more = project.principals_for_role(role, :limit => 2)
569
    assert_equal 2, first_page.size
570
    assert more
571

  
572
    rest, more = project.principals_for_role(role, :offset => first_page.size, :limit => nil)
573
    assert_equal 4, rest.size
574
    assert_not more
575
    assert_empty first_page & rest
576
  end
577

  
483 578
  def test_rolled_up_trackers
484 579
    parent = Project.find(1)
485 580
    parent.trackers = Tracker.find([1, 2])
(5-5/9)