Project

General

Profile

Feature #44581 » 0001-Make-project-members-page-available-to-all-users-wit.patch

Marius BĂLTEANU, 2026-10-07 22:19

View differences:

app/controllers/members_controller.rb
20 20
class MembersController < ApplicationController
21 21
  self.model_object = Member
22 22

  
23
  menu_item :overview
24

  
23 25
  before_action :find_model_object, :except => [:index, :new, :create, :autocomplete]
24 26
  before_action :find_project_from_association, :except => [:index, :new, :create, :autocomplete]
25 27
  before_action :find_project_by_project_id, :only => [:index, :new, :create, :autocomplete]
......
31 33
  include MembersHelper
32 34

  
33 35
  def index
34
    scope = @project.memberships
35
    @members = scope.includes(:principal, :roles).order(:id)
36

  
37 36
    respond_to do |format|
38
      format.html {head :not_acceptable}
37
      format.html
39 38
      format.api do
39
        scope = @project.memberships
40
        @members = scope.includes(:principal, :roles).order(:id)
40 41
        @offset, @limit = api_offset_and_limit
41 42
        @member_count = scope.count
42 43
        @member_pages = Paginator.new @member_count, @limit, params['page']
......
44 45
        @members = @members.limit(@limit).offset(@offset).to_a
45 46
      end
46 47
      format.csv do
47
        send_data(members_to_csv(@members), type: 'text/csv; header=present', filename: "#{@project.identifier}-members.csv")
48
        if User.current.allowed_to?(:manage_members, @project)
49
          scope = @project.memberships
50
          @members = scope.includes(:principal, :roles).order(:id)
51
          send_data(members_to_csv(@members), type: 'text/csv; header=present', filename: "#{@project.identifier}-members.csv")
52
        else
53
          head :forbidden
54
        end
48 55
      end
49 56
    end
50 57
  end
app/helpers/members_helper.rb
59 59
  # Returns the scope of the members of project matching the filters
60 60
  # set in the request params
61 61
  def members_scope(project)
62
    project.memberships.
63
      like(params[:member_name]).
64
      with_role(params[:member_role_id]).
65
      status(params[:member_status])
62
    scope =
63
      project.memberships.
64
        like(params[:member_name]).
65
        with_role(params[:member_role_id])
66
    if User.current.allowed_to?(:manage_members, project)
67
      scope.status(params[:member_status])
68
    else
69
      scope.active
70
    end
66 71
  end
67 72

  
68 73
  # limit/offset on Member.sorted would paginate role join rows, not members
app/helpers/projects_helper.rb
24 24
        {:name => 'info', :action => :edit_project,
25 25
         :partial => 'projects/edit', :label => :label_project},
26 26
        {:name => 'members', :action => :manage_members,
27
         :partial => 'projects/settings/members', :label => :label_member_plural,
27
         :partial => 'members/members', :label => :label_member_plural,
28 28
         :url => {:tab => 'members'}.merge(members_list_params)},
29 29
        {:name => 'issues', :action => :edit_project, :module => :issue_tracking,
30 30
         :partial => 'projects/settings/issues', :label => :label_issue_tracking},
app/helpers/routes_helper.rb
99 99
  def board_path(board, *)
100 100
    project_board_path(board.project, board, *)
101 101
  end
102

  
103
  def _project_members_path(project, parameters = {})
104
    params = parameters.is_a?(Hash) ? parameters.except('tab', :tab) : parameters
105
    if controller_name == 'members' && action_name == 'index'
106
      project_members_path(project, params)
107
    else
108
      settings_project_path(project, 'members', params)
109
    end
110
  end
102 111
end
app/views/members/_members.html.erb
1
<% members, member_pages, member_count = paginate_members(members_scope(@project)) %>
2

  
3
<% if User.current.admin? %>
4
  <div class="contextual"><%= link_to sprite_icon('settings', l(:label_administration)), users_path, :class => "icon icon-settings" %></div>
5
<% end %>
6

  
7
<% if User.current.allowed_to?(:manage_members, @project) %>
8
<p><%= link_to sprite_icon('add', l(:label_member_new)), new_project_membership_path(@project, members_list_params), :remote => true, :class => "icon icon-add" %></p>
9
<% end %>
10

  
11
<%= form_tag(_project_members_path(@project), :method => :get, :id => 'members-filter-form') do %>
12
<fieldset><legend><%= l(:label_filter_plural) %></legend>
13
<label for='member_name'><%= l(:label_user) %> / <%= l(:label_group) %>:</label>
14
<%= text_field_tag 'member_name', params[:member_name], :size => 20 %>
15
<label for='member_role_id'><%= l(:label_role) %>:</label>
16
<%= select_tag 'member_role_id',
17
               options_for_select([[l(:label_all), '']] + Role.givable.pluck(:name, :id), params[:member_role_id]),
