Project

General

Profile

Feature #44601 » 0001-Show-the-first-members-of-each-role-with-avatars-and.patch

Marius BĂLTEANU, 2026-10-12 23:31

View differences:

app/assets/stylesheets/application.css
795 795

  
796 796
td.center {text-align:center;}
797 797

  
798
.members.box .avatar {margin-block: 0 2px; margin-inline: 0 4px;}
799
.members.box .badge {inset-block-end: 0;}
800
.members.box a.badge-count, .members.box a.badge-count:hover {color: var(--oc-white); text-decoration: none;}
798 801
#watchers select {inline-size: 95%; display: block;}
799 802
#watchers .avatar {margin-block: 0 2px; margin-inline: 0 4px;}
800 803
#watchers svg.icon-svg {margin-block: 0 2px; margin-inline: 0 2px;}
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_principals = @project.roles_with_principals
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?
app/helpers/application_helper.rb
76 76
    end
77 77

  
78 78
    css_classes += " #{options[:class]}" if css_classes && options[:class].present?
79
    url ? link_to(principal_icon(principal).to_s + name, url, :class => css_classes) : h(name)
79
    return h(name) unless url
80

  
81
    icon =
82
      if options[:avatar]
83
        avatar(principal, :size => (options[:avatar] == true ? 16 : options[:avatar]))
84
      else
85
        principal_icon(principal)
86
      end
87
    link_to(safe_join([icon, name].compact), url, :class => css_classes)
80 88
  end
81 89

  
82 90
  def link_to_mention(user, object, options={})
app/helpers/projects_helper.rb
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19 19

  
20 20
module ProjectsHelper
21
  # Returns a badge with the number of members of the role that are not listed
22
  # in the project members box (ie. beyond +displayed_count+ out of
23
  # +total_count+), linked to the members settings tab filtered on that role
24
  # for the users allowed to manage the members of the project. Returns nil if
25
  # every member of the role is already displayed.
26
  def members_not_displayed_badge(project, role, total_count, displayed_count)
27
    remaining_count = total_count - displayed_count
28
    return if remaining_count <= 0
29

  
30
    options = {:class => 'badge badge-count', :title => l(:label_x_more_members, :count => remaining_count)}
31
    if User.current.allowed_to?(:manage_members, project)
32
      link_to("+#{remaining_count}", settings_project_path(project, :tab => 'members', :member_role_id => role.id), options)
33
    else
34
      content_tag('span', "+#{remaining_count}", options)
35
    end
36
  end
37

  
21 38
  def project_settings_tabs
22 39
    tabs =
