Commit 0df4a9db authored by Peter Hegman's avatar Peter Hegman Committed by Kushal Pandya

Add filter search bar to group members view

Improves filtering capability and moves toward Pajamas design system
parent f0afb27e
export default {
issues: 'issue-recent-searches',
merge_requests: 'merge-request-recent-searches',
group_members: 'group-members-recent-searches',
group_invited_members: 'group-invited-members-recent-searches',
};
......@@ -2,12 +2,15 @@
import { mapState, mapMutations } from 'vuex';
import { GlAlert } from '@gitlab/ui';
import MembersTable from '~/members/components/table/members_table.vue';
import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
import { HIDE_ERROR } from '~/members/store/mutation_types';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'GroupMembersApp',
components: { MembersTable, GlAlert },
components: { MembersTable, FilterSortContainer, GlAlert },
mixins: [glFeatureFlagsMixin()],
computed: {
...mapState(['showError', 'errorMessage']),
},
......@@ -33,6 +36,7 @@ export default {
<gl-alert v-if="showError" ref="errorAlert" variant="danger" @dismiss="hideError">{{
errorMessage
}}</gl-alert>
<filter-sort-container v-if="glFeatures.groupMembersFilteredSearch" />
<members-table />
</div>
</template>
<script>
import { mapState } from 'vuex';
import MembersFilteredSearchBar from './members_filtered_search_bar.vue';
export default {
name: 'FilterSortContainer',
components: { MembersFilteredSearchBar },
computed: {
...mapState(['filteredSearchBar']),
},
};
</script>
<template>
<div v-if="filteredSearchBar.show" class="gl-bg-gray-10 gl-p-5">
<members-filtered-search-bar />
</div>
</template>
<script>
import { mapState } from 'vuex';
import { GlFilteredSearchToken } from '@gitlab/ui';
import { setUrlParams, queryToObject } from '~/lib/utils/url_utility';
import { getParameterByName } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { SEARCH_TOKEN_TYPE, SORT_PARAM } from '~/members/constants';
export default {
name: 'MembersFilteredSearchBar',
components: { FilteredSearchBar },
availableTokens: [
{
type: 'two_factor',
icon: 'lock',
title: s__('Members|2FA'),
token: GlFilteredSearchToken,
unique: true,
operators: [{ value: '=', description: 'is' }],
options: [
{ value: 'enabled', title: s__('Members|Enabled') },
{ value: 'disabled', title: s__('Members|Disabled') },
],
requiredPermissions: 'canManageMembers',
},
{
type: 'with_inherited_permissions',
icon: 'group',
title: s__('Members|Membership'),
token: GlFilteredSearchToken,
unique: true,
operators: [{ value: '=', description: 'is' }],
options: [
{ value: 'exclude', title: s__('Members|Direct') },
{ value: 'only', title: s__('Members|Inherited') },
],
},
],
data() {
return {
initialFilterValue: [],
};
},
computed: {
...mapState(['sourceId', 'filteredSearchBar', 'canManageMembers']),
tokens() {
return this.$options.availableTokens.filter(token => {
if (
Object.prototype.hasOwnProperty.call(token, 'requiredPermissions') &&
!this[token.requiredPermissions]
) {
return false;
}
return this.filteredSearchBar.tokens?.includes(token.type);
});
},
},
created() {
const query = queryToObject(window.location.search);
const tokens = this.tokens
.filter(token => query[token.type])
.map(token => ({
type: token.type,
value: {
data: query[token.type],
operator: '=',
},
}));
if (query[this.filteredSearchBar.searchParam]) {
tokens.push({
type: SEARCH_TOKEN_TYPE,
value: {
data: query[this.filteredSearchBar.searchParam],
},
});
}
this.initialFilterValue = tokens;
},
methods: {
handleFilter(tokens) {
const params = tokens.reduce((accumulator, token) => {
const { type, value } = token;
if (!type || !value) {
return accumulator;
}
if (type === SEARCH_TOKEN_TYPE) {
if (value.data !== '') {
return {
...accumulator,
[this.filteredSearchBar.searchParam]: value.data,
};
}
} else {
return {
...accumulator,
[type]: value.data,
};
}
return accumulator;
}, {});
const sortParam = getParameterByName(SORT_PARAM);
window.location.href = setUrlParams(
{ ...params, ...(sortParam && { sort: sortParam }) },
window.location.href,
true,
);
},
},
};
</script>
<template>
<filtered-search-bar
:namespace="sourceId.toString()"
:tokens="tokens"
:recent-searches-storage-key="filteredSearchBar.recentSearchesStorageKey"
:search-input-placeholder="filteredSearchBar.placeholder"
:initial-filter-value="initialFilterValue"
data-testid="members-filtered-search-bar"
@onFilter="handleFilter"
/>
</template>
......@@ -69,3 +69,7 @@ export const DAYS_TO_EXPIRE_SOON = 7;
export const LEAVE_MODAL_ID = 'member-leave-modal';
export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id';
export const SEARCH_TOKEN_TYPE = 'filtered-search-term';
export const SORT_PARAM = 'sort';
......@@ -6,7 +6,7 @@ import groupsSelect from '~/groups_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
import { initGroupMembersApp } from '~/groups/members';
import { memberRequestFormatter, groupLinkRequestFormatter } from '~/groups/members/utils';
import { __ } from '~/locale';
import { s__ } from '~/locale';
function mountRemoveMemberModal() {
const el = document.querySelector('.js-remove-member-modal');
......@@ -33,7 +33,7 @@ initGroupMembersApp(document.querySelector('.js-group-members-list'), {
show: true,
tokens: ['two_factor', 'with_inherited_permissions'],
searchParam: 'search',
placeholder: __('Members|Filter members'),
placeholder: s__('Members|Filter members'),
recentSearchesStorageKey: 'group_members',
},
});
......@@ -52,7 +52,7 @@ initGroupMembersApp(document.querySelector('.js-group-invited-members-list'), {
show: true,
tokens: [],
searchParam: 'search_invited',
placeholder: __('Members|Search invited'),
placeholder: s__('Members|Search invited'),
recentSearchesStorageKey: 'group_invited_members',
},
});
......
......@@ -14,6 +14,10 @@ class Groups::GroupMembersController < Groups::ApplicationController
# Authorize
before_action :authorize_admin_group_member!, except: admin_not_required_endpoints
before_action do
push_frontend_feature_flag(:group_members_filtered_search, @group)
end
skip_before_action :check_two_factor_requirement, only: :leave
skip_cross_project_access_check :index, :create, :update, :destroy, :request_access,
:approve_access_request, :leave, :resend_invite,
......
......@@ -4,6 +4,7 @@
- show_access_requests = can_manage_members && @requesters.exists?
- invited_active = params[:search_invited].present? || params[:invited_members_page].present?
- vue_members_list_enabled = Feature.enabled?(:vue_group_members_list, @group, default_enabled: true)
- filtered_search_enabled = Feature.enabled?(:group_members_filtered_search, @group)
- current_user_is_group_owner = @group && @group.has_owner?(current_user)
- form_item_label_css_class = 'label-bold gl-mr-2 gl-mb-0 gl-py-2 align-self-md-center'
......@@ -54,6 +55,7 @@
.tab-content
#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
= 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 }
......@@ -83,6 +85,7 @@
- if @group.shared_with_group_links.any?
#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
= 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 }
......@@ -97,6 +100,7 @@
- if show_invited_members
#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
= 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 }
......@@ -117,6 +121,7 @@
- if show_access_requests
#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
= 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 }
......
---
name: group_members_filtered_search
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48272
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/289911
milestone: '13.7'
type: development
group: group::access
default_enabled: false
......@@ -16842,6 +16842,9 @@ msgstr ""
msgid "Members|%{userName} is currently an LDAP user. Editing their permissions will override the settings from the LDAP group sync."
msgstr ""
msgid "Members|2FA"
msgstr ""
msgid "Members|An error occurred while trying to enable LDAP override, please try again."
msgstr ""
......@@ -16875,9 +16878,18 @@ msgstr ""
msgid "Members|Are you sure you want to withdraw your access request for \"%{source}\""
msgstr ""
msgid "Members|Direct"
msgstr ""
msgid "Members|Disabled"
msgstr ""
msgid "Members|Edit permissions"
msgstr ""
msgid "Members|Enabled"
msgstr ""
msgid "Members|Expiration date removed successfully."
msgstr ""
......@@ -16890,12 +16902,18 @@ msgstr ""
msgid "Members|Filter members"
msgstr ""
msgid "Members|Inherited"
msgstr ""
msgid "Members|LDAP override enabled."
msgstr ""
msgid "Members|Leave \"%{source}\""
msgstr ""
msgid "Members|Membership"
msgstr ""
msgid "Members|No expiration set"
msgstr ""
......
......@@ -11,8 +11,7 @@ RSpec.describe 'Groups > Members > Filter members', :js do
let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
two_factor_auth_dropdown_toggle_selector = '[data-testid="member-filter-2fa-dropdown"] [data-testid="dropdown-toggle"]'
active_inherited_members_filter_selector = '[data-testid="filter-members-with-inherited-permissions"] a.is-active'
filtered_search_bar_selector = '[data-testid="members-filtered-search-bar"]'
before do
group.add_owner(user)
......@@ -27,7 +26,6 @@ RSpec.describe 'Groups > Members > Filter members', :js do
expect(member(0)).to include(user.name)
expect(member(1)).to include(user_with_2fa.name)
expect(page).to have_css(two_factor_auth_dropdown_toggle_selector, text: 'Everyone')
end
it 'shows only 2FA members' do
......@@ -35,7 +33,10 @@ RSpec.describe 'Groups > Members > Filter members', :js do
expect(member(0)).to include(user_with_2fa.name)
expect(all_rows.size).to eq(1)
expect(page).to have_css(two_factor_auth_dropdown_toggle_selector, text: 'Enabled')
within filtered_search_bar_selector do
expect(page).to have_content '2FA = Enabled'
end
end
it 'shows only non 2FA members' do
......@@ -43,7 +44,10 @@ RSpec.describe 'Groups > Members > Filter members', :js do
expect(member(0)).to include(user.name)
expect(all_rows.size).to eq(1)
expect(page).to have_css(two_factor_auth_dropdown_toggle_selector, text: 'Disabled')
within filtered_search_bar_selector do
expect(page).to have_content '2FA = Disabled'
end
end
it 'shows inherited members by default' do
......@@ -53,15 +57,16 @@ RSpec.describe 'Groups > Members > Filter members', :js do
expect(member(1)).to include(user_with_2fa.name)
expect(member(2)).to include(nested_group_user.name)
expect(all_rows.size).to eq(3)
expect(page).to have_css(active_inherited_members_filter_selector, text: 'Show all members', visible: false)
end
it 'shows only group members' do
visit_members_list(nested_group, with_inherited_permissions: 'exclude')
expect(member(0)).to include(nested_group_user.name)
expect(all_rows.size).to eq(1)
expect(page).to have_css(active_inherited_members_filter_selector, text: 'Show only direct members', visible: false)
within filtered_search_bar_selector do
expect(page).to have_content 'Membership = Direct'
end
end
it 'shows only inherited members' do
......@@ -69,7 +74,10 @@ RSpec.describe 'Groups > Members > Filter members', :js do
expect(member(0)).to include(user.name)
expect(member(1)).to include(user_with_2fa.name)
expect(all_rows.size).to eq(2)
expect(page).to have_css(active_inherited_members_filter_selector, text: 'Show only inherited members', visible: false)
within filtered_search_bar_selector do
expect(page).to have_content 'Membership = Inherited'
end
end
def visit_members_list(group, options = {})
......
......@@ -21,9 +21,10 @@ RSpec.describe 'Search group member', :js do
end
it 'renders member users' do
page.within '[data-testid="user-search-form"]' do
fill_in 'search', with: member.name
find('[data-testid="user-search-submit"]').click
page.within '[data-testid="members-filtered-search-bar"]' do
find_field('Filter members').click
find('input').native.send_keys(member.name)
click_button 'Search'
end
expect(members_table).to have_content(member.name)
......
......@@ -12,6 +12,8 @@ RSpec.describe 'Groups > Members > Sort members', :js do
dropdown_toggle_selector = '[data-testid="user-sort-dropdown"] [data-testid="dropdown-toggle"]'
before do
stub_feature_flags(group_members_filtered_search: false)
create(:group_member, :owner, user: owner, group: group, created_at: 5.days.ago)
create(:group_member, :developer, user: developer, group: group, created_at: 3.days.ago)
......
......@@ -62,9 +62,10 @@ RSpec.describe 'Groups > Members > Tabs' do
click_link 'Invited'
page.within '[data-testid="user-search-form"]' do
fill_in 'search_invited', with: 'email'
find('button[type="submit"]').click
page.within '[data-testid="members-filtered-search-bar"]' do
find_field('Search invited').click
find('input').native.send_keys('email')
click_button 'Search'
end
end
......@@ -74,9 +75,10 @@ RSpec.describe 'Groups > Members > Tabs' do
before do
click_link 'Members'
page.within '[data-testid="user-search-form"]' do
fill_in 'search', with: 'test'
find('button[type="submit"]').click
page.within '[data-testid="members-filtered-search-bar"]' do
find_field('Filter members').click
find('input').native.send_keys('test')
click_button 'Search'
end
end
......
......@@ -3,6 +3,7 @@ import { nextTick } from 'vue';
import Vuex from 'vuex';
import { GlAlert } from '@gitlab/ui';
import App from '~/groups/members/components/app.vue';
import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue';
import * as commonUtils from '~/lib/utils/common_utils';
import { RECEIVE_MEMBER_ROLE_ERROR, HIDE_ERROR } from '~/members/store/mutation_types';
import mutations from '~/members/store/mutations';
......@@ -14,7 +15,7 @@ describe('GroupMembersApp', () => {
let wrapper;
let store;
const createComponent = (state = {}) => {
const createComponent = (state = {}, options = {}) => {
store = new Vuex.Store({
state: {
showError: true,
......@@ -27,10 +28,12 @@ describe('GroupMembersApp', () => {
wrapper = shallowMount(App, {
localVue,
store,
...options,
});
};
const findAlert = () => wrapper.find(GlAlert);
const findFilterSortContainer = () => wrapper.find(FilterSortContainer);
beforeEach(() => {
commonUtils.scrollToElement = jest.fn();
......@@ -83,4 +86,22 @@ describe('GroupMembersApp', () => {
expect(findAlert().exists()).toBe(false);
});
});
describe.each`
featureFlagValue | exists
${true} | ${true}
${false} | ${false}
`(
'when `group_members_filtered_search` feature flag is $featureFlagValue',
({ featureFlagValue, exists }) => {
it(`${exists ? 'renders' : 'does not render'} FilterSortContainer`, () => {
createComponent(
{},
{ provide: { glFeatures: { groupMembersFilteredSearch: featureFlagValue } } },
);
expect(findFilterSortContainer().exists()).toBe(exists);
});
},
);
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue';
import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('FilterSortContainer', () => {
let wrapper;
const createComponent = state => {
const store = new Vuex.Store({
state: {
filteredSearchBar: {
show: true,
tokens: ['two_factor'],
searchParam: 'search',
placeholder: 'Filter members',
recentSearchesStorageKey: 'group_members',
},
...state,
},
});
wrapper = shallowMount(FilterSortContainer, {
localVue,
store,
});
};
describe('when `filteredSearchBar.show` is `false`', () => {
it('renders nothing', () => {
createComponent({
filteredSearchBar: {
show: false,
},
});
expect(wrapper.html()).toBe('');
});
});
describe('when `filteredSearchBar.show` is `true`', () => {
it('renders `MembersFilteredSearchBar`', () => {
createComponent({
filteredSearchBar: {
show: true,
},
});
expect(wrapper.find(MembersFilteredSearchBar).exists()).toBe(true);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlFilteredSearchToken } from '@gitlab/ui';
import MembersFilteredSearchBar from '~/members/components/filter_sort/members_filtered_search_bar.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('MembersFilteredSearchBar', () => {
let wrapper;
const createComponent = state => {
const store = new Vuex.Store({
state: {
sourceId: 1,
filteredSearchBar: {
show: true,
tokens: ['two_factor'],
searchParam: 'search',
placeholder: 'Filter members',
recentSearchesStorageKey: 'group_members',
},
canManageMembers: true,
...state,
},
});
wrapper = shallowMount(MembersFilteredSearchBar, {
localVue,
store,
});
};
const findFilteredSearchBar = () => wrapper.find(FilteredSearchBar);
it('passes correct props to `FilteredSearchBar` component', () => {
createComponent();
expect(findFilteredSearchBar().props()).toMatchObject({
namespace: '1',
recentSearchesStorageKey: 'group_members',
searchInputPlaceholder: 'Filter members',
});
});
describe('filtering tokens', () => {
it('includes tokens set in `filteredSearchBar.tokens`', () => {
createComponent();
expect(findFilteredSearchBar().props('tokens')).toEqual([
{
type: 'two_factor',
icon: 'lock',
title: '2FA',
token: GlFilteredSearchToken,
unique: true,
operators: [{ value: '=', description: 'is' }],
options: [
{ value: 'enabled', title: 'Enabled' },
{ value: 'disabled', title: 'Disabled' },
],
requiredPermissions: 'canManageMembers',
},
]);
});
describe('when `canManageMembers` is false', () => {
it('excludes 2FA token', () => {
createComponent({
filteredSearchBar: {
show: true,
tokens: ['two_factor', 'with_inherited_permissions'],
searchParam: 'search',
placeholder: 'Filter members',
recentSearchesStorageKey: 'group_members',
},
canManageMembers: false,
});
expect(findFilteredSearchBar().props('tokens')).toEqual([
{
type: 'with_inherited_permissions',
icon: 'group',
title: 'Membership',
token: GlFilteredSearchToken,
unique: true,
operators: [{ value: '=', description: 'is' }],
options: [{ value: 'exclude', title: 'Direct' }, { value: 'only', title: 'Inherited' }],
},
]);
});
});
});
describe('when filters are set via query params', () => {
beforeEach(() => {
delete window.location;
window.location = new URL('https://localhost');
});
it('parses and passes tokens to `FilteredSearchBar` component as `initialFilterValue` prop', () => {
window.location.search = '?two_factor=enabled&token_not_available=foobar';
createComponent();
expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([
{
type: 'two_factor',
value: {
data: 'enabled',
operator: '=',
},
},
]);
});
it('parses and passes search param to `FilteredSearchBar` component as `initialFilterValue` prop', () => {
window.location.search = '?search=foobar';
createComponent();
expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([
{
type: 'filtered-search-term',
value: {
data: 'foobar',
},
},
]);
});
});
describe('when filter bar is submitted', () => {
beforeEach(() => {
delete window.location;
window.location = new URL('https://localhost');
});
it('adds correct filter query params', () => {
createComponent();
findFilteredSearchBar().vm.$emit('onFilter', [
{ type: 'two_factor', value: { data: 'enabled', operator: '=' } },
]);
expect(window.location.href).toBe('https://localhost/?two_factor=enabled');
});
it('adds search query param', () => {
createComponent();
findFilteredSearchBar().vm.$emit('onFilter', [
{ type: 'two_factor', value: { data: 'enabled', operator: '=' } },
{ type: 'filtered-search-term', value: { data: 'foobar' } },
]);
expect(window.location.href).toBe('https://localhost/?two_factor=enabled&search=foobar');
});
it('adds sort query param', () => {
window.location.search = '?sort=name_asc';
createComponent();
findFilteredSearchBar().vm.$emit('onFilter', [
{ type: 'two_factor', value: { data: 'enabled', operator: '=' } },
{ type: 'filtered-search-term', value: { data: 'foobar' } },
]);
expect(window.location.href).toBe(
'https://localhost/?two_factor=enabled&search=foobar&sort=name_asc',
);
});
});
});
......@@ -50,7 +50,6 @@ RSpec.shared_examples 'Maintainer manages access requests' do
def expect_visible_access_request(entity, user)
if has_tabs
expect(page).to have_content "Access requests 1"
expect(page).to have_content "Users requesting access to #{entity.name}"
else
expect(page).to have_content "Users requesting access to #{entity.name} 1"
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