18
               :onchange => "this.form.submit(); return false;" %>
19
<% if User.current.allowed_to?(:manage_members, @project) %>
20
<label for='member_status'><%= l(:field_status) %>:</label>
21
<%= select_tag 'member_status',
22
               options_for_select(member_status_options, params[:member_status]),
23
               :onchange => "this.form.submit(); return false;" %>
24
<% end %>
25
<%= submit_tag l(:button_apply), :class => "small", :name => nil %>
26
<%= link_to sprite_icon('reload', l(:button_clear)), _project_members_path(@project), :class => 'icon icon-reload' %>
27
</fieldset>
28
<% end %>
29
&nbsp;
30

  
31
<% if member_count > 0 %>
32
<div class="autoscroll">
33
<table class="list members">
34
  <thead>
35
    <tr>
36
      <th><%= l(:label_user) %> / <%= l(:label_group) %></th>
37
      <th><%= l(:label_role_plural) %></th>
38
      <% if User.current.allowed_to?(:manage_members, @project) %>
39
        <th></th>
40
      <% end %>
41
      <%= call_hook(:view_projects_settings_members_table_header, :project => @project) %>
42
    </tr>
43
  </thead>
44
  <tbody>
45
  <% members.each do |member| %>
46
  <% next if member.new_record? %>
47
  <tr id="member-<%= member.id %>" class="member">
48
  <td class="name">
49
    <span class="icon">
50
      <% if member.principal %>
51
        <%= link_to_principal member.principal %>
52
      <% end %>
53
    </span>
54
  </td>
55
  <td class="roles">
56
    <span id="member-<%= member.id %>-roles"><%= member.roles.sort.collect(&:to_s).join(', ') %></span>
57
    <div id="member-<%= member.id %>-form"></div>
58
  </td>
59
  <% if User.current.allowed_to?(:manage_members, @project) %>
60
    <td class="buttons">
61
      <%= link_to sprite_icon('edit', l(:button_edit)),
62
                  edit_membership_path(member, members_list_params),
63
                  :remote => true,
64
                  :class => 'icon icon-edit' %>
65
      <%= remove_link membership_path(member, members_list_params),
66
                      :remote => true,
67
                      :data => (!User.current.admin? && member.include?(User.current) ? {:confirm => l(:text_own_membership_delete_confirmation)} : {}) if member.deletable? %>
68
    </td>
69
  <% end %>
70
  <%= call_hook(:view_projects_settings_members_table_row, { :project => @project, :member => member}) if User.current.allowed_to?(:manage_members, @project) %>
71
  </tr>
72
<% end %>
73
  </tbody>
74
</table>
75
</div>
76
<span class="pagination">
77
  <%= pagination_links_full(member_pages, member_count) do |text, parameters, options|
78
        link_to text, _project_members_path(@project, request.query_parameters.merge(parameters)), options
79
      end %>
80
</span>
81
<% if User.current.allowed_to?(:manage_members, @project) %>
82
  <% other_formats_links do |f| %>
83
    <%= f.link_to_with_query_parameters "CSV", {}, :onclick => "showModal('csv-export-options', '330px'); return false;" %>
84
  <% end %>
85
  <div id="csv-export-options" style="display: none;">
86
    <h3 class="title"><%= l(:label_export_options, :export_format => 'CSV') %></h3>
87
    <%= form_tag(project_memberships_path(project_id: @project.id, format: 'csv'), :method => :get, :id => 'csv-export-form') do %>
88
    <%= export_csv_encoding_select_tag %>
89
    <p class="buttons">
90
      <%= submit_tag l(:button_export), :name => nil, :onclick => 'hideModal(this);', :data => {:disable_with => false} %>
91
      <%= link_to_function l(:button_cancel), 'hideModal(this);' %>
92
    </p>
93
    <% end %>
94
  </div>
95
<% end %>
96
<% else %>
97
<p class="nodata"><%= l(:label_no_data) %></p>
98
<% end %>
app/views/members/index.html.erb
1
<h2><%= l(:label_member_plural) %></h2>
2

  
3
<%= render :partial => 'members' %>
4

  
5
<% html_title(l(:label_member_plural)) -%>
app/views/projects/_members_box.html.erb
4 4
    <% @principals_by_role.keys.sort.each do |role| %>