23 40
      [
app/models/project.rb
590 590
    end
591 591
  end
592 592

  
593
  # Returns an array of [Role, principals, count] for the active members of the
594
  # project, ordered by role position. Only the first +limit+ principals are
595
  # returned for each role, count being the total number of members with the role.
596
  def roles_with_principals(limit: 10)
597
    # One row per member and role: enough to count the members of each role and
598
    # to pick the first ones of each, without loading every member of the project
599
    role_and_user_ids =
600
      memberships.active.joins(:member_roles).
601
        reorder(Principal.fields_for_order_statement).
602
        pluck("#{MemberRole.table_name}.role_id", "#{Member.table_name}.user_id").uniq
603
    return [] if role_and_user_ids.empty?
604

  
605
    user_ids_by_role_id = role_and_user_ids.group_by(&:first).transform_values {|rows| rows.map(&:last)}
606
    principals =
607
      Principal.where(:id => user_ids_by_role_id.values.flat_map {|ids| ids.first(limit)}).index_by(&:id)
608

  
609
    Role.where(:id => user_ids_by_role_id.keys).sorted.map do |role|
610
      user_ids = user_ids_by_role_id[role.id]
611
      [role, user_ids.first(limit).filter_map {|user_id| principals[user_id]}, user_ids.size]
612
    end
613
  end
614

  
593 615
  # Adds user as a project member with the default role
594 616
  # Used for when a non-admin user creates a project
595 617
  def add_default_member(user)
app/views/projects/_members_box.html.erb
1
  <% if @principals_by_role.any? %>
1
  <% if @roles_with_principals.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_principals.each do |role, principals, count| %>
5
      <p>
6
        <span class="label"><%= role %>:</span>
7
        <%= safe_join(principals.collect {|principal| link_to_principal(principal, :avatar => true)}, ', ') %>
8
        <%= members_not_displayed_badge(@project, role, count, principals.size) %>
9
      </p>
6 10
    <% end %>
7 11
    <% if User.current.allowed_to?(:view_members, @project) %>
8 12
      <p><%= link_to l(:label_member_view_all), project_members_path(@project) %></p>
config/locales/en.yml
675 675
  label_member_new: New member
676 676
  label_member_plural: Members
677 677
  label_member_view_all: View all members
678
  label_x_more_members:
679
    one:   1 more member
680
    other: "%{count} more members"
678 681
  label_tracker: Tracker
679 682
  label_tracker_plural: Trackers
680 683
  label_tracker_all: All trackers
test/functional/projects_controller_test.rb
802 802
    assert_select '#main.nosidebar'
803 803
  end
804 804

  
805
  def test_show_should_display_members_box_with_avatars
806
    @request.session[:user_id] = 2
807
    get(:show, :params => {:id => 'ecookbook'})
808
    assert_response :success
809

  
810
    assert_select 'div.members.box' do
811
      assert_select 'p span.label', :text => 'Manager:'
812
      assert_select 'p a.user[href=?]', '/users/2' do
813
        assert_select '.avatar'
814
      end
815
    end
816
  end
817

  
818
  def test_show_should_link_the_number_of_members_not_displayed_of_a_role
819
    # 12 active members with the role, 10 of them displayed
820
    11.times {User.add_to_project(User.generate!, Project.find(1), Role.find(2))}
821
    @request.session[:user_id] = 2
822
    get(:show, :params => {:id => 'ecookbook'})
823
    assert_response :success
824

  
825
    assert_select 'div.members.box' do
826
      assert_select 'p a.user', :count => 10 + 1
827
      assert_select(
828
        'p a.badge[href=?][title=?]',
829
        '/projects/ecookbook/settings/members?member_role_id=2', '2 more members',
830
        :text => '+2'
831
      )
832
    end
833
  end
834

  
835
  def test_show_should_not_link_the_number_of_members_not_displayed_without_manage_members_permission
836
    11.times {User.add_to_project(User.generate!, Project.find(1), Role.find(2))}
837
    @request.session[:user_id] = 7
838
    get(:show, :params => {:id => 'ecookbook'})
839
    assert_response :success
840

  
841
    assert_select 'div.members.box' do
842
      assert_select 'p span.badge[title=?]', '2 more members', :text => '+2'
843
      assert_select 'p a[href*=?]', 'settings/members', :count => 0
844
    end
845
  end
846

  
805 847
  def test_show_should_display_visible_custom_fields
806 848
    ProjectCustomField.find_by_name('Development status').update_attribute :visible, true
807 849
    get(:show, :params => {:id => 'ecookbook'})
test/helpers/application_helper_test.rb
1808 1808
    assert_equal result, link_to_principal(user)
1809 1809
  end
1810 1810

  
1811
  def test_link_to_principal_with_avatar_should_replace_the_icon_with_an_avatar
1812
    html = link_to_principal(User.find(2), :avatar => true)
1813
    assert_select_in html, 'a.user.active[href=?]', '/users/2' do
1814
      assert_select '.avatar'
1815
      assert_select 'svg', :count => 0
1816
    end
1817
  end
1818

  
1819
  def test_link_to_principal_with_avatar_size
1820
    html = link_to_principal(Group.find(10), :avatar => 24)
1821
    assert_select_in html, 'a.group[href=?]', '/groups/10' do
1822
      assert_select 'img.avatar[width=?]', '24'
1823
    end
1824
  end
1825

  
1811 1826
  def test_link_to_principal_should_link_to_group
1812 1827
    group = Group.find(10)
1813 1828
    result = %r{<a class="group" href="/groups/10"><svg class="s18 icon-svg" aria-hidden="true"><use href="/assets/icons-\w+.svg#icon--group"></use></svg>A Team</a>}
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_principals
484
    group = Group.find(10)
485
    Member.create!(:principal => group, :project_id => 1, :role_ids => [1])
486

  
487
    roles_with_principals = Project.find(1).roles_with_principals
488
    assert_kind_of Array, roles_with_principals
489

  
490
    role, principals, count = roles_with_principals.detect {|r, _, _| r == Role.find(1)}
491
    assert_equal Role.find(1), role
492
    # The group, and the users it gives the role to, are members with the role too
493
    assert_equal 3, count
494
    assert_include User.find(2), principals
495
    assert_include group, principals
496
    # Groups are listed after the users
497
    assert_equal group, principals.last
498
  end
499

  
500
  def test_roles_with_principals_should_be_ordered_by_role_position
501
    roles = Project.find(1).roles_with_principals.map(&:first)
502
    assert_equal roles.sort_by(&:position), roles
503
  end
504

  
505
  def test_roles_with_principals_should_only_return_active_principals
506
    locked_user = User.find(5)
507
    assert_include locked_user, Project.find(1).memberships.map(&:principal)
508

  
509
    roles_with_principals = Project.find(1).roles_with_principals
510
    assert_not_include locked_user, roles_with_principals.flat_map {|_, principals, _| principals}
511
  end
512

  
513
  def test_roles_with_principals_should_limit_the_principals_but_not_the_count
514
    project = Project.find(1)
515
    role = Role.find(2)
516
    11.times {User.add_to_project(User.generate!, project, role)}
517

  
518
    _, principals, count = project.roles_with_principals.detect {|r, _, _| r == role}
519
    assert_equal 10, principals.size
520
    assert_equal 12, count
521
  end
522

  
523
  def test_roles_with_principals_should_list_a_member_under_each_of_its_roles
524
    member = Member.find(1)
525
    member.update!(:role_ids => [1, 2])
526

  
527
    roles_with_principals = Project.find(1).roles_with_principals
528
    [1, 2].each do |role_id|
529
      _, principals, = roles_with_principals.detect {|r, _, _| r == Role.find(role_id)}
530
      assert_include member.principal, principals
531
    end
532
  end
533

  
534
  def test_roles_with_principals_should_not_include_the_members_of_other_projects
535
    principals = Project.find(1).roles_with_principals.flat_map {|_, p, _| p}
536
    assert_not_include User.find(8), principals
537
  end
538

  
539
  def test_roles_with_principals_should_return_an_empty_array_without_members
540
    assert_equal [], Project.generate!.roles_with_principals
541
  end
542

  
483 543
  def test_rolled_up_trackers
484 544
    parent = Project.find(1)
485 545
    parent.trackers = Tracker.find([1, 2])
(2-2/2)