Commit ac33c827 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'astoicescu-add-search-of-members-table' into 'master'

Add search field for billable members table

See merge request gitlab-org/gitlab!49847
parents 32793249 e1961ad3
...@@ -836,11 +836,18 @@ const Api = { ...@@ -836,11 +836,18 @@ const Api = {
page: 1, page: 1,
}; };
const passedOptions = options;
// calling search API with empty string will not return results
if (!passedOptions.search) {
passedOptions.search = undefined;
}
return axios return axios
.get(url, { .get(url, {
params: { params: {
...defaults, ...defaults,
...options, ...passedOptions,
}, },
}) })
.then(({ data, headers }) => { .then(({ data, headers }) => {
......
...@@ -5,13 +5,15 @@ import { ...@@ -5,13 +5,15 @@ import {
GlAvatarLabeled, GlAvatarLabeled,
GlAvatarLink, GlAvatarLink,
GlPagination, GlPagination,
GlLoadingIcon,
GlTooltipDirective, GlTooltipDirective,
GlSearchBoxByType,
GlBadge,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { parseInt } from 'lodash'; import { parseInt, debounce } from 'lodash';
import { s__, sprintf } from '~/locale'; import { s__ } from '~/locale';
const AVATAR_SIZE = 32; const AVATAR_SIZE = 32;
const SEARCH_DEBOUNCE_MS = 250;
export default { export default {
directives: { directives: {
...@@ -22,31 +24,24 @@ export default { ...@@ -22,31 +24,24 @@ export default {
GlAvatarLabeled, GlAvatarLabeled,
GlAvatarLink, GlAvatarLink,
GlPagination, GlPagination,
GlLoadingIcon, GlSearchBoxByType,
GlBadge,
}, },
data() { data() {
return { return {
fields: ['user', 'email'], fields: ['user', 'email'],
searchQuery: '',
}; };
}, },
computed: { computed: {
...mapState(['isLoading', 'page', 'perPage', 'total', 'namespaceId', 'namespaceName']), ...mapState(['isLoading', 'page', 'perPage', 'total', 'namespaceName']),
...mapGetters(['tableItems']), ...mapGetters(['tableItems']),
headingText() {
return sprintf(s__('Billing|Users occupying seats in %{namespaceName} Group (%{total})'), {
total: this.total,
namespaceName: this.namespaceName,
});
},
subHeadingText() {
return s__('Billing|Updated live');
},
currentPage: { currentPage: {
get() { get() {
return parseInt(this.page, 10); return parseInt(this.page, 10);
}, },
set(val) { set(val) {
this.fetchBillableMembersList(val); this.fetchBillableMembersList({ page: val, search: this.searchQuery });
}, },
}, },
perPageFormatted() { perPageFormatted() {
...@@ -55,14 +50,45 @@ export default { ...@@ -55,14 +50,45 @@ export default {
totalFormatted() { totalFormatted() {
return parseInt(this.total, 10); return parseInt(this.total, 10);
}, },
emptyText() {
if (this.searchQuery?.length < 3) {
return s__('Billing|Enter at least three characters to search.');
}
return s__('Billing|No users to display.');
},
},
watch: {
searchQuery() {
this.executeQuery();
},
}, },
created() { created() {
this.fetchBillableMembersList(1); // This method is defined here instead of in `methods`
// because we need to access the .cancel() method
// lodash attaches to the function, which is
// made inaccessible by Vue. More info:
// https://stackoverflow.com/a/52988020/1063392
this.debouncedSearch = debounce(function search() {
this.fetchBillableMembersList({ search: this.searchQuery });
}, SEARCH_DEBOUNCE_MS);
this.fetchBillableMembersList();
}, },
methods: { methods: {
...mapActions(['fetchBillableMembersList']), ...mapActions(['fetchBillableMembersList', 'resetMembers']),
inputHandler(val) { onSearchEnter() {
this.fetchBillableMembersList(val); this.debouncedSearch.cancel();
this.executeQuery();
},
executeQuery() {
const queryLength = this.searchQuery?.length;
const MIN_SEARCH_LENGTH = 3;
if (queryLength === 0 || queryLength >= MIN_SEARCH_LENGTH) {
this.debouncedSearch();
} else if (queryLength < MIN_SEARCH_LENGTH) {
this.resetMembers();
}
}, },
}, },
avatarSize: AVATAR_SIZE, avatarSize: AVATAR_SIZE,
...@@ -73,9 +99,28 @@ export default { ...@@ -73,9 +99,28 @@ export default {
</script> </script>
<template> <template>
<div class="gl-pt-4"> <section>
<h4 data-testid="heading">{{ headingText }}</h4> <div
<p>{{ subHeadingText }}</p> class="gl-bg-gray-10 gl-p-6 gl-display-md-flex gl-justify-content-space-between gl-align-items-center"
>
<div data-testid="heading-info">
<h4
data-testid="heading-info-text"
class="gl-font-base gl-display-inline-block gl-font-weight-normal"
>
{{ s__('Billing|Users occupying seats in') }}
<span class="gl-font-weight-bold">{{ namespaceName }} {{ s__('Billing|Group') }}</span>
</h4>
<gl-badge>{{ total }}</gl-badge>
</div>
<gl-search-box-by-type
v-model.trim="searchQuery"
:placeholder="s__('Billing|Type to search')"
@keydown.enter.prevent="onSearchEnter"
/>
</div>
<gl-table <gl-table
class="seats-table" class="seats-table"
:items="tableItems" :items="tableItems"
...@@ -83,6 +128,8 @@ export default { ...@@ -83,6 +128,8 @@ export default {
:busy="isLoading" :busy="isLoading"
:show-empty="true" :show-empty="true"
data-testid="table" data-testid="table"
:empty-text="emptyText"
thead-class="gl-display-none"
> >
<template #cell(user)="data"> <template #cell(user)="data">
<div class="gl-display-flex"> <div class="gl-display-flex">
...@@ -109,14 +156,6 @@ export default { ...@@ -109,14 +156,6 @@ export default {
> >
</div> </div>
</template> </template>
<template #empty>
{{ s__('Billing|No users to display.') }}
</template>
<template #table-busy>
<gl-loading-icon size="lg" color="dark" class="gl-mt-5" />
</template>
</gl-table> </gl-table>
<gl-pagination <gl-pagination
...@@ -127,5 +166,5 @@ export default { ...@@ -127,5 +166,5 @@ export default {
align="center" align="center"
class="gl-mt-5" class="gl-mt-5"
/> />
</div> </section>
</template> </template>
...@@ -3,10 +3,10 @@ import * as types from './mutation_types'; ...@@ -3,10 +3,10 @@ import * as types from './mutation_types';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
export const fetchBillableMembersList = ({ dispatch, state }, page) => { export const fetchBillableMembersList = ({ dispatch, state }, { page, search } = {}) => {
dispatch('requestBillableMembersList'); dispatch('requestBillableMembersList');
return Api.fetchBillableGroupMembersList(state.namespaceId, { page }) return Api.fetchBillableGroupMembersList(state.namespaceId, { page, search })
.then((data) => dispatch('receiveBillableMembersListSuccess', data)) .then((data) => dispatch('receiveBillableMembersListSuccess', data))
.catch(() => dispatch('receiveBillableMembersListError')); .catch(() => dispatch('receiveBillableMembersListError'));
}; };
...@@ -22,3 +22,7 @@ export const receiveBillableMembersListError = ({ commit }) => { ...@@ -22,3 +22,7 @@ export const receiveBillableMembersListError = ({ commit }) => {
}); });
commit(types.RECEIVE_BILLABLE_MEMBERS_ERROR); commit(types.RECEIVE_BILLABLE_MEMBERS_ERROR);
}; };
export const resetMembers = ({ commit }) => {
commit(types.RESET_MEMBERS);
};
...@@ -6,6 +6,5 @@ export const tableItems = (state) => { ...@@ -6,6 +6,5 @@ export const tableItems = (state) => {
return { user: { name, username: formattedUserName, avatar_url, web_url }, email }; return { user: { name, username: formattedUserName, avatar_url, web_url }, email };
}); });
} }
return []; return [];
}; };
export const REQUEST_BILLABLE_MEMBERS = 'REQUEST_BILLABLE_MEMBERS'; export const REQUEST_BILLABLE_MEMBERS = 'REQUEST_BILLABLE_MEMBERS';
export const RECEIVE_BILLABLE_MEMBERS_SUCCESS = 'RECEIVE_BILLABLE_MEMBERS_SUCCESS'; export const RECEIVE_BILLABLE_MEMBERS_SUCCESS = 'RECEIVE_BILLABLE_MEMBERS_SUCCESS';
export const RECEIVE_BILLABLE_MEMBERS_ERROR = 'RECEIVE_BILLABLE_MEMBERS_ERROR'; export const RECEIVE_BILLABLE_MEMBERS_ERROR = 'RECEIVE_BILLABLE_MEMBERS_ERROR';
export const SET_SEARCH = 'SET_SEARCH';
export const RESET_MEMBERS = 'RESET_MEMBERS';
...@@ -26,4 +26,18 @@ export default { ...@@ -26,4 +26,18 @@ export default {
state.isLoading = false; state.isLoading = false;
state.hasError = true; state.hasError = true;
}, },
[types.SET_SEARCH](state, searchString) {
state.search = searchString ?? '';
},
[types.RESET_MEMBERS](state) {
state.members = [];
state.total = null;
state.page = null;
state.perPage = null;
state.isLoading = false;
},
}; };
import { GlPagination, GlTable, GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; import {
GlPagination,
GlTable,
GlAvatarLink,
GlAvatarLabeled,
GlSearchBoxByType,
GlBadge,
} from '@gitlab/ui';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import SubscriptionSeats from 'ee/billings/seat_usage/components/subscription_seats.vue'; import SubscriptionSeats from 'ee/billings/seat_usage/components/subscription_seats.vue';
...@@ -8,8 +15,8 @@ const localVue = createLocalVue(); ...@@ -8,8 +15,8 @@ const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
const actionSpies = { const actionSpies = {
setNamespaceId: jest.fn(),
fetchBillableMembersList: jest.fn(), fetchBillableMembersList: jest.fn(),
resetMembers: jest.fn(),
}; };
const providedFields = { const providedFields = {
...@@ -52,7 +59,13 @@ describe('Subscription Seats', () => { ...@@ -52,7 +59,13 @@ describe('Subscription Seats', () => {
}; };
const findTable = () => wrapper.find(GlTable); const findTable = () => wrapper.find(GlTable);
const findPageHeading = () => wrapper.find('[data-testid="heading"]'); const findTableEmptyText = () => findTable().attributes('empty-text');
const findPageHeading = () => wrapper.find('[data-testid="heading-info"]');
const findPageHeadingText = () => findPageHeading().find('[data-testid="heading-info-text"]');
const findPageHeadingBadge = () => findPageHeading().find(GlBadge);
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const findPagination = () => wrapper.find(GlPagination); const findPagination = () => wrapper.find(GlPagination);
const serializeUser = (rowWrapper) => { const serializeUser = (rowWrapper) => {
...@@ -97,7 +110,7 @@ describe('Subscription Seats', () => { ...@@ -97,7 +110,7 @@ describe('Subscription Seats', () => {
}); });
it('correct actions are called on create', () => { it('correct actions are called on create', () => {
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledWith(expect.any(Object), 1); expect(actionSpies.fetchBillableMembersList).toHaveBeenCalled();
}); });
}); });
...@@ -118,8 +131,8 @@ describe('Subscription Seats', () => { ...@@ -118,8 +131,8 @@ describe('Subscription Seats', () => {
describe('heading text', () => { describe('heading text', () => {
it('contains the group name and total seats number', () => { it('contains the group name and total seats number', () => {
expect(findPageHeading().text()).toMatch(providedFields.namespaceName); expect(findPageHeadingText().text()).toMatch(providedFields.namespaceName);
expect(findPageHeading().text()).toMatch('300'); expect(findPageHeadingBadge().text()).toMatch('300');
}); });
}); });
...@@ -169,4 +182,88 @@ describe('Subscription Seats', () => { ...@@ -169,4 +182,88 @@ describe('Subscription Seats', () => {
expect(findTable().attributes('busy')).toBe('true'); expect(findTable().attributes('busy')).toBe('true');
}); });
}); });
describe('search box', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('input event triggers the fetchBillableMembersList action', async () => {
const SEARCH_STRING = 'search string';
// fetchBillableMembersList is called once on created()
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(1);
await findSearchBox().vm.$emit('input', SEARCH_STRING);
// fetchBillableMembersList is triggered a second time on input
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(2);
// fetchBillableMembersList is triggered the second time with the correct argument
expect(actionSpies.fetchBillableMembersList.mock.calls[1][1]).toEqual({
search: SEARCH_STRING,
});
});
});
describe('typing inside of the search box', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('causes the empty table text to change based on the number of typed characters', async () => {
const EMPTY_TEXT_TOO_SHORT = 'Enter at least three characters to search.';
const EMPTY_TEXT_NO_USERS = 'No users to display.';
expect(findTableEmptyText()).toBe(EMPTY_TEXT_TOO_SHORT);
await findSearchBox().vm.$emit('input', 'a');
expect(findTableEmptyText()).toBe(EMPTY_TEXT_TOO_SHORT);
await findSearchBox().vm.$emit('input', 'aa');
expect(findTableEmptyText()).toBe(EMPTY_TEXT_TOO_SHORT);
await findSearchBox().vm.$emit('input', 'aaa');
expect(findTableEmptyText()).toBe(EMPTY_TEXT_NO_USERS);
});
it('dispatches the resetMembers action when 1 or 2 characters have been typed', async () => {
expect(actionSpies.resetMembers).not.toHaveBeenCalled();
await findSearchBox().vm.$emit('input', 'a');
expect(actionSpies.resetMembers).toHaveBeenCalledTimes(1);
await findSearchBox().vm.$emit('input', 'aa');
expect(actionSpies.resetMembers).toHaveBeenCalledTimes(2);
await findSearchBox().vm.$emit('input', 'aaa');
expect(actionSpies.resetMembers).toHaveBeenCalledTimes(2);
});
it('dispatches fetchBillableMembersList action when search box is emptied out', async () => {
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(1);
await findSearchBox().vm.$emit('input', 'a');
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(1);
await findSearchBox().vm.$emit('input', '');
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(2);
});
it('dispatches fetchBillableMembersList action when more than 2 characters are typed', async () => {
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(1);
await findSearchBox().vm.$emit('input', 'a');
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(1);
await findSearchBox().vm.$emit('input', 'aa');
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(1);
await findSearchBox().vm.$emit('input', 'aaa');
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(2);
await findSearchBox().vm.$emit('input', 'aaaa');
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(3);
});
});
}); });
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import state from 'ee/billings/seat_usage/store/state'; import State from 'ee/billings/seat_usage/store/state';
import * as types from 'ee/billings/seat_usage/store/mutation_types'; import * as types from 'ee/billings/seat_usage/store/mutation_types';
import * as actions from 'ee/billings/seat_usage/store/actions'; import * as actions from 'ee/billings/seat_usage/store/actions';
import { mockDataSeats } from 'ee_jest/billings/mock_data'; import { mockDataSeats } from 'ee_jest/billings/mock_data';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import Api from '~/api';
jest.mock('~/flash'); jest.mock('~/flash');
describe('seats actions', () => { describe('seats actions', () => {
let mockedState; let state;
let mock; let mock;
beforeEach(() => { beforeEach(() => {
mockedState = state(); state = State();
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
}); });
...@@ -26,7 +27,24 @@ describe('seats actions', () => { ...@@ -26,7 +27,24 @@ describe('seats actions', () => {
describe('fetchBillableMembersList', () => { describe('fetchBillableMembersList', () => {
beforeEach(() => { beforeEach(() => {
gon.api_version = 'v4'; gon.api_version = 'v4';
mockedState.namespaceId = 1; state.namespaceId = 1;
});
it('passes correct arguments to Api call', () => {
const payload = { page: 5, search: 'search string' };
const spy = jest.spyOn(Api, 'fetchBillableGroupMembersList');
testAction({
action: actions.fetchBillableMembersList,
payload,
state,
expectedMutations: expect.anything(),
expectedActions: expect.anything(),
});
expect(spy).toBeCalledWith(state.namespaceId, expect.objectContaining(payload));
spy.mockRestore();
}); });
describe('on success', () => { describe('on success', () => {
...@@ -37,19 +55,17 @@ describe('seats actions', () => { ...@@ -37,19 +55,17 @@ describe('seats actions', () => {
}); });
it('should dispatch the request and success actions', () => { it('should dispatch the request and success actions', () => {
testAction( testAction({
actions.fetchBillableMembersList, action: actions.fetchBillableMembersList,
{}, state,
mockedState, expectedActions: [
[],
[
{ type: 'requestBillableMembersList' }, { type: 'requestBillableMembersList' },
{ {
type: 'receiveBillableMembersListSuccess', type: 'receiveBillableMembersListSuccess',
payload: mockDataSeats, payload: mockDataSeats,
}, },
], ],
); });
}); });
}); });
...@@ -59,59 +75,60 @@ describe('seats actions', () => { ...@@ -59,59 +75,60 @@ describe('seats actions', () => {
}); });
it('should dispatch the request and error actions', () => { it('should dispatch the request and error actions', () => {
testAction( testAction({
actions.fetchBillableMembersList, action: actions.fetchBillableMembersList,
{}, state,
mockedState, expectedActions: [
[], { type: 'requestBillableMembersList' },
[{ type: 'requestBillableMembersList' }, { type: 'receiveBillableMembersListError' }], { type: 'receiveBillableMembersListError' },
); ],
});
}); });
}); });
}); });
describe('requestBillableMembersList', () => { describe('requestBillableMembersList', () => {
it('should commit the request mutation', () => { it('should commit the request mutation', () => {
testAction( testAction({
actions.requestBillableMembersList, action: actions.requestBillableMembersList,
{},
state, state,
[{ type: types.REQUEST_BILLABLE_MEMBERS }], expectedMutations: [{ type: types.REQUEST_BILLABLE_MEMBERS }],
[], });
);
}); });
}); });
describe('receiveBillableMembersListSuccess', () => { describe('receiveBillableMembersListSuccess', () => {
it('should commit the success mutation', () => { it('should commit the success mutation', () => {
testAction( testAction({
actions.receiveBillableMembersListSuccess, action: actions.receiveBillableMembersListSuccess,
mockDataSeats,
mockedState,
[
{
type: types.RECEIVE_BILLABLE_MEMBERS_SUCCESS,
payload: mockDataSeats, payload: mockDataSeats,
}, state,
expectedMutations: [
{ type: types.RECEIVE_BILLABLE_MEMBERS_SUCCESS, payload: mockDataSeats },
], ],
[], });
);
}); });
}); });
describe('receiveBillableMembersListError', () => { describe('receiveBillableMembersListError', () => {
it('should commit the error mutation', (done) => { it('should commit the error mutation', async () => {
testAction( await testAction({
actions.receiveBillableMembersListError, action: actions.receiveBillableMembersListError,
{}, state,
mockedState, expectedMutations: [{ type: types.RECEIVE_BILLABLE_MEMBERS_ERROR }],
[{ type: types.RECEIVE_BILLABLE_MEMBERS_ERROR }], });
[],
() => {
expect(createFlash).toHaveBeenCalled(); expect(createFlash).toHaveBeenCalled();
done(); });
}, });
);
describe('resetMembers', () => {
it('should commit mutation', () => {
testAction({
action: actions.resetMembers,
state,
expectedMutations: [{ type: types.RESET_MEMBERS }],
});
}); });
}); });
}); });
...@@ -30,6 +30,8 @@ describe('EE billings seats module mutations', () => { ...@@ -30,6 +30,8 @@ describe('EE billings seats module mutations', () => {
}); });
it('sets state as expected', () => { it('sets state as expected', () => {
expect(state.members).toMatchObject(mockDataSeats.data);
expect(state.total).toBe('3'); expect(state.total).toBe('3');
expect(state.page).toBe('1'); expect(state.page).toBe('1');
expect(state.perPage).toBe('1'); expect(state.perPage).toBe('1');
...@@ -53,4 +55,37 @@ describe('EE billings seats module mutations', () => { ...@@ -53,4 +55,37 @@ describe('EE billings seats module mutations', () => {
expect(state.hasError).toBeTruthy(); expect(state.hasError).toBeTruthy();
}); });
}); });
describe(types.SET_SEARCH, () => {
const SEARCH_STRING = 'a search string';
beforeEach(() => {
mutations[types.SET_SEARCH](state, SEARCH_STRING);
});
it('sets the search state', () => {
expect(state.search).toBe(SEARCH_STRING);
});
});
describe(types.RESET_MEMBERS, () => {
beforeEach(() => {
mutations[types.RECEIVE_BILLABLE_MEMBERS_SUCCESS](state, mockDataSeats);
mutations[types.RESET_MEMBERS](state);
});
it('resets members state', () => {
expect(state.members).toMatchObject([]);
expect(state.total).toBeNull();
expect(state.page).toBeNull();
expect(state.perPage).toBeNull();
expect(state.isLoading).toBeFalsy();
});
it('sets isLoading to false', () => {
expect(state.isLoading).toBeFalsy();
});
});
}); });
...@@ -4493,16 +4493,22 @@ msgstr "" ...@@ -4493,16 +4493,22 @@ msgstr ""
msgid "Billing|An error occurred while loading billable members list" msgid "Billing|An error occurred while loading billable members list"
msgstr "" msgstr ""
msgid "Billing|Enter at least three characters to search."
msgstr ""
msgid "Billing|Group"
msgstr ""
msgid "Billing|No users to display." msgid "Billing|No users to display."
msgstr "" msgstr ""
msgid "Billing|Private" msgid "Billing|Private"
msgstr "" msgstr ""
msgid "Billing|Updated live" msgid "Billing|Type to search"
msgstr "" msgstr ""
msgid "Billing|Users occupying seats in %{namespaceName} Group (%{total})" msgid "Billing|Users occupying seats in"
msgstr "" msgstr ""
msgid "Bitbucket Server Import" msgid "Bitbucket Server Import"
......
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