5 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>
6 6
    <% end %>
7
    <% if User.current.allowed_to?(:view_members, @project) %>
8
      <p><%= link_to l(:label_member_view_all), project_members_path(@project) %></p>
9
    <% end %>
7 10
  </div>
8 11
  <% end %>
app/views/projects/settings/_members.html.erb
1
<% members, member_pages, member_count = paginate_members(members_scope(@project)) %>
2

  
3
<% if User.current.admin? %>
4
  <div class="contextual"><%= link_to sprite_icon('settings', l(:label_administration)), users_path, :class => "icon icon-settings" %></div>
5
<% end %>
6
<p><%= link_to sprite_icon('add', l(:label_member_new)), new_project_membership_path(@project, members_list_params), :remote => true, :class => "icon icon-add" %></p>
7

  
8
<%= form_tag(settings_project_path(@project, :tab => 'members'), :method => :get, :id => 'members-filter-form') do %>
9
<fieldset><legend><%= l(:label_filter_plural) %></legend>
10
<label for='member_name'><%= l(:label_user) %> / <%= l(:label_group) %>:</label>
11
<%= text_field_tag 'member_name', params[:member_name], :size => 20 %>
12
<label for='member_role_id'><%= l(:label_role) %>:</label>
13
<%= select_tag 'member_role_id',
14
               options_for_select([[l(:label_all), '']] + Role.givable.pluck(:name, :id), params[:member_role_id]),
15
               :onchange => "this.form.submit(); return false;" %>
16
 <label for='member_status'><%= l(:field_status) %>:</label>
17
 <%= select_tag 'member_status',
18
                options_for_select(member_status_options, params[:member_status]),
19
                :onchange => "this.form.submit(); return false;" %>
20
<%= submit_tag l(:button_apply), :class => "small", :name => nil %>
21
<%= link_to sprite_icon('reload', l(:button_clear)), settings_project_path(@project, :tab => 'members'), :class => 'icon icon-reload' %>
22
</fieldset>
23
<% end %>
24
&nbsp;
25

  
26
<% if member_count > 0 %>
27
<div class="autoscroll">
28
<table class="list members">
29
  <thead>
30
    <tr>
31
      <th><%= l(:label_user) %> / <%= l(:label_group) %></th>
32
      <th><%= l(:label_role_plural) %></th>
33
      <th></th>
34
      <%= call_hook(:view_projects_settings_members_table_header, :project => @project) %>
35
    </tr>
36
  </thead>
37
  <tbody>
38
  <% members.each do |member| %>
39
  <% next if member.new_record? %>
40
  <tr id="member-<%= member.id %>" class="member">
41
  <td class="name">
42
    <span class="icon">
43
      <% if member.principal %>
44
        <%= link_to_principal member.principal %>
45
      <% end %>
46
    </span>
47
  </td>
48
  <td class="roles">
49
    <span id="member-<%= member.id %>-roles"><%= member.roles.sort.collect(&:to_s).join(', ') %></span>
50
    <div id="member-<%= member.id %>-form"></div>
51
  </td>
52
  <td class="buttons">
53
      <%= link_to sprite_icon('edit', l(:button_edit)),
54
                  edit_membership_path(member, members_list_params),
55
                  :remote => true,
56
                  :class => 'icon icon-edit' %>
57
      <%= remove_link membership_path(member, members_list_params),
58
                      :remote => true,
59
                      :data => (!User.current.admin? && member.include?(User.current) ? {:confirm => l(:text_own_membership_delete_confirmation)} : {}) if member.deletable? %>
60
  </td>
61
  <%= call_hook(:view_projects_settings_members_table_row, { :project => @project, :member => member}) %>
62
  </tr>
63
<% end %>
64
  </tbody>
65
</table>
66
</div>
67
<span class="pagination">
68
  <%= pagination_links_full(member_pages, member_count) do |text, parameters, options|
69
        link_to text, settings_project_path(@project, 'members', request.query_parameters.except('tab', :tab).merge(parameters)), options
70
      end %>
71
</span>
72
<% other_formats_links do |f| %>
73
  <%= f.link_to_with_query_parameters "CSV", {}, :onclick => "showModal('csv-export-options', '330px'); return false;" %>
74
<% end %>
75
<div id="csv-export-options" style="display: none;">
76
  <h3 class="title"><%= l(:label_export_options, :export_format => 'CSV') %></h3>
77
  <%= form_tag(project_memberships_path(project_id: @project.id, format: 'csv'), :method => :get, :id => 'csv-export-form') do %>
78
  <%= export_csv_encoding_select_tag %>
79
  <p class="buttons">
80
    <%= submit_tag l(:button_export), :name => nil, :onclick => 'hideModal(this);', :data => {:disable_with => false} %>
81
    <%= link_to_function l(:button_cancel), 'hideModal(this);' %>
82
  </p>
83
  <% end %>
84
</div>
85
<% else %>
86
<p class="nodata"><%= l(:label_no_data) %></p>
87
<% end %>
1
<%= render :partial => 'members/members' %>
config/locales/en.yml
674 674
  label_member: Member
675 675
  label_member_new: New member
676 676
  label_member_plural: Members
677
  label_member_view_all: View all members
677 678
  label_tracker: Tracker
678 679
  label_tracker_plural: Trackers
679 680
  label_tracker_all: All trackers
config/routes.rb
164 164
      end
165 165
    end
166 166

  
167
    get 'members', :to => 'members#index', :as => 'members'
168

  
167 169
    resource :enumerations, :controller => 'project_enumerations', :only => [:update, :destroy]
168 170

  
169 171
    get 'issues/:copy_from/copy', :to => 'issues#new', :as => 'copy_issue'
test/functional/members_controller_test.rb
461 461
    assert_response :success
462 462
    assert_include 'User Misc', response.body
463 463
  end
464

  
465
  def test_index_html_should_render_members_page
466
    get(:index, :params => {:project_id => 1})
467
    assert_response :success
468
    assert_select 'h2', :text => 'Members'
469
    assert_select 'div#main-menu a.overview.selected'
470
    assert_select 'title', :text => 'Members - eCookbook - Redmine'
471
    assert_select 'form#members-filter-form[action=?]', '/projects/ecookbook/members' do
472
      assert_select 'input[name=member_name]'
473
      assert_select 'select[name=member_role_id]'
474
      assert_select 'input[type=submit]'
475
      assert_select 'a[href=?]', '/projects/ecookbook/members'
476
    end
477
    assert_select 'table.list.members'
478
  end
479

  
480
  def test_index_html_filter_by_status
481
    @request.session[:user_id] = 2
482
    get(:index, :params => {:project_id => 1, :member_status => 3})
483
    assert_response :success
484
    assert_select 'table.list.members tbody tr.member', :count => 1
485
    assert_select 'tr#member-4'
486
    assert_select 'tr#member-1', :count => 0
487
    assert_select 'tr#member-2', :count => 0
488
  end
489

  
490
  def test_index_html_for_user_with_manage_members_permission_should_display_actions
491
    @request.session[:user_id] = 2 # user 2 has manage_members
492
    get(:index, :params => {:project_id => 1})
493
    assert_response :success
494
    assert_select 'p a.icon-add'
495
    assert_select 'td.buttons a.icon-edit'
496
    assert_select 'select[name=member_status]'
497
  end
498

  
499
  def test_index_html_for_user_without_manage_members_permission_should_not_display_actions
500
    role = Role.create!(:name => 'Viewer', :permissions => [:view_project, :view_members])
501
    user = User.generate!
502
    User.add_to_project(user, Project.find(1), role)
503
    @request.session[:user_id] = user.id
504

  
505
    get(:index, :params => {:project_id => 1})
506
    assert_response :success
507
    assert_select 'p a.icon-add', :count => 0
508
    assert_select 'td.buttons', :count => 0
509
    assert_select 'select[name=member_status]', :count => 0
510
    assert_select 'tr#member-4', :count => 0
511
  end
512

  
513
  def test_index_html_for_user_without_manage_members_permission_should_only_show_active_members
514
    role = Role.create!(:name => 'Viewer', :permissions => [:view_project, :view_members])
515
    user = User.generate!
516
    User.add_to_project(user, Project.find(1), role)
517
    @request.session[:user_id] = user.id
518

  
519
    get(:index, :params => {:project_id => 1, :member_status => 3})
520
    assert_response :success
521
    assert_select 'select[name=member_status]', :count => 0
522
    assert_select 'tr#member-4', :count => 0
523
    assert_select 'tr#member-1'
524
    assert_select 'tr#member-2'
525
  end
526

  
527
  def test_index_html_filter_by_name
528
    get(:index, :params => {:project_id => 1, :member_name => 'John'})
529
    assert_response :success
530
    assert_select 'table.list.members tbody tr.member', :count => 1
531
    assert_select 'tr#member-1'
532
  end
533

  
534
  def test_index_html_filter_by_role
