Commit e1961ad3 authored by Andrei Stoicescu's avatar Andrei Stoicescu Committed by Natalia Tepluhina

Add search box to billable users table

 - add search box
 - modify state to handle filtering
 - change styling
parent a5fb595d
......@@ -836,11 +836,18 @@ const Api = {
page: 1,
};
const passedOptions = options;
// calling search API with empty string will not return results
if (!passedOptions.search) {
passedOptions.search = undefined;
}
return axios
.get(url, {
params: {
...defaults,
...options,
...passedOptions,
},
})
.then(({ data, headers }) => {
......
......@@ -5,13 +5,15 @@ import {
GlAvatarLabeled,
GlAvatarLink,
GlPagination,
GlLoadingIcon,
GlTooltipDirective,
GlSearchBoxByType,
GlBadge,
} from '@gitlab/ui';
import { parseInt } from 'lodash';
import { s__, sprintf } from '~/locale';
import { parseInt, debounce } from 'lodash';
import { s__ } from '~/locale';
const AVATAR_SIZE = 32;
const SEARCH_DEBOUNCE_MS = 250;
export default {
directives: {
......@@ -22,31 +24,24 @@ export default {
GlAvatarLabeled,
GlAvatarLink,
GlPagination,
GlLoadingIcon,
GlSearchBoxByType,
GlBadge,
},
data() {
return {
fields: ['user', 'email'],
searchQuery: '',
};
},
computed: {
...mapState(['isLoading', 'page', 'perPage', 'total', 'namespaceId', 'namespaceName']),
...mapState(['isLoading', 'page', 'perPage', 'total', 'namespaceName']),
...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: {
get() {
return parseInt(this.page, 10);
},
set(val) {
this.fetchBillableMembersList(val);
this.fetchBillableMembersList({ page: val, search: this.searchQuery });
},
},
perPageFormatted() {
......@@ -55,14 +50,45 @@ export default {
totalFormatted() {
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() {
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: {
...mapActions(['fetchBillableMembersList']),
inputHandler(val) {
this.fetchBillableMembersList(val);
...mapActions(['fetchBillableMembersList', 'resetMembers']),
onSearchEnter() {
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,
......@@ -73,9 +99,28 @@ export default {
</script>
<template>
<div class="gl-pt-4">
<h4 data-testid="heading">{{ headingText }}</h4>
<p>{{ subHeadingText }}</p>
<section>
<div
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
class="seats-table"
:items="tableItems"
......@@ -83,6 +128,8 @@ export default {
:busy="isLoading"
:show-empty="true"
data-testid="table"
:empty-text="emptyText"
thead-class="gl-display-none"
>
<template #cell(user)="data">
<div class="gl-display-flex">
......@@ -109,14 +156,6 @@ export default {
>
</div>
</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-pagination
......@@ -127,5 +166,5 @@ export default {
align="center"
class="gl-mt-5"
/>
</div>
</section>
</template>
......@@ -3,10 +3,10 @@ import * as types from './mutation_types';
import createFlash from '~/flash';
import { s__ } from '~/locale';
export const fetchBillableMembersList = ({ dispatch, state }, page) => {
export const fetchBillableMembersList = ({ dispatch, state }, { page, search } = {}) => {
dispatch('requestBillableMembersList');
return Api.fetchBillableGroupMembersList(state.namespaceId, { page })
return Api.fetchBillableGroupMembersList(state.namespaceId, { page, search })
.then((data) => dispatch('receiveBillableMembersListSuccess', data))
.catch(() => dispatch('receiveBillableMembersListError'));
};
......@@ -22,3 +22,7 @@ export const receiveBillableMembersListError = ({ commit }) => {
});
commit(types.RECEIVE_BILLABLE_MEMBERS_ERROR);
};
export const resetMembers = ({ commit }) => {
commit(types.RESET_MEMBERS);
};
......@@ -6,6 +6,5 @@ export const tableItems = (state) => {
return { user: { name, username: formattedUserName, avatar_url, web_url }, email };
});
}
return [];
};
export const REQUEST_BILLABLE_MEMBERS = 'REQUEST_BILLABLE_MEMBERS';
export const RECEIVE_BILLABLE_MEMBERS_SUCCESS = 'RECEIVE_BILLABLE_MEMBERS_SUCCESS';
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 {
state.isLoading = false;
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 Vuex from 'vuex';
import SubscriptionSeats from 'ee/billings/seat_usage/components/subscription_seats.vue';
......@@ -8,8 +15,8 @@ const localVue = createLocalVue();
localVue.use(Vuex);
const actionSpies = {
setNamespaceId: jest.fn(),
fetchBillableMembersList: jest.fn(),
resetMembers: jest.fn(),
};
const providedFields = {
......@@ -52,7 +59,13 @@ describe('Subscription Seats', () => {
};
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 serializeUser = (rowWrapper) => {
......@@ -97,7 +110,7 @@ describe('Subscription Seats', () => {
});
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', () => {
describe('heading text', () => {
it('contains the group name and total seats number', () => {
expect(findPageHeading().text()).toMatch(providedFields.namespaceName);
expect(findPageHeading().text()).toMatch('300');
expect(findPageHeadingText().text()).toMatch(providedFields.namespaceName);
expect(findPageHeadingBadge().text()).toMatch('300');
});
});
......@@ -169,4 +182,88 @@ describe('Subscription Seats', () => {
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 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 actions from 'ee/billings/seat_usage/store/actions';
import { mockDataSeats } from 'ee_jest/billings/mock_data';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import Api from '~/api';
jest.mock('~/flash');
describe('seats actions', () => {
let mockedState;
let state;
let mock;
beforeEach(() => {
mockedState = state();
state = State();
mock = new MockAdapter(axios);
});
......@@ -26,7 +27,24 @@ describe('seats actions', () => {
describe('fetchBillableMembersList', () => {
beforeEach(() => {
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', () => {
......@@ -37,19 +55,17 @@ describe('seats actions', () => {
});
it('should dispatch the request and success actions', () => {
testAction(
actions.fetchBillableMembersList,
{},
mockedState,
[],
[
testAction({
action: actions.fetchBillableMembersList,
state,
expectedActions: [
{ type: 'requestBillableMembersList' },
{
type: 'receiveBillableMembersListSuccess',
payload: mockDataSeats,
},
],
);
});
});
});
......@@ -59,59 +75,60 @@ describe('seats actions', () => {
});
it('should dispatch the request and error actions', () => {
testAction(
actions.fetchBillableMembersList,
{},
mockedState,
[],
[{ type: 'requestBillableMembersList' }, { type: 'receiveBillableMembersListError' }],
);
testAction({
action: actions.fetchBillableMembersList,
state,
expectedActions: [
{ type: 'requestBillableMembersList' },
{ type: 'receiveBillableMembersListError' },
],
});
});
});
});
describe('requestBillableMembersList', () => {
it('should commit the request mutation', () => {
testAction(
actions.requestBillableMembersList,
{},
testAction({
action: actions.requestBillableMembersList,
state,
[{ type: types.REQUEST_BILLABLE_MEMBERS }],
[],
);
expectedMutations: [{ type: types.REQUEST_BILLABLE_MEMBERS }],
});
});
});
describe('receiveBillableMembersListSuccess', () => {
it('should commit the success mutation', () => {
testAction(
actions.receiveBillableMembersListSuccess,
mockDataSeats,
mockedState,
[
{
type: types.RECEIVE_BILLABLE_MEMBERS_SUCCESS,
payload: mockDataSeats,
},
testAction({
action: actions.receiveBillableMembersListSuccess,
payload: mockDataSeats,
state,
expectedMutations: [
{ type: types.RECEIVE_BILLABLE_MEMBERS_SUCCESS, payload: mockDataSeats },
],
[],
);
});
});
});
describe('receiveBillableMembersListError', () => {
it('should commit the error mutation', (done) => {
testAction(
actions.receiveBillableMembersListError,
{},
mockedState,
[{ type: types.RECEIVE_BILLABLE_MEMBERS_ERROR }],
[],
() => {
expect(createFlash).toHaveBeenCalled();
done();
},
);
it('should commit the error mutation', async () => {
await testAction({
action: actions.receiveBillableMembersListError,
state,
expectedMutations: [{ type: types.RECEIVE_BILLABLE_MEMBERS_ERROR }],
});
expect(createFlash).toHaveBeenCalled();
});
});
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', () => {
});
it('sets state as expected', () => {
expect(state.members).toMatchObject(mockDataSeats.data);
expect(state.total).toBe('3');
expect(state.page).toBe('1');
expect(state.perPage).toBe('1');
......@@ -53,4 +55,37 @@ describe('EE billings seats module mutations', () => {
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();
});
});
});
......@@ -4487,16 +4487,22 @@ msgstr ""
msgid "Billing|An error occurred while loading billable members list"
msgstr ""
msgid "Billing|Enter at least three characters to search."
msgstr ""
msgid "Billing|Group"
msgstr ""
msgid "Billing|No users to display."
msgstr ""
msgid "Billing|Private"
msgstr ""
msgid "Billing|Updated live"
msgid "Billing|Type to search"
msgstr ""
msgid "Billing|Users occupying seats in %{namespaceName} Group (%{total})"
msgid "Billing|Users occupying seats in"
msgstr ""
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