Project

General

Profile

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

Takenori TAKAKI, 2026-09-10 15:02

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
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
  # Lazily renders the members of a role that were omitted from the overview's members box (#88737).
201
  def show_all_members
202
    @role = @project.roles_with_active_members.detect {|role| role.id == params[:role_id].to_i}
203
    unless @role
204
      render_404
205
      return
206
    end
207

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

  
200 212
  def settings
201 213
    @issue_custom_fields = IssueCustomField.sorted.to_a
202 214
    @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_not_display_locked_users_in_members_box
921
    get(:show, :params => {:id => 'ecookbook'})
922
    assert_response :success
923
    # User 5 is a locked member with the Developer role (see test/fixtures/members.yml)
924
    assert_select 'div.members.box a', :text => User.find(5).name, :count => 0
925
  end
926

  
927
  def test_show_should_not_display_more_link_when_role_has_few_members
928
    project = Project.find(1)
929
    role = Role.find(1) # Manager, already has 1 active member (John Smith)
930
    5.times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])}
931

  
932
    get(:show, :params => {:id => 'ecookbook'})
933
    assert_response :success
934
    assert_select 'div.members.box p.member-role:nth-of-type(1)' do
935
      assert_select 'a[href^=?]', '/users/', :count => 6
936
      assert_select 'span.show-all-members-trigger', :count => 0
937
    end
938
  end
939

  
940
  def test_show_should_limit_members_box_principals_per_role_and_display_more_link
941
    project = Project.find(1)
942
    role = Role.find(1)
943
    (Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE + 5).times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])}
944

  
945
    get(:show, :params => {:id => 'ecookbook'})
946
    assert_response :success
947
    assert_select 'div.members.box p.member-role:nth-of-type(1)' do
948
      assert_select 'a[href^=?]', '/users/', :count => Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE
949
      assert_select 'span.show-all-members-trigger a[data-action=?][href*=?]',
950
                    'click->project-members-box-expand#showAll', "offset=#{Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE}",
951
                    :text => 'Show all'
952
    end
953
  end
954

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

  
960
    get(:show_all_members, :params => {:id => 'ecookbook', :role_id => role.id, :offset => 1}, :xhr => true)
961
    assert_response :success
962
    assert_select 'a[href^=?]', '/users/', :count => 5
963
    assert_select 'span.show-all-members-trigger', :count => 0
964
  end
965

  
966
  def test_show_all_members_should_return_every_remaining_member_without_a_further_trigger
967
    project = Project.find(1)
968
    role = Role.find(1)
969
    (Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE + 5).times {Member.create!(:principal => User.generate!, :project => project, :role_ids => [role.id])}
970

  
971
    get(
972
      :show_all_members,
973
      :params => {:id => 'ecookbook', :role_id => role.id, :offset => Project::MEMBERS_BOX_PRINCIPALS_PER_ROLE},
974
      :xhr => true
975
    )
976
    assert_response :success
977
    assert_select 'a[href^=?]', '/users/', :count => 6
978
    assert_select 'span.show-all-members-trigger', :count => 0
979
  end
980

  
981
  def test_show_all_members_should_not_include_locked_users
982
    get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 2, :offset => 0}, :xhr => true)
983
    assert_response :success
984
    assert_select 'a', :text => User.find(5).name, :count => 0
985
  end
986

  
987
  def test_show_all_members_should_not_render_a_stray_comma_when_nothing_remains
988
    # Role 1 (Manager) on project 1 has only 1 active member, so offset 999 lands past the end.
989
    get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 1, :offset => 999}, :xhr => true)
990
    assert_response :success
991
    assert_equal '', response.body.strip
992
  end
993

  
994
  def test_show_all_members_should_respond_with_not_found_for_a_role_without_active_members
995
    get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 999}, :xhr => true)
996
    assert_response :not_found
997
  end
998

  
999
  def test_show_all_members_should_not_require_login_for_a_public_project
1000
    get(:show_all_members, :params => {:id => 'ecookbook', :role_id => 1}, :xhr => true)
1001
    assert_response :success
1002
  end
1003

  
909 1004
  def test_settings
910 1005
    @request.session[:user_id] = 2 # manager
911 1006
    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])
(3-3/9)