535
    get(:index, :params => {:project_id => 1, :member_role_id => 2})
536
    assert_response :success
537
    assert_select 'table.list.members tbody tr.member'
538
    assert_select 'tr#member-2'
539
    assert_select 'tr#member-1', :count => 0
540
  end
541

  
542
  def test_index_html_with_pagination
543
    project = Project.find(1)
544
    26.times { User.add_to_project(User.generate!, project, Role.find(2)) }
545
    with_settings :per_page_options => '25,50,100' do
546
      get(:index, :params => {:project_id => 1, :members_page => 2})
547
      assert_response :success
548
      assert_select 'span.pagination'
549
      assert_select 'span.pagination a[href*=?]', '/projects/ecookbook/members?members_page='
550
      assert_select 'span.pagination a[href*=?]', 'settings/members', :count => 0
551
    end
552
  end
553

  
554
  def test_index_html_unauthorized_user_should_be_denied
555
    @request.session[:user_id] = nil
556
    get(:index, :params => {:project_id => 2})
557
    assert_response :redirect
558
  end
464 559
end
test/functional/projects_controller_test.rb
784 784
    get(:show, :params => {:id => 1})
785 785
    assert_response :success
786 786
    assert_select '#header h1', :text => "eCookbook"
787
    assert_select 'div.members.box a[href=?]', '/projects/ecookbook/members', :text => 'View all members'
787 788
  end
788 789

  
789 790
  def test_show_by_identifier
......
1037 1038
      }
1038 1039
    )
1039 1040
    assert_response :success
1041
    assert_select 'div#main-menu a.settings.selected'
1040 1042
    assert_select "tr#member-#{user_member.id} td.name a[href=?]", "/users/#{user.id}", :text => user.name
1041 1043
    assert_select "tr#member-#{group_member.id} td.name a[href=?]", '/groups/10', :text => 'A Team'
1042 1044
  end
test/helpers/members_helper_test.rb
87 87
  end
88 88

  
89 89
  def test_members_scope_default_should_return_all_members
90
    User.current = User.find(2)
90 91
    project = Project.find(1)
91 92
    stubs(:params).returns({})
92 93

  
......
94 95
  end
95 96

  
96 97
  def test_members_scope_with_status_active_should_filter_by_active_status
98
    User.current = User.find(2)
97 99
    project = Project.find(1)
98 100
    stubs(:params).returns({:member_status => '1'})
99 101

  
......
101 103
  end
102 104

  
103 105
  def test_members_scope_with_status_locked_should_return_locked_members
106
    User.current = User.find(2)
104 107
    project = Project.find(1)
105 108
    stubs(:params).returns({:member_status => '3'})
106 109

  
107 110
    assert_equal [4], members_scope(project).ids.sort
108 111
  end
112

  
113
  def test_members_scope_for_user_without_manage_members_permission_should_return_active_members_only
114
    User.current = User.find(4)
115
    project = Project.find(1)
116
    stubs(:params).returns({:member_status => '3'})
117

  
118
    assert_equal [1, 2], members_scope(project).ids.sort
119
  end
109 120
end
test/helpers/routes_helper_test.rb
43 43
    assert_equal 'http://test.host/projects/ecookbook/issues?set_filter=1', _project_issues_url(Project.find(1), set_filter: 1)
44 44
    assert_equal 'http://test.host/issues?set_filter=1', _project_issues_url(nil, set_filter: 1)
45 45
  end
46

  
47
  def test_project_members_path_in_settings_context
48
    project = Project.find('ecookbook')
49
    stubs(:controller_name).returns('projects')
50
    stubs(:action_name).returns('settings')
51

  
52
    assert_equal '/projects/ecookbook/settings/members', _project_members_path(project)
53
    assert_equal '/projects/ecookbook/settings/members?members_page=2', _project_members_path(project, :members_page => 2)
54
    assert_equal '/projects/ecookbook/settings/members?members_page=2', _project_members_path(project, :tab => 'members', :members_page => 2)
55
  end
56

  
57
  def test_project_members_path_in_project_members_context
58
    project = Project.find('ecookbook')
59
    stubs(:controller_name).returns('members')
60
    stubs(:action_name).returns('index')
61

  
62
    assert_equal '/projects/ecookbook/members', _project_members_path(project)
63
    assert_equal '/projects/ecookbook/members?members_page=2', _project_members_path(project, :members_page => 2)
64
    assert_equal '/projects/ecookbook/members?members_page=2', _project_members_path(project, :tab => 'members', :members_page => 2)
65
  end
46 66
end
(1-1/4)