Commit 8ff9c0cb authored by Ash McKenzie's avatar Ash McKenzie

Merge branch '281824-convert-project-members-list-view-from-haml-to-vue-setup-tabs' into 'master'

Move project members into tabs

See merge request gitlab-org/gitlab!49764
parents 34d2b34a 4c7f2e23
...@@ -17,17 +17,18 @@ class Projects::ProjectMembersController < Projects::ApplicationController ...@@ -17,17 +17,18 @@ class Projects::ProjectMembersController < Projects::ApplicationController
@skip_groups += @project.group.self_and_ancestors_ids if @project.group @skip_groups += @project.group.self_and_ancestors_ids if @project.group
@group_links = @project.project_group_links @group_links = @project.project_group_links
@group_links = @group_links.search(params[:search]) if params[:search].present? @group_links = @group_links.search(params[:search_groups]) if params[:search_groups].present?
@project_members = MembersFinder project_members = MembersFinder
.new(@project, current_user, params: filter_params) .new(@project, current_user, params: filter_params)
.execute(include_relations: requested_relations) .execute(include_relations: requested_relations)
@project_members = present_members(@project_members.page(params[:page])) if helpers.can_manage_project_members?(@project)
@invited_members = present_members(project_members.invite)
@requesters = present_members(AccessRequestsFinder.new(@project).execute(current_user))
end
@requesters = present_members( @project_members = present_members(project_members.non_invite.page(params[:page]))
AccessRequestsFinder.new(@project).execute(current_user)
)
@project_member = @project.project_members.new @project_member = @project.project_members.new
end end
......
# frozen_string_literal: true
module Projects::ProjectMembersHelper
def can_manage_project_members?(project)
can?(current_user, :admin_project_member, project)
end
def show_groups?(group_links)
group_links.exists? || groups_tab_active?
end
def show_invited_members?(project, invited_members)
can_manage_project_members?(project) && invited_members.exists?
end
def show_access_requests?(project, requesters)
can_manage_project_members?(project) && requesters.exists?
end
def groups_tab_active?
params[:search_groups].present?
end
def current_user_is_group_owner?(project)
return false if project.group.nil?
project.group.has_owner?(current_user)
end
end
...@@ -53,18 +53,18 @@ ...@@ -53,18 +53,18 @@
#tab-members.tab-pane{ class: ('active' unless invited_active) } #tab-members.tab-pane{ class: ('active' unless invited_active) }
.card.card-without-border .card.card-without-border
- unless filtered_search_enabled - unless filtered_search_enabled
= render 'groups/group_members/tab_pane/header' do = render 'shared/members/tab_pane/header' do
= render 'groups/group_members/tab_pane/title' do = render 'shared/members/tab_pane/title' do
= html_escape(_('Members with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } = html_escape(_('Members with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
= form_tag group_group_members_path(@group), method: :get, class: 'user-search-form gl-display-flex gl-md-align-items-center gl-flex-wrap gl-flex-direction-column gl-md-flex-direction-row gl-mx-n3 gl-my-n3', data: { testid: 'user-search-form' } do = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form gl-display-flex gl-md-align-items-center gl-flex-wrap gl-flex-direction-column gl-md-flex-direction-row gl-mx-n3 gl-my-n3', data: { testid: 'user-search-form' } do
.gl-px-3.gl-py-2 .gl-px-3.gl-py-2
.search-control-wrap.gl-relative .search-control-wrap.gl-relative
= render 'shared/members/search_field' = render 'shared/members/search_field'
- if can_manage_members - if can_manage_members
= render 'groups/group_members/tab_pane/form_item' do = render 'shared/members/tab_pane/form_item' do
= label_tag '2fa', _('2FA'), class: form_item_label_css_class = label_tag '2fa', _('2FA'), class: form_item_label_css_class
= render 'shared/members/filter_2fa_dropdown' = render 'shared/members/filter_2fa_dropdown'
= render 'groups/group_members/tab_pane/form_item' do = render 'shared/members/tab_pane/form_item' do
= label_tag :sort_by, _('Sort by'), class: form_item_label_css_class = label_tag :sort_by, _('Sort by'), class: form_item_label_css_class
= render 'shared/members/sort_dropdown' = render 'shared/members/sort_dropdown'
.js-group-members-list{ data: group_members_list_data_attributes(@group, @members) } .js-group-members-list{ data: group_members_list_data_attributes(@group, @members) }
...@@ -75,8 +75,8 @@ ...@@ -75,8 +75,8 @@
#tab-groups.tab-pane #tab-groups.tab-pane
.card.card-without-border .card.card-without-border
- unless filtered_search_enabled - unless filtered_search_enabled
= render 'groups/group_members/tab_pane/header' do = render 'shared/members/tab_pane/header' do
= render 'groups/group_members/tab_pane/title' do = render 'shared/members/tab_pane/title' do
= html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } = html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
.js-group-linked-list{ data: linked_groups_list_data_attributes(@group) } .js-group-linked-list{ data: linked_groups_list_data_attributes(@group) }
.loading .loading
...@@ -85,8 +85,8 @@ ...@@ -85,8 +85,8 @@
#tab-invited-members.tab-pane{ class: ('active' if invited_active) } #tab-invited-members.tab-pane{ class: ('active' if invited_active) }
.card.card-without-border .card.card-without-border
- unless filtered_search_enabled - unless filtered_search_enabled
= render 'groups/group_members/tab_pane/header' do = render 'shared/members/tab_pane/header' do
= render 'groups/group_members/tab_pane/title' do = render 'shared/members/tab_pane/title' do
= html_escape(_('Members invited to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } = html_escape(_('Members invited to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
= form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do
= render 'shared/members/search_field', name: 'search_invited' = render 'shared/members/search_field', name: 'search_invited'
...@@ -98,8 +98,8 @@ ...@@ -98,8 +98,8 @@
#tab-access-requests.tab-pane #tab-access-requests.tab-pane
.card.card-without-border .card.card-without-border
- unless filtered_search_enabled - unless filtered_search_enabled
= render 'groups/group_members/tab_pane/header' do = render 'shared/members/tab_pane/header' do
= render 'groups/group_members/tab_pane/title' do = render 'shared/members/tab_pane/title' do
= html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } = html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
.js-group-access-requests-list{ data: group_members_list_data_attributes(@group, @requesters) } .js-group-access-requests-list{ data: group_members_list_data_attributes(@group, @requesters) }
.loading .loading
......
.card.project-members-groups .card.card-without-border
.card-header = render 'shared/members/tab_pane/header' do
= html_escape(_("Groups with access to %{strong_open}%{project_name}%{strong_close}")) % { project_name: sanitize(@project.name, tags: []), strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } = render 'shared/members/tab_pane/title' do
%span.badge.badge-pill= group_links.size = html_escape(_("Groups with access to %{strong_open}%{project_name}%{strong_close}")) % { project_name: sanitize(@project.name, tags: []), strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
%ul.content-list.members-list = form_tag project_project_members_path(@project), method: :get, class: 'user-search-form gl-mx-n3 gl-my-n3', data: { testid: 'group-link-search-form' } do
- can_admin_member = can?(current_user, :admin_project_member, @project) .gl-px-3.gl-py-2
.search-control-wrap.gl-relative
= render 'shared/members/search_field', name: 'search_groups'
%ul.content-list.members-list{ data: { testid: 'project-member-groups' } }
- @group_links.each do |group_link| - @group_links.each do |group_link|
= render 'shared/members/group', group_link: group_link, can_admin_member: can_admin_member, group_link_path: project_group_link_path(@project, group_link) = render 'shared/members/group', group_link: group_link, can_admin_member: can_manage_project_members?(@project), group_link_path: project_group_link_path(@project, group_link)
- project = local_assigns.fetch(:project) - project = local_assigns.fetch(:project)
- members = local_assigns.fetch(:members) - members = local_assigns.fetch(:members)
- group = local_assigns.fetch(:group) - group = local_assigns.fetch(:group)
- current_user_is_group_owner = group && group.has_owner?(current_user) - current_user_is_group_owner = local_assigns.fetch(:current_user_is_group_owner)
.card .card.card-without-border
.card-header.flex-project-members-panel = render 'shared/members/tab_pane/header' do
%span.flex-project-title = render 'shared/members/tab_pane/title' do
= html_escape(_("Members of %{strong_open}%{project_name}%{strong_close}")) % { project_name: sanitize(project.name, tags: []), strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } = html_escape(_("Members of %{strong_open}%{project_name}%{strong_close}")) % { project_name: sanitize(project.name, tags: []), strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
%span.badge.badge-pill= members.total_count = form_tag project_project_members_path(project), method: :get, class: 'user-search-form gl-display-flex gl-md-align-items-center gl-flex-wrap gl-flex-direction-column gl-md-flex-direction-row gl-mx-n3 gl-my-n3', data: { testid: 'user-search-form' } do
= form_tag project_project_members_path(project), method: :get, class: 'form-inline user-search-form flex-users-form' do .gl-px-3.gl-py-2
.form-group .search-control-wrap.gl-relative
.position-relative = render 'shared/members/search_field'
= search_field_tag :search, params[:search], { placeholder: _('Find existing members by name'), class: 'form-control', spellcheck: false } = render 'shared/members/tab_pane/form_item' do
%button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") } = label_tag :sort_by, _('Sort by'), class: 'label-bold gl-mr-2 gl-mb-0 gl-py-2 align-self-md-center'
= sprite_icon('search', css_class: 'gl-vertical-align-middle!')
= label_tag :sort_by, _('Sort by'), class: 'col-form-label label-bold px-2'
= render 'shared/members/sort_dropdown' = render 'shared/members/sort_dropdown'
%ul.content-list.members-list{ data: { qa_selector: 'members_list', testid: 'members-table' } } %ul.content-list.members-list{ data: { qa_selector: 'members_list', testid: 'members-table' } }
= render partial: 'shared/members/member', = render partial: 'shared/members/member',
......
- page_title _("Members") - page_title _("Members")
- can_admin_project_members = can?(current_user, :admin_project_member, @project)
- group = @project.group - group = @project.group
.js-remove-member-modal .js-remove-member-modal
...@@ -8,37 +7,73 @@ ...@@ -8,37 +7,73 @@
- if project_can_be_shared? - if project_can_be_shared?
%h4 %h4
= _("Project members") = _("Project members")
- if can_admin_project_members - if can_manage_project_members?(@project)
%p= share_project_description(@project) %p= share_project_description(@project)
- else - else
%p %p
= html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe } = html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe }
.light - if can_manage_project_members?(@project) && project_can_be_shared?
- if can_admin_project_members && project_can_be_shared? - if !membership_locked? && @project.allowed_to_share_with_group?
- if !membership_locked? && @project.allowed_to_share_with_group? %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' }
%li.nav-tab{ role: 'presentation' } %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member")
%a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") %li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) }
%li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) } %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group")
%a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group")
.tab-content.gitlab-tab-content .tab-content.gitlab-tab-content
.tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
= render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project) = render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project)
.tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) } .tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) }
= render 'shared/members/invite_group', submit_url: project_group_links_path(@project), access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, group_link_field: 'link_group_id', group_access_field: 'link_group_access' = render 'shared/members/invite_group', submit_url: project_group_links_path(@project), access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, group_link_field: 'link_group_id', group_access_field: 'link_group_access'
- elsif !membership_locked? - elsif !membership_locked?
.invite-member= render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project) .invite-member= render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project)
- elsif @project.allowed_to_share_with_group? - elsif @project.allowed_to_share_with_group?
.invite-group= render 'shared/members/invite_group', access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, submit_url: project_group_links_path(@project), group_link_field: 'link_group_id', group_access_field: 'link_group_access' .invite-group= render 'shared/members/invite_group', access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, submit_url: project_group_links_path(@project), group_link_field: 'link_group_id', group_access_field: 'link_group_access'
%ul.nav-links.mobile-separator.nav.nav-tabs
= render 'shared/members/requests', membership_source: @project, group: group, requesters: @requesters %li.nav-item
.clearfix = link_to '#tab-members', class: ['nav-link', ('active' unless groups_tab_active?)], data: { toggle: 'tab' } do
%h5.member.existing-title %span
= _("Existing members and groups") = _('Members')
- if @group_links.any? %span.badge.badge-pill= @project_members.total_count
- if show_groups?(@group_links)
%li.nav-item
= link_to '#tab-groups', class: ['nav-link', ('active' if groups_tab_active?)] , data: { toggle: 'tab', qa_selector: 'groups_list_tab' } do
%span
= _('Groups')
%span.badge.badge-pill= @group_links.count
- if show_invited_members?(@project, @invited_members)
%li.nav-item
= link_to '#tab-invited-members', class: 'nav-link', data: { toggle: 'tab' } do
%span
= _('Invited')
%span.badge.badge-pill= @invited_members.count
- if show_access_requests?(@project, @requesters)
%li.nav-item
= link_to '#tab-access-requests', class: 'nav-link', data: { toggle: 'tab' } do
%span
= _('Access requests')
%span.badge.badge-pill= @requesters.count
.tab-content
#tab-members.tab-pane{ class: ('active' unless groups_tab_active?) }
= render 'projects/project_members/team', project: @project, group: group, members: @project_members, current_user_is_group_owner: current_user_is_group_owner?(@project)
= paginate @project_members, theme: "gitlab", params: { search_groups: nil }
- if show_groups?(@group_links)
#tab-groups.tab-pane{ class: ('active' if groups_tab_active?) }
= render 'projects/project_members/groups', group_links: @group_links = render 'projects/project_members/groups', group_links: @group_links
- if show_invited_members?(@project, @invited_members)
= render 'projects/project_members/team', project: @project, group: group, members: @project_members #tab-invited-members.tab-pane
= paginate @project_members, theme: "gitlab" .card.card-without-border
= render 'shared/members/tab_pane/header' do
= render 'shared/members/tab_pane/title' do
= html_escape(_('Members invited to %{strong_start}%{project_name}%{strong_end}')) % { project_name: @project.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
%ul.content-list.members-list
= render partial: 'shared/members/member', collection: @invited_members, as: :member, locals: { membership_source: @project, group: group, current_user_is_group_owner: current_user_is_group_owner?(@project) }
- if show_access_requests?(@project, @requesters)
#tab-access-requests.tab-pane
.card.card-without-border
= render 'shared/members/tab_pane/header' do
= render 'shared/members/tab_pane/title' do
= html_escape(_('Users requesting access to %{strong_start}%{project_name}%{strong_end}')) % { project_name: @project.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
%ul.content-list.members-list
= render partial: 'shared/members/member', collection: @requesters, as: :member, locals: { membership_source: @project, group: group }
---
title: Reorganize project member management into tabs
merge_request: 49764
author:
type: changed
...@@ -21,7 +21,7 @@ project's **Members**. ...@@ -21,7 +21,7 @@ project's **Members**.
When your project belongs to the group, group members inherit the membership and permission When your project belongs to the group, group members inherit the membership and permission
level for the project from the group. level for the project from the group.
![Project members page](img/project_members.png) ![Project members page](img/project_members_13_8.png)
From the image above, we can deduce the following things: From the image above, we can deduce the following things:
...@@ -46,17 +46,17 @@ using the dropdown on the right side: ...@@ -46,17 +46,17 @@ using the dropdown on the right side:
Right next to **People**, start typing the name or username of the user you Right next to **People**, start typing the name or username of the user you
want to add. want to add.
![Search for people](img/add_user_search_people.png) ![Search for people](img/add_user_search_people_13_8.png)
Select the user and the [permission level](../../permissions.md) Select the user and the [permission level](../../permissions.md)
that you'd like to give the user. Note that you can select more than one user. that you'd like to give the user. Note that you can select more than one user.
![Give user permissions](img/add_user_give_permissions.png) ![Give user permissions](img/add_user_give_permissions_13_8.png)
Once done, select **Add users to project** and they are immediately added to Once done, select **Add users to project** and they are immediately added to
your project with the permissions you gave them above. your project with the permissions you gave them above.
![List members](img/add_user_list_members.png) ![List members](img/add_user_list_members_13_8.png)
From there on, you can either remove an existing user or change their access From there on, you can either remove an existing user or change their access
level to the project. level to the project.
...@@ -68,14 +68,14 @@ You can import another project's users in your own project by hitting the ...@@ -68,14 +68,14 @@ You can import another project's users in your own project by hitting the
In the dropdown menu, you can see only the projects you are Maintainer on. In the dropdown menu, you can see only the projects you are Maintainer on.
![Import members from another project](img/add_user_import_members_from_another_project.png) ![Import members from another project](img/add_user_import_members_from_another_project_13_8.png)
Select the one you want and hit **Import project members**. A flash message Select the one you want and hit **Import project members**. A flash message
displays, notifying you that the import was successful, and the new members displays, notifying you that the import was successful, and the new members
are now in the project's members list. Notice that the permissions that they are now in the project's members list. Notice that the permissions that they
had on the project you imported from are retained. had on the project you imported from are retained.
![Members list of new members](img/add_user_imported_members.png) ![Members list of new members](img/add_user_imported_members_13_8.png)
## Invite people using their e-mail address ## Invite people using their e-mail address
...@@ -83,18 +83,18 @@ If a user you want to give access to doesn't have an account on your GitLab ...@@ -83,18 +83,18 @@ If a user you want to give access to doesn't have an account on your GitLab
instance, you can invite them just by typing their e-mail address in the instance, you can invite them just by typing their e-mail address in the
user search field. user search field.
![Invite user by mail](img/add_user_email_search.png) ![Invite user by mail](img/add_user_email_search_13_8.png)
As you can imagine, you can mix inviting multiple people and adding existing As you can imagine, you can mix inviting multiple people and adding existing
GitLab users to the project. GitLab users to the project.
![Invite user by mail ready to submit](img/add_user_email_ready.png) ![Invite user by mail ready to submit](img/add_user_email_ready_13_8.png)
Once done, hit **Add users to project** and watch that there is a new member Once done, hit **Add users to project** and watch that there is a new member
with the e-mail address we used above. From there on, you can resend the with the e-mail address we used above. From there on, you can resend the
invitation, change their access level, or even delete them. invitation, change their access level, or even delete them.
![Invite user members list](img/add_user_email_accept.png) ![Invite user members list](img/add_user_email_accept_13_8.png)
While unaccepted, the system automatically sends reminder emails on the second, fifth, While unaccepted, the system automatically sends reminder emails on the second, fifth,
and tenth day after the invitation was initially sent. and tenth day after the invitation was initially sent.
...@@ -130,7 +130,7 @@ NOTE: ...@@ -130,7 +130,7 @@ NOTE:
If a project does not have any maintainers, the notification is sent to the If a project does not have any maintainers, the notification is sent to the
most recently active owners of the project's group. most recently active owners of the project's group.
![Manage access requests](img/access_requests_management.png) ![Manage access requests](img/access_requests_management_13_8.png)
If you change your mind before your request is approved, just click the If you change your mind before your request is approved, just click the
**Withdraw Access Request** button. **Withdraw Access Request** button.
......
...@@ -26,19 +26,20 @@ To share 'Project Acme' with the 'Engineering' group: ...@@ -26,19 +26,20 @@ To share 'Project Acme' with the 'Engineering' group:
1. For 'Project Acme' use the left navigation menu to go to **Members**. 1. For 'Project Acme' use the left navigation menu to go to **Members**.
![share project with groups](img/share_project_with_groups_tab_v13_6.png) ![share project with groups](img/share_project_with_groups_tab_v13_8.png)
1. Select the **Invite group** tab. 1. Select the **Invite group** tab.
1. Add the 'Engineering' group with the maximum access level of your choice. 1. Add the 'Engineering' group with the maximum access level of your choice.
1. Optionally, select an expiring date. 1. Optionally, select an expiring date.
1. Click **Invite**. 1. Click **Invite**.
1. After sharing 'Project Acme' with 'Engineering':
- The group is listed in the **Groups** tab.
![share project with groups tab](img/share_project_with_groups_tab_v13_6.png) !['Engineering' group is listed in Groups tab](img/project_groups_tab_13_8.png)
1. After sharing 'Project Acme' with 'Engineering', the project is listed - The project is listed on the group dashboard.
on the group dashboard
!['Project Acme' is listed as a shared project for 'Engineering'](img/other_group_sees_shared_project_v13_6.png) !['Project Acme' is listed as a shared project for 'Engineering'](img/other_group_sees_shared_project_v13_8.png)
Note that you can only share a project with: Note that you can only share a project with:
......
...@@ -75,7 +75,9 @@ RSpec.describe 'Project > Members > Invite group and members', :js do ...@@ -75,7 +75,9 @@ RSpec.describe 'Project > Members > Invite group and members', :js do
page.find('body').click page.find('body').click
find('.btn-success').click find('.btn-success').click
page.within('.project-members-groups') do click_link 'Groups'
page.within('[data-testid="project-member-groups"]') do
expect(page).to have_content(group_to_share_with.name) expect(page).to have_content(group_to_share_with.name)
end end
end end
......
...@@ -11559,9 +11559,6 @@ msgstr "" ...@@ -11559,9 +11559,6 @@ msgstr ""
msgid "Existing branch name, tag, or commit SHA" msgid "Existing branch name, tag, or commit SHA"
msgstr "" msgstr ""
msgid "Existing members and groups"
msgstr ""
msgid "Existing projects may be moved into a group" msgid "Existing projects may be moved into a group"
msgstr "" msgstr ""
...@@ -12428,9 +12425,6 @@ msgstr "" ...@@ -12428,9 +12425,6 @@ msgstr ""
msgid "Find by path" msgid "Find by path"
msgstr "" msgstr ""
msgid "Find existing members by name"
msgstr ""
msgid "Find file" msgid "Find file"
msgstr "" msgstr ""
...@@ -17272,6 +17266,9 @@ msgstr "" ...@@ -17272,6 +17266,9 @@ msgstr ""
msgid "Members invited to %{strong_start}%{group_name}%{strong_end}" msgid "Members invited to %{strong_start}%{group_name}%{strong_end}"
msgstr "" msgstr ""
msgid "Members invited to %{strong_start}%{project_name}%{strong_end}"
msgstr ""
msgid "Members listed as CODEOWNERS of affected files." msgid "Members listed as CODEOWNERS of affected files."
msgstr "" msgstr ""
...@@ -30733,6 +30730,9 @@ msgstr "" ...@@ -30733,6 +30730,9 @@ msgstr ""
msgid "Users requesting access to %{strong_start}%{group_name}%{strong_end}" msgid "Users requesting access to %{strong_start}%{group_name}%{strong_end}"
msgstr "" msgstr ""
msgid "Users requesting access to %{strong_start}%{project_name}%{strong_end}"
msgstr ""
msgid "Users were successfully added." msgid "Users were successfully added."
msgstr "" msgstr ""
......
...@@ -17,6 +17,7 @@ module QA ...@@ -17,6 +17,7 @@ module QA
view 'app/views/projects/project_members/index.html.haml' do view 'app/views/projects/project_members/index.html.haml' do
element :invite_group_tab element :invite_group_tab
element :groups_list_tab
end end
view 'app/views/shared/members/_invite_group.html.haml' do view 'app/views/shared/members/_invite_group.html.haml' do
...@@ -48,6 +49,7 @@ module QA ...@@ -48,6 +49,7 @@ module QA
def remove_group(group_name) def remove_group(group_name)
click_element :invite_group_tab click_element :invite_group_tab
click_element :groups_list_tab
page.accept_alert do page.accept_alert do
within_element(:group_row, text: group_name) do within_element(:group_row, text: group_name) do
click_element :delete_group_access_link click_element :delete_group_access_link
......
...@@ -14,32 +14,137 @@ RSpec.describe Projects::ProjectMembersController do ...@@ -14,32 +14,137 @@ RSpec.describe Projects::ProjectMembersController do
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
end end
context 'when project belongs to group' do context 'project members' do
let(:user_in_group) { create(:user) } context 'when project belongs to group' do
let(:project_in_group) { create(:project, :public, group: group) } let(:user_in_group) { create(:user) }
let(:project_in_group) { create(:project, :public, group: group) }
before do
group.add_owner(user_in_group)
project_in_group.add_maintainer(user)
sign_in(user)
end
it 'lists inherited project members by default' do
get :index, params: { namespace_id: project_in_group.namespace, project_id: project_in_group }
expect(assigns(:project_members).map(&:user_id)).to contain_exactly(user.id, user_in_group.id)
end
it 'lists direct project members only' do
get :index, params: { namespace_id: project_in_group.namespace, project_id: project_in_group, with_inherited_permissions: 'exclude' }
expect(assigns(:project_members).map(&:user_id)).to contain_exactly(user.id)
end
it 'lists inherited project members only' do
get :index, params: { namespace_id: project_in_group.namespace, project_id: project_in_group, with_inherited_permissions: 'only' }
expect(assigns(:project_members).map(&:user_id)).to contain_exactly(user_in_group.id)
end
end
context 'when invited members are present' do
let!(:invited_member) { create(:project_member, :invited, project: project) }
before do
project.add_maintainer(user)
sign_in(user)
end
it 'excludes the invited members from project members list' do
get :index, params: { namespace_id: project.namespace, project_id: project }
expect(assigns(:project_members).map(&:invite_email)).not_to contain_exactly(invited_member.invite_email)
end
end
end
context 'group links' do
let!(:project_group_link) { create(:project_group_link, project: project, group: group) }
it 'lists group links' do
get :index, params: { namespace_id: project.namespace, project_id: project }
expect(assigns(:group_links).map(&:id)).to contain_exactly(project_group_link.id)
end
context 'when `search_groups` param is present' do
let(:group_2) { create(:group, :public, name: 'group_2') }
let!(:project_group_link_2) { create(:project_group_link, project: project, group: group_2) }
it 'lists group links that match search' do
get :index, params: { namespace_id: project.namespace, project_id: project, search_groups: 'group_2' }
expect(assigns(:group_links).map(&:id)).to contain_exactly(project_group_link_2.id)
end
end
end
context 'invited members' do
let!(:invited_member) { create(:project_member, :invited, project: project) }
before do before do
group.add_owner(user_in_group) project.add_maintainer(user)
project_in_group.add_maintainer(user)
sign_in(user) sign_in(user)
end end
it 'lists inherited project members by default' do context 'when user has `admin_project_member` permissions' do
get :index, params: { namespace_id: project_in_group.namespace, project_id: project_in_group } before do
allow(controller.helpers).to receive(:can_manage_project_members?).with(project).and_return(true)
end
it 'lists invited members' do
get :index, params: { namespace_id: project.namespace, project_id: project }
expect(assigns(:invited_members).map(&:invite_email)).to contain_exactly(invited_member.invite_email)
end
end
context 'when user does not have `admin_project_member` permissions' do
before do
allow(controller.helpers).to receive(:can_manage_project_members?).with(project).and_return(false)
end
it 'does not list invited members' do
get :index, params: { namespace_id: project.namespace, project_id: project }
expect(assigns(:invited_members)).to be_nil
end
end
end
context 'access requests' do
let(:access_requester_user) { create(:user) }
expect(assigns(:project_members).map(&:user_id)).to contain_exactly(user.id, user_in_group.id) before do
project.request_access(access_requester_user)
project.add_maintainer(user)
sign_in(user)
end end
it 'lists direct project members only' do context 'when user has `admin_project_member` permissions' do
get :index, params: { namespace_id: project_in_group.namespace, project_id: project_in_group, with_inherited_permissions: 'exclude' } before do
allow(controller.helpers).to receive(:can_manage_project_members?).with(project).and_return(true)
end
it 'lists access requests' do
get :index, params: { namespace_id: project.namespace, project_id: project }
expect(assigns(:project_members).map(&:user_id)).to contain_exactly(user.id) expect(assigns(:requesters).map(&:user_id)).to contain_exactly(access_requester_user.id)
end
end end
it 'lists inherited project members only' do context 'when user does not have `admin_project_member` permissions' do
get :index, params: { namespace_id: project_in_group.namespace, project_id: project_in_group, with_inherited_permissions: 'only' } before do
allow(controller.helpers).to receive(:can_manage_project_members?).with(project).and_return(false)
end
it 'does not list access requests' do
get :index, params: { namespace_id: project.namespace, project_id: project }
expect(assigns(:project_members).map(&:user_id)).to contain_exactly(user_in_group.id) expect(assigns(:requesters)).to be_nil
end
end end
end end
end end
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
FactoryBot.define do FactoryBot.define do
factory :project_group_link do factory :project_group_link do
project project
group group { association(:group) }
expires_at { nil } expires_at { nil }
group_access { Gitlab::Access::DEVELOPER } group_access { Gitlab::Access::DEVELOPER }
......
...@@ -15,7 +15,9 @@ FactoryBot.define do ...@@ -15,7 +15,9 @@ FactoryBot.define do
trait(:invited) do trait(:invited) do
user_id { nil } user_id { nil }
invite_token { 'xxx' } invite_token { 'xxx' }
invite_email { 'email@email.com' } sequence :invite_email do |n|
"email#{n}@email.com"
end
end end
trait :blocked do trait :blocked do
......
...@@ -4,7 +4,6 @@ require 'spec_helper' ...@@ -4,7 +4,6 @@ require 'spec_helper'
RSpec.describe 'Groups > Members > Maintainer manages access requests' do RSpec.describe 'Groups > Members > Maintainer manages access requests' do
it_behaves_like 'Maintainer manages access requests' do it_behaves_like 'Maintainer manages access requests' do
let(:has_tabs) { true }
let(:entity) { create(:group, :public) } let(:entity) { create(:group, :public) }
let(:members_page_path) { group_group_members_path(entity) } let(:members_page_path) { group_group_members_path(entity) }
end end
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'Projects members' do RSpec.describe 'Projects members', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:developer) { create(:user) } let(:developer) { create(:user) }
let(:group) { create(:group, :public) } let(:group) { create(:group, :public) }
...@@ -66,62 +66,60 @@ RSpec.describe 'Projects members' do ...@@ -66,62 +66,60 @@ RSpec.describe 'Projects members' do
end end
end end
context 'with a group and a project invitee' do context 'with a group, a project invitee, and a project requester' do
before do before do
group.request_access(group_requester)
project.request_access(project_requester)
group_invitee group_invitee
project_invitee project_invitee
visit project_project_members_path(project) visit project_project_members_path(project)
end end
it 'shows the project invitee, the project developer, and the group owner' do it 'shows the group owner' do
page.within first('.content-list') do page.within first('.content-list') do
expect(page).to have_content('test1@abc.com')
expect(page).not_to have_content('test2@abc.com')
# Project developer
expect(page).to have_content(developer.name)
# Group owner # Group owner
expect(page).to have_content(user.name) expect(page).to have_content(user.name)
expect(page).to have_content(group.name) expect(page).to have_content(group.name)
end end
end end
end
context 'with a group requester' do it 'shows the project developer' do
before do page.within first('.content-list') do
group.request_access(group_requester) # Project developer
visit project_project_members_path(project) expect(page).to have_content(developer.name)
end
end end
it 'does not appear in the project members page' do it 'shows the project invitee' do
click_link 'Invited'
page.within first('.content-list') do page.within first('.content-list') do
expect(page).to have_content('test1@abc.com')
expect(page).not_to have_content('test2@abc.com')
end
end
it 'shows the project requester' do
click_link 'Access requests'
page.within first('.content-list') do
expect(page).to have_content(project_requester.name)
expect(page).not_to have_content(group_requester.name) expect(page).not_to have_content(group_requester.name)
end end
end end
end end
context 'with a group and a project requesters' do context 'with a group requester' do
before do before do
group.request_access(group_requester) group.request_access(group_requester)
project.request_access(project_requester)
visit project_project_members_path(project) visit project_project_members_path(project)
end end
it 'shows the project requester, the project developer, and the group owner' do it 'does not appear in the project members page' do
expect(page).not_to have_link('Access requests')
page.within first('.content-list') do page.within first('.content-list') do
expect(page).to have_content(project_requester.name)
expect(page).not_to have_content(group_requester.name) expect(page).not_to have_content(group_requester.name)
end end
page.within all('.content-list').last do
# Project developer
expect(page).to have_content(developer.name)
# Group owner
expect(page).to have_content(user.name)
expect(page).to have_content(group.name)
end
end end
end end
......
...@@ -16,6 +16,7 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do ...@@ -16,6 +16,7 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
visit project_project_members_path(project) visit project_project_members_path(project)
click_groups_tab
end end
it 'updates group access level' do it 'updates group access level' do
...@@ -29,6 +30,8 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do ...@@ -29,6 +30,8 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do
visit project_project_members_path(project) visit project_project_members_path(project)
click_groups_tab
expect(first('.group_member')).to have_content('Guest') expect(first('.group_member')).to have_content('Guest')
end end
...@@ -71,23 +74,31 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do ...@@ -71,23 +74,31 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do
expect(page).not_to have_selector('.group_member') expect(page).not_to have_selector('.group_member')
end end
context 'search in existing members (yes, this filters the groups list as well)' do context 'search in existing members' do
it 'finds no results' do it 'finds no results' do
page.within '.user-search-form' do page.within '.user-search-form' do
fill_in 'search', with: 'testing 123' fill_in 'search_groups', with: 'testing 123'
find('.user-search-btn').click find('.user-search-btn').click
end end
click_groups_tab
expect(page).not_to have_selector('.group_member') expect(page).not_to have_selector('.group_member')
end end
it 'finds results' do it 'finds results' do
page.within '.user-search-form' do page.within '.user-search-form' do
fill_in 'search', with: group.name fill_in 'search_groups', with: group.name
find('.user-search-btn').click find('.user-search-btn').click
end end
click_groups_tab
expect(page).to have_selector('.group_member', count: 1) expect(page).to have_selector('.group_member', count: 1)
end end
end end
def click_groups_tab
click_link 'Groups'
end
end end
...@@ -39,7 +39,7 @@ RSpec.describe 'Project > Members > Invite group', :js do ...@@ -39,7 +39,7 @@ RSpec.describe 'Project > Members > Invite group', :js do
it 'the project can be shared with another group' do it 'the project can be shared with another group' do
visit project_project_members_path(project) visit project_project_members_path(project)
expect(page).not_to have_css('.project-members-groups') expect(page).not_to have_link 'Groups'
click_on 'invite-group-tab' click_on 'invite-group-tab'
...@@ -47,7 +47,9 @@ RSpec.describe 'Project > Members > Invite group', :js do ...@@ -47,7 +47,9 @@ RSpec.describe 'Project > Members > Invite group', :js do
page.find('body').click page.find('body').click
find('.btn-success').click find('.btn-success').click
page.within('.project-members-groups') do click_link 'Groups'
page.within('[data-testid="project-member-groups"]') do
expect(page).to have_content(group_to_share_with.name) expect(page).to have_content(group_to_share_with.name)
end end
end end
...@@ -132,7 +134,9 @@ RSpec.describe 'Project > Members > Invite group', :js do ...@@ -132,7 +134,9 @@ RSpec.describe 'Project > Members > Invite group', :js do
end end
it 'the group link shows the expiration time with a warning class' do it 'the group link shows the expiration time with a warning class' do
page.within('.project-members-groups') do click_link 'Groups'
page.within('[data-testid="project-member-groups"]') do
# Using distance_of_time_in_words_to_now because it is not the same as # Using distance_of_time_in_words_to_now because it is not the same as
# subtraction, and this way avoids time zone issues as well # subtraction, and this way avoids time zone issues as well
expires_in_text = distance_of_time_in_words_to_now(project.project_group_links.first.expires_at) expires_in_text = distance_of_time_in_words_to_now(project.project_group_links.first.expires_at)
......
...@@ -82,7 +82,9 @@ RSpec.describe 'Project members list' do ...@@ -82,7 +82,9 @@ RSpec.describe 'Project members list' do
add_user('test@example.com', 'Reporter') add_user('test@example.com', 'Reporter')
page.within(second_row) do click_link 'Invited'
page.within(first_row) do
expect(page).to have_content('test@example.com') expect(page).to have_content('test@example.com')
expect(page).to have_content('Invited') expect(page).to have_content('Invited')
expect(page).to have_button('Reporter') expect(page).to have_button('Reporter')
......
...@@ -4,7 +4,6 @@ require 'spec_helper' ...@@ -4,7 +4,6 @@ require 'spec_helper'
RSpec.describe 'Projects > Members > Maintainer manages access requests' do RSpec.describe 'Projects > Members > Maintainer manages access requests' do
it_behaves_like 'Maintainer manages access requests' do it_behaves_like 'Maintainer manages access requests' do
let(:has_tabs) { false }
let(:entity) { create(:project, :public) } let(:entity) { create(:project, :public) }
let(:members_page_path) { project_project_members_path(entity) } let(:members_page_path) { project_project_members_path(entity) }
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Projects > Members > Tabs' do
using RSpec::Parameterized::TableSyntax
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, creator: user, namespace: user.namespace) }
let_it_be(:group) { create(:group) }
let_it_be(:project_members) { create_list(:project_member, 2, project: project) }
let_it_be(:access_requests) { create_list(:project_member, 2, :access_request, project: project) }
let_it_be(:invites) { create_list(:project_member, 2, :invited, project: project) }
let_it_be(:project_group_links) { create_list(:project_group_link, 2, project: project) }
shared_examples 'active "Members" tab' do
it 'displays "Members" tab' do
expect(page).to have_selector('.nav-link.active', text: 'Members')
end
end
before do
allow(Kaminari.config).to receive(:default_per_page).and_return(1)
sign_in(user)
visit project_project_members_path(project)
end
where(:tab, :count) do
'Members' | 3
'Invited' | 2
'Groups' | 2
'Access requests' | 2
end
with_them do
it "renders #{params[:tab]} tab" do
expect(page).to have_selector('.nav-link', text: "#{tab} #{count}")
end
end
context 'displays "Members" tab by default' do
it_behaves_like 'active "Members" tab'
end
context 'when searching "Groups"', :js do
before do
click_link 'Groups'
page.within '[data-testid="group-link-search-form"]' do
fill_in 'search_groups', with: 'group'
find('button[type="submit"]').click
end
end
it 'displays "Groups" tab' do
expect(page).to have_selector('.nav-link.active', text: 'Groups')
end
context 'and then searching "Members"' do
before do
click_link 'Members 3'
page.within '[data-testid="user-search-form"]' do
fill_in 'search', with: 'user'
find('button[type="submit"]').click
end
end
it_behaves_like 'active "Members" tab'
end
end
end
...@@ -57,7 +57,7 @@ RSpec.describe 'Projects > Settings > User manages project members' do ...@@ -57,7 +57,7 @@ RSpec.describe 'Projects > Settings > User manages project members' do
end end
end end
it 'shows all members of project shared group' do it 'shows all members of project shared group', :js do
group.add_owner(user) group.add_owner(user)
group.add_developer(user_dmitriy) group.add_developer(user_dmitriy)
...@@ -67,7 +67,9 @@ RSpec.describe 'Projects > Settings > User manages project members' do ...@@ -67,7 +67,9 @@ RSpec.describe 'Projects > Settings > User manages project members' do
visit(project_project_members_path(project)) visit(project_project_members_path(project))
page.within('.project-members-groups') do click_link 'Groups'
page.within('[data-testid="project-member-groups"]') do
expect(page).to have_content('OpenSource') expect(page).to have_content('OpenSource')
expect(first('.group_member')).to have_content('Maintainer') expect(first('.group_member')).to have_content('Maintainer')
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::ProjectMembersHelper do
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let(:allow_admin_project) { nil }
before do
allow(helper).to receive(:current_user).and_return(current_user)
allow(helper).to receive(:can?).with(current_user, :admin_project_member, project).and_return(allow_admin_project)
end
shared_examples 'when `current_user` does not have `admin_project_member` permissions' do
let(:allow_admin_project) { false }
it { is_expected.to be(false) }
end
describe '#can_manage_project_members?' do
subject { helper.can_manage_project_members?(project) }
context 'when `current_user` has `admin_project_member` permissions' do
let(:allow_admin_project) { true }
it { is_expected.to be(true) }
end
include_examples 'when `current_user` does not have `admin_project_member` permissions'
end
describe '#show_groups?' do
subject { helper.show_groups?(project.project_group_links) }
context 'when group links exist' do
let!(:project_group_link) { create(:project_group_link, project: project) }
it { is_expected.to be(true) }
end
context 'when `search_groups` param is set' do
before do
allow(helper).to receive(:params).and_return({ search_groups: 'foo' })
end
it { is_expected.to be(true) }
end
context 'when `search_groups` param is not set and group links do not exist' do
it { is_expected.to be(false) }
end
end
describe '#show_invited_members?' do
subject { helper.show_invited_members?(project, project.project_members.invite) }
context 'when `current_user` has `admin_project_member` permissions' do
let(:allow_admin_project) { true }
context 'when invited members exist' do
let!(:invite) { create(:project_member, :invited, project: project) }
it { is_expected.to be(true) }
end
context 'when invited members do not exist' do
it { is_expected.to be(false) }
end
end
include_examples 'when `current_user` does not have `admin_project_member` permissions'
end
describe '#show_access_requests?' do
subject { helper.show_access_requests?(project, project.requesters) }
context 'when `current_user` has `admin_project_member` permissions' do
let(:allow_admin_project) { true }
context 'when access requests exist' do
let!(:access_request) { create(:project_member, :access_request, project: project) }
it { is_expected.to be(true) }
end
context 'when access requests do not exist' do
it { is_expected.to be(false) }
end
end
include_examples 'when `current_user` does not have `admin_project_member` permissions'
end
describe '#groups_tab_active?' do
subject { helper.groups_tab_active? }
context 'when `search_groups` param is set' do
before do
allow(helper).to receive(:params).and_return({ search_groups: 'foo' })
end
it { is_expected.to be(true) }
end
context 'when `search_groups` param is not set' do
it { is_expected.to be(false) }
end
end
describe '#current_user_is_group_owner?' do
let(:group) { create(:group) }
subject { helper.current_user_is_group_owner?(project2) }
describe "when current user is the owner of the project's parent group" do
let(:project2) { create(:project, namespace: group) }
before do
group.add_owner(current_user)
end
it { is_expected.to be(true) }
end
describe "when current user is not the owner of the project's parent group" do
let_it_be(:user) { create(:user) }
let(:project2) { create(:project, namespace: group) }
before do
group.add_owner(user)
end
it { is_expected.to be(false) }
end
describe "when project does not have a parent group" do
let(:user) { create(:user) }
let(:project2) { create(:project, namespace: user.namespace) }
it { is_expected.to be(false) }
end
end
end
...@@ -12,9 +12,7 @@ RSpec.shared_examples 'Maintainer manages access requests' do ...@@ -12,9 +12,7 @@ RSpec.shared_examples 'Maintainer manages access requests' do
sign_in(maintainer) sign_in(maintainer)
visit members_page_path visit members_page_path
if has_tabs click_on 'Access requests'
click_on 'Access requests'
end
end end
it 'maintainer can see access requests', :js do it 'maintainer can see access requests', :js do
...@@ -48,11 +46,7 @@ RSpec.shared_examples 'Maintainer manages access requests' do ...@@ -48,11 +46,7 @@ RSpec.shared_examples 'Maintainer manages access requests' do
end end
def expect_visible_access_request(entity, user) def expect_visible_access_request(entity, user)
if has_tabs expect(page).to have_content "Access requests 1"
expect(page).to have_content "Access requests 1"
else
expect(page).to have_content "Users requesting access to #{entity.name} 1"
end
expect(page).to have_content user.name expect(page).to have_content user.name
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment