diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 631f627838b..5972b29a298 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -17,17 +17,18 @@ class Projects::ProjectMembersController < Projects::ApplicationController @skip_groups += @project.group.self_and_ancestors_ids if @project.group @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) .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( - AccessRequestsFinder.new(@project).execute(current_user) - ) + @project_members = present_members(project_members.non_invite.page(params[:page])) @project_member = @project.project_members.new end diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb new file mode 100644 index 00000000000..168526d2abb --- /dev/null +++ b/app/helpers/projects/project_members_helper.rb @@ -0,0 +1,29 @@ +# 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 diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index f9939f19c1a..f13c1f29041 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -53,18 +53,18 @@ #tab-members.tab-pane{ class: ('active' unless invited_active) } .card.card-without-border - unless filtered_search_enabled - = render 'groups/group_members/tab_pane/header' do - = render 'groups/group_members/tab_pane/title' do + = render 'shared/members/tab_pane/header' 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: ''.html_safe, strong_end: ''.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 .gl-px-3.gl-py-2 .search-control-wrap.gl-relative = render 'shared/members/search_field' - 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 = 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 = render 'shared/members/sort_dropdown' .js-group-members-list{ data: group_members_list_data_attributes(@group, @members) } @@ -75,8 +75,8 @@ #tab-groups.tab-pane .card.card-without-border - unless filtered_search_enabled - = render 'groups/group_members/tab_pane/header' do - = render 'groups/group_members/tab_pane/title' do + = render 'shared/members/tab_pane/header' 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: ''.html_safe, strong_end: ''.html_safe } .js-group-linked-list{ data: linked_groups_list_data_attributes(@group) } .loading @@ -85,8 +85,8 @@ #tab-invited-members.tab-pane{ class: ('active' if invited_active) } .card.card-without-border - unless filtered_search_enabled - = render 'groups/group_members/tab_pane/header' do - = render 'groups/group_members/tab_pane/title' do + = render 'shared/members/tab_pane/header' 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: ''.html_safe, strong_end: ''.html_safe } = 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' @@ -98,8 +98,8 @@ #tab-access-requests.tab-pane .card.card-without-border - unless filtered_search_enabled - = render 'groups/group_members/tab_pane/header' do - = render 'groups/group_members/tab_pane/title' do + = render 'shared/members/tab_pane/header' 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: ''.html_safe, strong_end: ''.html_safe } .js-group-access-requests-list{ data: group_members_list_data_attributes(@group, @requesters) } .loading diff --git a/app/views/projects/project_members/_groups.html.haml b/app/views/projects/project_members/_groups.html.haml index 39ef1e52a0d..fe8a50ebb42 100644 --- a/app/views/projects/project_members/_groups.html.haml +++ b/app/views/projects/project_members/_groups.html.haml @@ -1,8 +1,11 @@ -.card.project-members-groups - .card-header - = html_escape(_("Groups with access to %{strong_open}%{project_name}%{strong_close}")) % { project_name: sanitize(@project.name, tags: []), strong_open: ''.html_safe, strong_close: ''.html_safe } - %span.badge.badge-pill= group_links.size - %ul.content-list.members-list - - can_admin_member = can?(current_user, :admin_project_member, @project) +.card.card-without-border + = render 'shared/members/tab_pane/header' do + = render 'shared/members/tab_pane/title' do + = html_escape(_("Groups with access to %{strong_open}%{project_name}%{strong_close}")) % { project_name: sanitize(@project.name, tags: []), strong_open: ''.html_safe, strong_close: ''.html_safe } + = 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 + .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| - = 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) diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index 171212b6a96..24ca7ebded9 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -1,20 +1,18 @@ - project = local_assigns.fetch(:project) - members = local_assigns.fetch(:members) - 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-header.flex-project-members-panel - %span.flex-project-title +.card.card-without-border + = render 'shared/members/tab_pane/header' do + = 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: ''.html_safe, strong_close: ''.html_safe } - %span.badge.badge-pill= members.total_count - = form_tag project_project_members_path(project), method: :get, class: 'form-inline user-search-form flex-users-form' do - .form-group - .position-relative - = search_field_tag :search, params[:search], { placeholder: _('Find existing members by name'), class: 'form-control', spellcheck: false } - %button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") } - = sprite_icon('search', css_class: 'gl-vertical-align-middle!') - = label_tag :sort_by, _('Sort by'), class: 'col-form-label label-bold px-2' + = 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 + .gl-px-3.gl-py-2 + .search-control-wrap.gl-relative + = render 'shared/members/search_field' + = render 'shared/members/tab_pane/form_item' do + = label_tag :sort_by, _('Sort by'), class: 'label-bold gl-mr-2 gl-mb-0 gl-py-2 align-self-md-center' = render 'shared/members/sort_dropdown' %ul.content-list.members-list{ data: { qa_selector: 'members_list', testid: 'members-table' } } = render partial: 'shared/members/member', diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index cad76d7aeac..0f5f169f548 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -1,5 +1,4 @@ - page_title _("Members") -- can_admin_project_members = can?(current_user, :admin_project_member, @project) - group = @project.group .js-remove-member-modal @@ -8,37 +7,73 @@ - if project_can_be_shared? %h4 = _("Project members") - - if can_admin_project_members + - if can_manage_project_members?(@project) %p= share_project_description(@project) - else %p = html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: ''.html_safe, i_close: ''.html_safe } - .light - - if can_admin_project_members && project_can_be_shared? - - if !membership_locked? && @project.allowed_to_share_with_group? - %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } - %li.nav-tab{ role: 'presentation' } - %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?) } - %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group") + - if can_manage_project_members?(@project) && project_can_be_shared? + - if !membership_locked? && @project.allowed_to_share_with_group? + %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } + %li.nav-tab{ role: 'presentation' } + %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?) } + %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-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) - .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' - - 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) - - 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' - - = render 'shared/members/requests', membership_source: @project, group: group, requesters: @requesters - .clearfix - %h5.member.existing-title - = _("Existing members and groups") - - if @group_links.any? + .tab-content.gitlab-tab-content + .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) + .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' + - 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) + - 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' + %ul.nav-links.mobile-separator.nav.nav-tabs + %li.nav-item + = link_to '#tab-members', class: ['nav-link', ('active' unless groups_tab_active?)], data: { toggle: 'tab' } do + %span + = _('Members') + %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/team', project: @project, group: group, members: @project_members - = paginate @project_members, theme: "gitlab" + - if show_invited_members?(@project, @invited_members) + #tab-invited-members.tab-pane + .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: ''.html_safe, strong_end: ''.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: ''.html_safe, strong_end: ''.html_safe } + %ul.content-list.members-list + = render partial: 'shared/members/member', collection: @requesters, as: :member, locals: { membership_source: @project, group: group } diff --git a/app/views/groups/group_members/tab_pane/_form_item.html.haml b/app/views/shared/members/tab_pane/_form_item.html.haml similarity index 100% rename from app/views/groups/group_members/tab_pane/_form_item.html.haml rename to app/views/shared/members/tab_pane/_form_item.html.haml diff --git a/app/views/groups/group_members/tab_pane/_header.html.haml b/app/views/shared/members/tab_pane/_header.html.haml similarity index 100% rename from app/views/groups/group_members/tab_pane/_header.html.haml rename to app/views/shared/members/tab_pane/_header.html.haml diff --git a/app/views/groups/group_members/tab_pane/_title.html.haml b/app/views/shared/members/tab_pane/_title.html.haml similarity index 100% rename from app/views/groups/group_members/tab_pane/_title.html.haml rename to app/views/shared/members/tab_pane/_title.html.haml diff --git a/changelogs/unreleased/281824-convert-project-members-list-view-from-haml-to-vue-setup-tabs.yml b/changelogs/unreleased/281824-convert-project-members-list-view-from-haml-to-vue-setup-tabs.yml new file mode 100644 index 00000000000..1c1e4c48bad --- /dev/null +++ b/changelogs/unreleased/281824-convert-project-members-list-view-from-haml-to-vue-setup-tabs.yml @@ -0,0 +1,5 @@ +--- +title: Reorganize project member management into tabs +merge_request: 49764 +author: +type: changed diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 8cdfbd558ca..e1205346585 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -516,12 +516,14 @@ class MyMigration < ActiveRecord::Migration[6.0] disable_ddl_transaction! + INDEX_NAME = 'index_name' + def up - add_concurrent_index :table, :column + add_concurrent_index :table, :column, name: INDEX_NAME end def down - remove_concurrent_index :table, :column, name: index_name + remove_concurrent_index :table, :column, name: INDEX_NAME end end ``` diff --git a/doc/user/project/members/img/access_requests_management.png b/doc/user/project/members/img/access_requests_management.png deleted file mode 100644 index 9a1c9621e41..00000000000 Binary files a/doc/user/project/members/img/access_requests_management.png and /dev/null differ diff --git a/doc/user/project/members/img/access_requests_management_13_8.png b/doc/user/project/members/img/access_requests_management_13_8.png new file mode 100644 index 00000000000..950ef4dec01 Binary files /dev/null and b/doc/user/project/members/img/access_requests_management_13_8.png differ diff --git a/doc/user/project/members/img/add_user_email_accept.png b/doc/user/project/members/img/add_user_email_accept.png deleted file mode 100644 index cbee9e08c70..00000000000 Binary files a/doc/user/project/members/img/add_user_email_accept.png and /dev/null differ diff --git a/doc/user/project/members/img/add_user_email_accept_13_8.png b/doc/user/project/members/img/add_user_email_accept_13_8.png new file mode 100644 index 00000000000..ed980036af5 Binary files /dev/null and b/doc/user/project/members/img/add_user_email_accept_13_8.png differ diff --git a/doc/user/project/members/img/add_user_email_ready.png b/doc/user/project/members/img/add_user_email_ready.png deleted file mode 100644 index 0066eb3427b..00000000000 Binary files a/doc/user/project/members/img/add_user_email_ready.png and /dev/null differ diff --git a/doc/user/project/members/img/add_user_email_ready_13_8.png b/doc/user/project/members/img/add_user_email_ready_13_8.png new file mode 100644 index 00000000000..a610b46a176 Binary files /dev/null and b/doc/user/project/members/img/add_user_email_ready_13_8.png differ diff --git a/doc/user/project/members/img/add_user_email_search.png b/doc/user/project/members/img/add_user_email_search.png deleted file mode 100644 index 66bcd6aad80..00000000000 Binary files a/doc/user/project/members/img/add_user_email_search.png and /dev/null differ diff --git a/doc/user/project/members/img/add_user_email_search_13_8.png b/doc/user/project/members/img/add_user_email_search_13_8.png new file mode 100644 index 00000000000..934cf19bd3d Binary files /dev/null and b/doc/user/project/members/img/add_user_email_search_13_8.png differ diff --git a/doc/user/project/members/img/add_user_give_permissions.png b/doc/user/project/members/img/add_user_give_permissions.png deleted file mode 100644 index 376a3eefccc..00000000000 Binary files a/doc/user/project/members/img/add_user_give_permissions.png and /dev/null differ diff --git a/doc/user/project/members/img/add_user_give_permissions_13_8.png b/doc/user/project/members/img/add_user_give_permissions_13_8.png new file mode 100644 index 00000000000..1916d056a52 Binary files /dev/null and b/doc/user/project/members/img/add_user_give_permissions_13_8.png differ diff --git a/doc/user/project/members/img/add_user_import_members_from_another_project.png b/doc/user/project/members/img/add_user_import_members_from_another_project.png deleted file mode 100644 index cb3b70bd4b5..00000000000 Binary files a/doc/user/project/members/img/add_user_import_members_from_another_project.png and /dev/null differ diff --git a/doc/user/project/members/img/add_user_import_members_from_another_project_13_8.png b/doc/user/project/members/img/add_user_import_members_from_another_project_13_8.png new file mode 100644 index 00000000000..a6dddec3fb7 Binary files /dev/null and b/doc/user/project/members/img/add_user_import_members_from_another_project_13_8.png differ diff --git a/doc/user/project/members/img/add_user_imported_members.png b/doc/user/project/members/img/add_user_imported_members.png deleted file mode 100644 index 51fd7688890..00000000000 Binary files a/doc/user/project/members/img/add_user_imported_members.png and /dev/null differ diff --git a/doc/user/project/members/img/add_user_imported_members_13_8.png b/doc/user/project/members/img/add_user_imported_members_13_8.png new file mode 100644 index 00000000000..725e447604f Binary files /dev/null and b/doc/user/project/members/img/add_user_imported_members_13_8.png differ diff --git a/doc/user/project/members/img/add_user_list_members.png b/doc/user/project/members/img/add_user_list_members.png deleted file mode 100644 index e0fa404288d..00000000000 Binary files a/doc/user/project/members/img/add_user_list_members.png and /dev/null differ diff --git a/doc/user/project/members/img/add_user_list_members_13_8.png b/doc/user/project/members/img/add_user_list_members_13_8.png new file mode 100644 index 00000000000..b8c0160c6d8 Binary files /dev/null and b/doc/user/project/members/img/add_user_list_members_13_8.png differ diff --git a/doc/user/project/members/img/add_user_search_people.png b/doc/user/project/members/img/add_user_search_people.png deleted file mode 100644 index 41767a9167c..00000000000 Binary files a/doc/user/project/members/img/add_user_search_people.png and /dev/null differ diff --git a/doc/user/project/members/img/add_user_search_people_13_8.png b/doc/user/project/members/img/add_user_search_people_13_8.png new file mode 100644 index 00000000000..e9aa58512ab Binary files /dev/null and b/doc/user/project/members/img/add_user_search_people_13_8.png differ diff --git a/doc/user/project/members/img/other_group_sees_shared_project_v13_6.png b/doc/user/project/members/img/other_group_sees_shared_project_v13_6.png deleted file mode 100644 index e6e3f8f043b..00000000000 Binary files a/doc/user/project/members/img/other_group_sees_shared_project_v13_6.png and /dev/null differ diff --git a/doc/user/project/members/img/other_group_sees_shared_project_v13_8.png b/doc/user/project/members/img/other_group_sees_shared_project_v13_8.png new file mode 100644 index 00000000000..aa2aaf071e1 Binary files /dev/null and b/doc/user/project/members/img/other_group_sees_shared_project_v13_8.png differ diff --git a/doc/user/project/members/img/project_groups_tab_13_8.png b/doc/user/project/members/img/project_groups_tab_13_8.png new file mode 100644 index 00000000000..5d7948f0761 Binary files /dev/null and b/doc/user/project/members/img/project_groups_tab_13_8.png differ diff --git a/doc/user/project/members/img/project_members.png b/doc/user/project/members/img/project_members.png deleted file mode 100644 index 218f5a24d2e..00000000000 Binary files a/doc/user/project/members/img/project_members.png and /dev/null differ diff --git a/doc/user/project/members/img/project_members_13_8.png b/doc/user/project/members/img/project_members_13_8.png new file mode 100644 index 00000000000..9120d471b3b Binary files /dev/null and b/doc/user/project/members/img/project_members_13_8.png differ diff --git a/doc/user/project/members/img/share_project_with_groups_tab_v13_6.png b/doc/user/project/members/img/share_project_with_groups_tab_v13_6.png deleted file mode 100644 index 7d83659ef7a..00000000000 Binary files a/doc/user/project/members/img/share_project_with_groups_tab_v13_6.png and /dev/null differ diff --git a/doc/user/project/members/img/share_project_with_groups_tab_v13_8.png b/doc/user/project/members/img/share_project_with_groups_tab_v13_8.png new file mode 100644 index 00000000000..6cbbb386396 Binary files /dev/null and b/doc/user/project/members/img/share_project_with_groups_tab_v13_8.png differ diff --git a/doc/user/project/members/img/share_project_with_groups_v13_6.png b/doc/user/project/members/img/share_project_with_groups_v13_6.png deleted file mode 100644 index 121e77671a3..00000000000 Binary files a/doc/user/project/members/img/share_project_with_groups_v13_6.png and /dev/null differ diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md index 85cb139c45b..cccb998fc31 100644 --- a/doc/user/project/members/index.md +++ b/doc/user/project/members/index.md @@ -21,7 +21,7 @@ project's **Members**. When your project belongs to the group, group members inherit the membership and permission 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: @@ -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 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) 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 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 level to the project. @@ -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. -![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 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 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 @@ -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 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 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 with the e-mail address we used above. From there on, you can resend the 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, and tenth day after the invitation was initially sent. @@ -130,7 +130,7 @@ NOTE: If a project does not have any maintainers, the notification is sent to the 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 **Withdraw Access Request** button. diff --git a/doc/user/project/members/share_project_with_groups.md b/doc/user/project/members/share_project_with_groups.md index edfe8ae3b5b..d17717fb29c 100644 --- a/doc/user/project/members/share_project_with_groups.md +++ b/doc/user/project/members/share_project_with_groups.md @@ -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**. - ![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. Add the 'Engineering' group with the maximum access level of your choice. 1. Optionally, select an expiring date. 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 - on the group dashboard + - The project is listed 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: diff --git a/lib/gitlab/error_tracking.rb b/lib/gitlab/error_tracking.rb index a5ace2be773..e6877d73cf8 100644 --- a/lib/gitlab/error_tracking.rb +++ b/lib/gitlab/error_tracking.rb @@ -111,8 +111,8 @@ module Gitlab private def before_send(event, hint) - event = add_context_from_exception_type(event, hint) - event = custom_fingerprinting(event, hint) + inject_context_for_exception(event, hint[:exception]) + custom_fingerprinting(event, hint[:exception]) event end @@ -123,7 +123,6 @@ module Gitlab end extra = sanitize_request_parameters(extra) - inject_sql_query_into_extra(exception, extra) if sentry && Raven.configuration.server Raven.capture_exception(exception, tags: default_tags, extra: extra) @@ -150,12 +149,6 @@ module Gitlab filter.filter(parameters) end - def inject_sql_query_into_extra(exception, extra) - return unless exception.is_a?(ActiveRecord::StatementInvalid) - - extra[:sql] = PgQuery.normalize(exception.sql.to_s) - end - def sentry_dsn return unless Rails.env.production? || Rails.env.development? return unless Gitlab.config.sentry.enabled @@ -183,9 +176,17 @@ module Gitlab {} end - # Debugging for https://gitlab.com/gitlab-org/gitlab-foss/issues/57727 - def add_context_from_exception_type(event, hint) - if ActiveModel::MissingAttributeError === hint[:exception] + # Group common, mostly non-actionable exceptions by type and message, + # rather than cause + def custom_fingerprinting(event, ex) + return event unless CUSTOM_FINGERPRINTING.include?(ex.class.name) + + event.fingerprint = [ex.class.name, ex.message] + end + + def inject_context_for_exception(event, ex) + case ex + when ActiveModel::MissingAttributeError # Debugging for https://gitlab.com/gitlab-org/gitlab/-/issues/26751 columns_hash = ActiveRecord::Base .connection .schema_cache @@ -193,21 +194,11 @@ module Gitlab .transform_values { |v| v.map(&:first) } event.extra.merge!(columns_hash) + when ActiveRecord::StatementInvalid + event.extra[:sql] = PgQuery.normalize(ex.sql.to_s) + else + inject_context_for_exception(event, ex.cause) if ex.cause.present? end - - event - end - - # Group common, mostly non-actionable exceptions by type and message, - # rather than cause - def custom_fingerprinting(event, hint) - ex = hint[:exception] - - return event unless CUSTOM_FINGERPRINTING.include?(ex.class.name) - - event.fingerprint = [ex.class.name, ex.message] - - event end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c96260b83b3..c34387c23b9 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -11559,9 +11559,6 @@ msgstr "" msgid "Existing branch name, tag, or commit SHA" msgstr "" -msgid "Existing members and groups" -msgstr "" - msgid "Existing projects may be moved into a group" msgstr "" @@ -12428,9 +12425,6 @@ msgstr "" msgid "Find by path" msgstr "" -msgid "Find existing members by name" -msgstr "" - msgid "Find file" msgstr "" @@ -17272,6 +17266,9 @@ msgstr "" msgid "Members invited to %{strong_start}%{group_name}%{strong_end}" msgstr "" +msgid "Members invited to %{strong_start}%{project_name}%{strong_end}" +msgstr "" + msgid "Members listed as CODEOWNERS of affected files." msgstr "" @@ -30733,6 +30730,9 @@ msgstr "" msgid "Users requesting access to %{strong_start}%{group_name}%{strong_end}" msgstr "" +msgid "Users requesting access to %{strong_start}%{project_name}%{strong_end}" +msgstr "" + msgid "Users were successfully added." msgstr "" diff --git a/package.json b/package.json index 072c9efadfe..2c95b597e4f 100644 --- a/package.json +++ b/package.json @@ -42,9 +42,9 @@ "@babel/plugin-syntax-import-meta": "^7.10.1", "@babel/preset-env": "^7.10.1", "@gitlab/at.js": "1.5.5", - "@gitlab/svgs": "1.177.0", + "@gitlab/svgs": "1.178.0", "@gitlab/tributejs": "1.0.0", - "@gitlab/ui": "25.2.1", + "@gitlab/ui": "25.3.1", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "^6.0.3-4", "@rails/ujs": "^6.0.3-4", diff --git a/qa/qa/page/project/members.rb b/qa/qa/page/project/members.rb index 88b05ceb1d1..447049ce22a 100644 --- a/qa/qa/page/project/members.rb +++ b/qa/qa/page/project/members.rb @@ -17,6 +17,7 @@ module QA view 'app/views/projects/project_members/index.html.haml' do element :invite_group_tab + element :groups_list_tab end view 'app/views/shared/members/_invite_group.html.haml' do @@ -48,6 +49,7 @@ module QA def remove_group(group_name) click_element :invite_group_tab + click_element :groups_list_tab page.accept_alert do within_element(:group_row, text: group_name) do click_element :delete_group_access_link diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index 74311fa89f3..971eb782fa4 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -14,32 +14,137 @@ RSpec.describe Projects::ProjectMembersController do expect(response).to have_gitlab_http_status(:ok) end - context 'when project belongs to group' do - let(:user_in_group) { create(:user) } - let(:project_in_group) { create(:project, :public, group: group) } + context 'project members' do + context 'when project belongs to group' do + 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 - group.add_owner(user_in_group) - project_in_group.add_maintainer(user) + project.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 } + context 'when user has `admin_project_member` permissions' do + before do + allow(controller.helpers).to receive(:can_manage_project_members?).with(project).and_return(true) + end - expect(assigns(:project_members).map(&:user_id)).to contain_exactly(user.id, user_in_group.id) + 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 - 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' } + 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 - expect(assigns(:project_members).map(&:user_id)).to contain_exactly(user.id) + 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) } + + before do + project.request_access(access_requester_user) + project.add_maintainer(user) + sign_in(user) 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' } + context 'when user has `admin_project_member` permissions' do + before do + allow(controller.helpers).to receive(:can_manage_project_members?).with(project).and_return(true) + end - expect(assigns(:project_members).map(&:user_id)).to contain_exactly(user_in_group.id) + it 'lists access requests' do + get :index, params: { namespace_id: project.namespace, project_id: project } + + expect(assigns(:requesters).map(&:user_id)).to contain_exactly(access_requester_user.id) + 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 access requests' do + get :index, params: { namespace_id: project.namespace, project_id: project } + + expect(assigns(:requesters)).to be_nil + end end end end diff --git a/spec/factories/project_group_links.rb b/spec/factories/project_group_links.rb index 5e3e83f18c1..b1b0f04d84c 100644 --- a/spec/factories/project_group_links.rb +++ b/spec/factories/project_group_links.rb @@ -3,7 +3,7 @@ FactoryBot.define do factory :project_group_link do project - group + group { association(:group) } expires_at { nil } group_access { Gitlab::Access::DEVELOPER } diff --git a/spec/factories/project_members.rb b/spec/factories/project_members.rb index 0c2ffac4112..3e83ab7118c 100644 --- a/spec/factories/project_members.rb +++ b/spec/factories/project_members.rb @@ -15,7 +15,9 @@ FactoryBot.define do trait(:invited) do user_id { nil } invite_token { 'xxx' } - invite_email { 'email@email.com' } + sequence :invite_email do |n| + "email#{n}@email.com" + end end trait :blocked do diff --git a/spec/features/groups/members/master_manages_access_requests_spec.rb b/spec/features/groups/members/master_manages_access_requests_spec.rb index 71c9b280ebe..2a17e7d2a5c 100644 --- a/spec/features/groups/members/master_manages_access_requests_spec.rb +++ b/spec/features/groups/members/master_manages_access_requests_spec.rb @@ -4,7 +4,6 @@ require 'spec_helper' RSpec.describe 'Groups > Members > Maintainer manages access requests' do it_behaves_like 'Maintainer manages access requests' do - let(:has_tabs) { true } let(:entity) { create(:group, :public) } let(:members_page_path) { group_group_members_path(entity) } end diff --git a/spec/features/projects/members/group_members_spec.rb b/spec/features/projects/members/group_members_spec.rb index 3060d2c6a43..9c740fd3834 100644 --- a/spec/features/projects/members/group_members_spec.rb +++ b/spec/features/projects/members/group_members_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Projects members' do +RSpec.describe 'Projects members', :js do let(:user) { create(:user) } let(:developer) { create(:user) } let(:group) { create(:group, :public) } @@ -66,26 +66,47 @@ RSpec.describe 'Projects members' do 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 + group.request_access(group_requester) + project.request_access(project_requester) group_invitee project_invitee visit project_project_members_path(project) 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 - 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 expect(page).to have_content(user.name) expect(page).to have_content(group.name) end end + + it 'shows the project developer' do + page.within first('.content-list') do + # Project developer + expect(page).to have_content(developer.name) + end + end + + it 'shows the project invitee' do + click_link 'Invited' + + 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) + end + end end context 'with a group requester' do @@ -95,36 +116,13 @@ RSpec.describe 'Projects members' do end it 'does not appear in the project members page' do + expect(page).not_to have_link('Access requests') page.within first('.content-list') do expect(page).not_to have_content(group_requester.name) end end end - context 'with a group and a project requesters' do - before do - group.request_access(group_requester) - project.request_access(project_requester) - visit project_project_members_path(project) - end - - it 'shows the project requester, the project developer, and the group owner' do - page.within first('.content-list') do - expect(page).to have_content(project_requester.name) - expect(page).not_to have_content(group_requester.name) - 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 - describe 'showing status of members' do it_behaves_like 'showing user status' do let(:user_with_status) { developer } diff --git a/spec/features/projects/members/groups_with_access_list_spec.rb b/spec/features/projects/members/groups_with_access_list_spec.rb index d59f8eb4b1d..686d86b1783 100644 --- a/spec/features/projects/members/groups_with_access_list_spec.rb +++ b/spec/features/projects/members/groups_with_access_list_spec.rb @@ -16,6 +16,7 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do project.add_maintainer(user) sign_in(user) visit project_project_members_path(project) + click_groups_tab end it 'updates group access level' do @@ -29,6 +30,8 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do visit project_project_members_path(project) + click_groups_tab + expect(first('.group_member')).to have_content('Guest') end @@ -71,23 +74,31 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do expect(page).not_to have_selector('.group_member') 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 page.within '.user-search-form' do - fill_in 'search', with: 'testing 123' + fill_in 'search_groups', with: 'testing 123' find('.user-search-btn').click end + click_groups_tab + expect(page).not_to have_selector('.group_member') end it 'finds results' 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 end + click_groups_tab + expect(page).to have_selector('.group_member', count: 1) end end + + def click_groups_tab + click_link 'Groups' + end end diff --git a/spec/features/projects/members/invite_group_spec.rb b/spec/features/projects/members/invite_group_spec.rb index 30e32ad1366..bb56ae348fb 100644 --- a/spec/features/projects/members/invite_group_spec.rb +++ b/spec/features/projects/members/invite_group_spec.rb @@ -39,7 +39,7 @@ RSpec.describe 'Project > Members > Invite group', :js do it 'the project can be shared with another group' do 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' @@ -47,7 +47,9 @@ RSpec.describe 'Project > Members > Invite group', :js do page.find('body').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) end end @@ -132,7 +134,9 @@ RSpec.describe 'Project > Members > Invite group', :js do end 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 # 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) diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/list_spec.rb index 36ff461aac2..eba0867dc8c 100644 --- a/spec/features/projects/members/list_spec.rb +++ b/spec/features/projects/members/list_spec.rb @@ -82,7 +82,9 @@ RSpec.describe 'Project members list' do 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('Invited') expect(page).to have_button('Reporter') diff --git a/spec/features/projects/members/master_manages_access_requests_spec.rb b/spec/features/projects/members/master_manages_access_requests_spec.rb index 2fdc75dca91..4c3eaa93352 100644 --- a/spec/features/projects/members/master_manages_access_requests_spec.rb +++ b/spec/features/projects/members/master_manages_access_requests_spec.rb @@ -4,7 +4,6 @@ require 'spec_helper' RSpec.describe 'Projects > Members > Maintainer manages access requests' do it_behaves_like 'Maintainer manages access requests' do - let(:has_tabs) { false } let(:entity) { create(:project, :public) } let(:members_page_path) { project_project_members_path(entity) } end diff --git a/spec/features/projects/members/tabs_spec.rb b/spec/features/projects/members/tabs_spec.rb new file mode 100644 index 00000000000..bdcf02c82a4 --- /dev/null +++ b/spec/features/projects/members/tabs_spec.rb @@ -0,0 +1,73 @@ +# 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 diff --git a/spec/features/projects/settings/user_manages_project_members_spec.rb b/spec/features/projects/settings/user_manages_project_members_spec.rb index 3836b95a28a..726b8fb6840 100644 --- a/spec/features/projects/settings/user_manages_project_members_spec.rb +++ b/spec/features/projects/settings/user_manages_project_members_spec.rb @@ -57,7 +57,7 @@ RSpec.describe 'Projects > Settings > User manages project members' do 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_developer(user_dmitriy) @@ -67,7 +67,9 @@ RSpec.describe 'Projects > Settings > User manages project members' do 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(first('.group_member')).to have_content('Maintainer') end diff --git a/spec/frontend/static_site_editor/components/submit_changes_error_spec.js b/spec/frontend/static_site_editor/components/submit_changes_error_spec.js index 1218710a186..7af3014b338 100644 --- a/spec/frontend/static_site_editor/components/submit_changes_error_spec.js +++ b/spec/frontend/static_site_editor/components/submit_changes_error_spec.js @@ -19,7 +19,7 @@ describe('Submit Changes Error', () => { }); }; - const findRetryButton = () => wrapper.findAll(GlButton).at(1); + const findRetryButton = () => wrapper.find(GlButton); const findAlert = () => wrapper.find(GlAlert); beforeEach(() => { diff --git a/spec/helpers/projects/project_members_helper_spec.rb b/spec/helpers/projects/project_members_helper_spec.rb new file mode 100644 index 00000000000..cc290367e34 --- /dev/null +++ b/spec/helpers/projects/project_members_helper_spec.rb @@ -0,0 +1,145 @@ +# 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 diff --git a/spec/lib/gitlab/error_tracking_spec.rb b/spec/lib/gitlab/error_tracking_spec.rb index 68a46b11487..764478ad1d7 100644 --- a/spec/lib/gitlab/error_tracking_spec.rb +++ b/spec/lib/gitlab/error_tracking_spec.rb @@ -236,7 +236,7 @@ RSpec.describe Gitlab::ErrorTracking do context 'the exception implements :sentry_extra_data' do let(:extra_info) { { event: 'explosion', size: :massive } } - let(:exception) { double(message: 'bang!', sentry_extra_data: extra_info, backtrace: caller) } + let(:exception) { double(message: 'bang!', sentry_extra_data: extra_info, backtrace: caller, cause: nil) } it 'includes the extra data from the exception in the tracking information' do track_exception @@ -247,7 +247,7 @@ RSpec.describe Gitlab::ErrorTracking do end context 'the exception implements :sentry_extra_data, which returns nil' do - let(:exception) { double(message: 'bang!', sentry_extra_data: nil, backtrace: caller) } + let(:exception) { double(message: 'bang!', sentry_extra_data: nil, backtrace: caller, cause: nil) } let(:extra) { { issue_url: issue_url } } it 'just includes the other extra info' do @@ -287,10 +287,23 @@ RSpec.describe Gitlab::ErrorTracking do let(:exception) { ActiveRecord::StatementInvalid.new(sql: 'SELECT "users".* FROM "users" WHERE "users"."id" = 1 AND "users"."foo" = $1') } it 'injects the normalized sql query into extra' do - track_exception + allow(Raven.client.transport).to receive(:send_event) do |event| + expect(event.extra).to include(sql: 'SELECT "users".* FROM "users" WHERE "users"."id" = $2 AND "users"."foo" = $1') + end - expect(Raven).to have_received(:capture_exception) - .with(exception, a_hash_including(extra: a_hash_including(sql: 'SELECT "users".* FROM "users" WHERE "users"."id" = $2 AND "users"."foo" = $1'))) + track_exception + end + end + + context 'when the `ActiveRecord::StatementInvalid` is wrapped in another exception' do + let(:exception) { RuntimeError.new(cause: ActiveRecord::StatementInvalid.new(sql: 'SELECT "users".* FROM "users" WHERE "users"."id" = 1 AND "users"."foo" = $1')) } + + it 'injects the normalized sql query into extra' do + allow(Raven.client.transport).to receive(:send_event) do |event| + expect(event.extra).to include(sql: 'SELECT "users".* FROM "users" WHERE "users"."id" = $2 AND "users"."foo" = $1') + end + + track_exception end end end diff --git a/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb b/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb index 1dbaace1c89..c2dc87b0fb0 100644 --- a/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb +++ b/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb @@ -12,9 +12,7 @@ RSpec.shared_examples 'Maintainer manages access requests' do sign_in(maintainer) visit members_page_path - if has_tabs - click_on 'Access requests' - end + click_on 'Access requests' end it 'maintainer can see access requests', :js do @@ -48,11 +46,7 @@ RSpec.shared_examples 'Maintainer manages access requests' do end def expect_visible_access_request(entity, user) - if has_tabs - 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 "Access requests 1" expect(page).to have_content user.name end diff --git a/yarn.lock b/yarn.lock index edce7ab6dec..71a4a088a5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -861,20 +861,20 @@ eslint-plugin-vue "^6.2.1" vue-eslint-parser "^7.0.0" -"@gitlab/svgs@1.177.0": - version "1.177.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.177.0.tgz#e481ed327a11d3834c8b1668d7485b9eefef97f5" - integrity sha512-L7DggusgkbubNFCRIYtCuYiLx+t5Hp8y/XIxJ3RM5mqAfxkTR1KxALNLDP9CT7xWieHDhNvgcXAdamGoi0ofDQ== +"@gitlab/svgs@1.178.0": + version "1.178.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.178.0.tgz#069edb8abb4c7137d48f527592476655f066538b" + integrity sha512-m1xe5SPgpi9lSFCHHTkkGeScxkqhi7aD8qApL5F4MqCGeKF9IhELIVoMD1R6vkfjzFJh0BwFREPkuwjnAOMKfA== "@gitlab/tributejs@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8" integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw== -"@gitlab/ui@25.2.1": - version "25.2.1" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-25.2.1.tgz#2c332134bbc82a6c40ff5fdb73aacccf730629d8" - integrity sha512-bOkL2sfkovCV6MO/N70Xfe+vTdyi2Vp2efgDvOx4tHzqJllM6Y379wculi0VmdGw3X4TpmPI+zLWAAZ9vkhDAQ== +"@gitlab/ui@25.3.1": + version "25.3.1" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-25.3.1.tgz#7557c810397f8c4b81c5360e4642afc3f8274dfc" + integrity sha512-vCl74UZgQ5m1caJk8O067KKYa+DP40ES2XDnM/wAc9mZAMynP0GPpePc3cmTLY8vpfzxx2A2iJr04SLgI2pxjA== dependencies: "@babel/standalone" "^7.0.0" "@gitlab/vue-toasted" "^1.3.0"