Commit 4856eb00 authored by Savas Vedova's avatar Savas Vedova

Merge branch 'vs/add-sorting-to-seat-usage-table' into 'master'

Add ability to sort billable members

See merge request gitlab-org/gitlab!66374
parents f1ed5229 671a17dc
...@@ -45,9 +45,6 @@ export default { ...@@ -45,9 +45,6 @@ export default {
issueMetricImagesPath: '/api/:version/projects/:id/issues/:issue_iid/metric_images', issueMetricImagesPath: '/api/:version/projects/:id/issues/:issue_iid/metric_images',
issueMetricSingleImagePath: issueMetricSingleImagePath:
'/api/:version/projects/:id/issues/:issue_iid/metric_images/:image_id', '/api/:version/projects/:id/issues/:issue_iid/metric_images/:image_id',
billableGroupMembersPath: '/api/:version/groups/:id/billable_members',
billableGroupMemberMembershipsPath:
'/api/:version/groups/:group_id/billable_members/:member_id/memberships',
userSubscription(namespaceId) { userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId)); const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
...@@ -370,41 +367,6 @@ export default { ...@@ -370,41 +367,6 @@ export default {
return axios.delete(individualMetricImageUrl); return axios.delete(individualMetricImageUrl);
}, },
fetchBillableGroupMembersList(namespaceId, options = {}, callback = () => {}) {
const url = Api.buildUrl(this.billableGroupMembersPath).replace(':id', namespaceId);
const defaults = {
per_page: DEFAULT_PER_PAGE,
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,
...passedOptions,
},
})
.then(({ data, headers }) => {
callback(data);
return { data, headers };
});
},
fetchBillableGroupMemberMemberships(namespaceId, memberId) {
const url = Api.buildUrl(this.billableGroupMemberMembershipsPath)
.replace(':group_id', namespaceId)
.replace(':member_id', memberId);
return axios.get(url);
},
projectGroups(id, options) { projectGroups(id, options) {
const url = Api.buildUrl(this.projectGroupsPath).replace(':id', encodeURIComponent(id)); const url = Api.buildUrl(this.projectGroupsPath).replace(':id', encodeURIComponent(id));
......
import { DEFAULT_PER_PAGE } from '~/api';
import { buildApiUrl } from '~/api/api_utils'; import { buildApiUrl } from '~/api/api_utils';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
const GROUPS_BILLABLE_MEMBERS_SINGLE_PATH = '/api/:version/groups/:group_id/billable_members/:id'; const GROUPS_BILLABLE_MEMBERS_SINGLE_PATH = '/api/:version/groups/:group_id/billable_members/:id';
const GROUPS_BILLABLE_MEMBERS_PATH = '/api/:version/groups/:id/billable_members';
const GROUPS_BILLABLE_MEMBERS_SINGLE_MEMBERSHIPS_PATH =
'/api/:version/groups/:group_id/billable_members/:member_id/memberships';
export function removeBillableMemberFromGroup(groupId, memberId, options) { export const fetchBillableGroupMembersList = (namespaceId, options = {}) => {
const url = buildApiUrl(GROUPS_BILLABLE_MEMBERS_PATH).replace(':id', namespaceId);
const defaults = {
per_page: DEFAULT_PER_PAGE,
page: 1,
};
return axios.get(url, {
params: {
...defaults,
...options,
},
});
};
export const fetchBillableGroupMemberMemberships = (namespaceId, memberId) => {
const url = buildApiUrl(GROUPS_BILLABLE_MEMBERS_SINGLE_MEMBERSHIPS_PATH)
.replace(':group_id', namespaceId)
.replace(':member_id', memberId);
return axios.get(url);
};
export const removeBillableMemberFromGroup = (groupId, memberId) => {
const url = buildApiUrl(GROUPS_BILLABLE_MEMBERS_SINGLE_PATH) const url = buildApiUrl(GROUPS_BILLABLE_MEMBERS_SINGLE_PATH)
.replace(':group_id', groupId) .replace(':group_id', groupId)
.replace(':id', memberId); .replace(':id', memberId);
return axios.delete(url, { params: { ...options } }); return axios.delete(url);
} };
...@@ -10,22 +10,21 @@ import { ...@@ -10,22 +10,21 @@ import {
GlModalDirective, GlModalDirective,
GlIcon, GlIcon,
GlPagination, GlPagination,
GlSearchBoxByType,
GlTable, GlTable,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { parseInt, debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { import {
FIELDS, FIELDS,
AVATAR_SIZE, AVATAR_SIZE,
SEARCH_DEBOUNCE_MS,
REMOVE_BILLABLE_MEMBER_MODAL_ID, REMOVE_BILLABLE_MEMBER_MODAL_ID,
CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_ID, CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_ID,
CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_TITLE, CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_TITLE,
CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT, CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT,
SORT_OPTIONS,
} from 'ee/billings/seat_usage/constants'; } from 'ee/billings/seat_usage/constants';
import { s__ } from '~/locale'; import { s__, __ } from '~/locale';
import FilterSortContainerRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import RemoveBillableMemberModal from './remove_billable_member_modal.vue'; import RemoveBillableMemberModal from './remove_billable_member_modal.vue';
import SubscriptionSeatDetails from './subscription_seat_details.vue'; import SubscriptionSeatDetails from './subscription_seat_details.vue';
...@@ -44,15 +43,10 @@ export default { ...@@ -44,15 +43,10 @@ export default {
GlModal, GlModal,
GlIcon, GlIcon,
GlPagination, GlPagination,
GlSearchBoxByType,
GlTable, GlTable,
RemoveBillableMemberModal, RemoveBillableMemberModal,
SubscriptionSeatDetails, SubscriptionSeatDetails,
}, FilterSortContainerRoot,
data() {
return {
searchQuery: '',
};
}, },
computed: { computed: {
...mapState([ ...mapState([
...@@ -63,44 +57,26 @@ export default { ...@@ -63,44 +57,26 @@ export default {
'namespaceName', 'namespaceName',
'namespaceId', 'namespaceId',
'billableMemberToRemove', 'billableMemberToRemove',
'search',
'sort',
]), ]),
...mapGetters(['tableItems']), ...mapGetters(['tableItems']),
currentPage: { currentPage: {
get() { get() {
return parseInt(this.page, 10); return this.page;
}, },
set(val) { set(val) {
this.fetchBillableMembersList({ page: val, search: this.searchQuery }); this.setCurrentPage(val);
}, },
}, },
perPageFormatted() {
return parseInt(this.perPage, 10);
},
totalFormatted() {
return parseInt(this.total, 10);
},
emptyText() { emptyText() {
if (this.searchQuery?.length < 3) { if (this.search?.length < 3) {
return s__('Billing|Enter at least three characters to search.'); return s__('Billing|Enter at least three characters to search.');
} }
return s__('Billing|No users to display.'); return s__('Billing|No users to display.');
}, },
}, },
watch: {
searchQuery() {
this.executeQuery();
},
},
created() { created() {
// 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(); this.fetchBillableMembersList();
}, },
methods: { methods: {
...@@ -108,20 +84,22 @@ export default { ...@@ -108,20 +84,22 @@ export default {
'fetchBillableMembersList', 'fetchBillableMembersList',
'resetBillableMembers', 'resetBillableMembers',
'setBillableMemberToRemove', 'setBillableMemberToRemove',
'setSearchQuery',
'setCurrentPage',
'setSortOption',
]), ]),
onSearchEnter() { applyFilter(searchTerms) {
this.debouncedSearch.cancel(); const searchQuery = searchTerms.reduce((terms, searchTerm) => {
this.executeQuery(); if (searchTerm.type !== 'filtered-search-term') {
}, return '';
executeQuery() { }
const queryLength = this.searchQuery?.length;
const MIN_SEARCH_LENGTH = 3;
if (queryLength === 0 || queryLength >= MIN_SEARCH_LENGTH) { return `${terms} ${searchTerm.value.data}`;
this.debouncedSearch(); }, '');
} else if (queryLength < MIN_SEARCH_LENGTH) { this.setSearchQuery(searchQuery.trim() || null);
this.resetBillableMembers(); },
} handleSortOptionChange(sortOption) {
this.setSortOption(sortOption);
}, },
displayRemoveMemberModal(user) { displayRemoveMemberModal(user) {
if (user.removable) { if (user.removable) {
...@@ -141,6 +119,7 @@ export default { ...@@ -141,6 +119,7 @@ export default {
emailNotVisibleTooltipText: s__( emailNotVisibleTooltipText: s__(
'Billing|An email address is only visible for users with public emails.', 'Billing|An email address is only visible for users with public emails.',
), ),
filterUsersPlaceholder: __('Filter users'),
}, },
avatarSize: AVATAR_SIZE, avatarSize: AVATAR_SIZE,
fields: FIELDS, fields: FIELDS,
...@@ -148,6 +127,7 @@ export default { ...@@ -148,6 +127,7 @@ export default {
cannotRemoveModalId: CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_ID, cannotRemoveModalId: CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_ID,
cannotRemoveModalTitle: CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_TITLE, cannotRemoveModalTitle: CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_TITLE,
cannotRemoveModalText: CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT, cannotRemoveModalText: CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT,
sortOptions: SORT_OPTIONS,
}; };
</script> </script>
...@@ -166,11 +146,17 @@ export default { ...@@ -166,11 +146,17 @@ export default {
</h4> </h4>
<gl-badge>{{ total }}</gl-badge> <gl-badge>{{ total }}</gl-badge>
</div> </div>
</div>
<gl-search-box-by-type <div class="gl-bg-gray-10 gl-p-3">
v-model.trim="searchQuery" <filter-sort-container-root
:placeholder="s__('Billing|Type to search')" :namespace="namespaceId"
@keydown.enter.prevent="onSearchEnter" :tokens="[]"
:search-input-placeholder="$options.i18n.filterUsersPlaceholder"
:sort-options="$options.sortOptions"
initial-sort-by="last_activity_on_desc"
@onFilter="applyFilter"
@onSort="handleSortOptionChange"
/> />
</div> </div>
...@@ -258,8 +244,8 @@ export default { ...@@ -258,8 +244,8 @@ export default {
<gl-pagination <gl-pagination
v-if="currentPage" v-if="currentPage"
v-model="currentPage" v-model="currentPage"
:per-page="perPageFormatted" :per-page="perPage"
:total-items="totalFormatted" :total-items="total"
align="center" align="center"
class="gl-mt-5" class="gl-mt-5"
/> />
......
...@@ -47,3 +47,22 @@ group and all its subgroups and projects. This action can't be undone.`, ...@@ -47,3 +47,22 @@ group and all its subgroups and projects. This action can't be undone.`,
); );
export const AVATAR_SIZE = 32; export const AVATAR_SIZE = 32;
export const SEARCH_DEBOUNCE_MS = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; export const SEARCH_DEBOUNCE_MS = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
export const SORT_OPTIONS = [
{
id: 10,
title: __('Last Activity'),
sortDirection: {
descending: 'last_activity_on_desc',
ascending: 'last_activity_on_asc',
},
},
{
id: 20,
title: __('Name'),
sortDirection: {
descending: 'name_desc',
ascending: 'name_asc',
},
},
];
import Api from 'ee/api';
import * as GroupsApi from 'ee/api/groups_api'; import * as GroupsApi from 'ee/api/groups_api';
import createFlash, { FLASH_TYPES } from '~/flash'; import createFlash, { FLASH_TYPES } from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import * as types from './mutation_types'; import * as types from './mutation_types';
export const fetchBillableMembersList = ({ dispatch, state }, { page, search } = {}) => { export const fetchBillableMembersList = ({ commit, dispatch, state }) => {
dispatch('requestBillableMembersList'); commit(types.REQUEST_BILLABLE_MEMBERS);
return Api.fetchBillableGroupMembersList(state.namespaceId, { page, search }) const { page, search, sort } = state;
.then((data) => dispatch('receiveBillableMembersListSuccess', data))
return GroupsApi.fetchBillableGroupMembersList(state.namespaceId, { page, search, sort })
.then(({ data, headers }) => dispatch('receiveBillableMembersListSuccess', { data, headers }))
.catch(() => dispatch('receiveBillableMembersListError')); .catch(() => dispatch('receiveBillableMembersListError'));
}; };
export const requestBillableMembersList = ({ commit }) => commit(types.REQUEST_BILLABLE_MEMBERS);
export const receiveBillableMembersListSuccess = ({ commit }, response) => export const receiveBillableMembersListSuccess = ({ commit }, response) =>
commit(types.RECEIVE_BILLABLE_MEMBERS_SUCCESS, response); commit(types.RECEIVE_BILLABLE_MEMBERS_SUCCESS, response);
...@@ -68,7 +67,7 @@ export const fetchBillableMemberDetails = ({ dispatch, commit, state }, memberId ...@@ -68,7 +67,7 @@ export const fetchBillableMemberDetails = ({ dispatch, commit, state }, memberId
commit(types.FETCH_BILLABLE_MEMBER_DETAILS, memberId); commit(types.FETCH_BILLABLE_MEMBER_DETAILS, memberId);
return Api.fetchBillableGroupMemberMemberships(state.namespaceId, memberId) return GroupsApi.fetchBillableGroupMemberMemberships(state.namespaceId, memberId)
.then(({ data }) => .then(({ data }) =>
commit(types.FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS, { memberId, memberships: data }), commit(types.FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS, { memberId, memberships: data }),
) )
...@@ -82,3 +81,21 @@ export const fetchBillableMemberDetailsError = ({ commit }, memberId) => { ...@@ -82,3 +81,21 @@ export const fetchBillableMemberDetailsError = ({ commit }, memberId) => {
message: s__('Billing|An error occurred while getting a billable member details'), message: s__('Billing|An error occurred while getting a billable member details'),
}); });
}; };
export const setSearchQuery = ({ commit, dispatch }, searchQuery) => {
commit(types.SET_SEARCH_QUERY, searchQuery);
dispatch('fetchBillableMembersList');
};
export const setCurrentPage = ({ commit, dispatch }, page) => {
commit(types.SET_CURRENT_PAGE, page);
dispatch('fetchBillableMembersList');
};
export const setSortOption = ({ commit, dispatch }, sortOption) => {
commit(types.SET_SORT_OPTION, sortOption);
dispatch('fetchBillableMembersList');
};
...@@ -2,7 +2,9 @@ export const REQUEST_BILLABLE_MEMBERS = 'REQUEST_BILLABLE_MEMBERS'; ...@@ -2,7 +2,9 @@ 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 SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE';
export const SET_SORT_OPTION = 'SET_SORT_OPTION';
export const RESET_BILLABLE_MEMBERS = 'RESET_BILLABLE_MEMBERS'; export const RESET_BILLABLE_MEMBERS = 'RESET_BILLABLE_MEMBERS';
export const REMOVE_BILLABLE_MEMBER = 'REMOVE_BILLABLE_MEMBER'; export const REMOVE_BILLABLE_MEMBER = 'REMOVE_BILLABLE_MEMBER';
......
...@@ -16,9 +16,9 @@ export default { ...@@ -16,9 +16,9 @@ export default {
const { data, headers } = payload; const { data, headers } = payload;
state.members = data; state.members = data;
state.total = headers[HEADER_TOTAL_ENTRIES]; state.total = Number(headers[HEADER_TOTAL_ENTRIES]);
state.page = headers[HEADER_PAGE_NUMBER]; state.page = Number(headers[HEADER_PAGE_NUMBER]);
state.perPage = headers[HEADER_ITEMS_PER_PAGE]; state.perPage = Number(headers[HEADER_ITEMS_PER_PAGE]);
state.isLoading = false; state.isLoading = false;
}, },
...@@ -28,8 +28,16 @@ export default { ...@@ -28,8 +28,16 @@ export default {
state.hasError = true; state.hasError = true;
}, },
[types.SET_SEARCH](state, searchString) { [types.SET_SEARCH_QUERY](state, searchString) {
state.search = searchString ?? ''; state.search = searchString ?? null;
},
[types.SET_CURRENT_PAGE](state, pageNumber) {
state.page = pageNumber;
},
[types.SET_SORT_OPTION](state, sortOption) {
state.sort = sortOption;
}, },
[types.RESET_BILLABLE_MEMBERS](state) { [types.RESET_BILLABLE_MEMBERS](state) {
......
...@@ -9,4 +9,6 @@ export default ({ namespaceId = null, namespaceName = null } = {}) => ({ ...@@ -9,4 +9,6 @@ export default ({ namespaceId = null, namespaceName = null } = {}) => ({
perPage: null, perPage: null,
billableMemberToRemove: null, billableMemberToRemove: null,
userDetails: {}, userDetails: {},
search: null,
sort: 'last_activity_on_desc',
}); });
import MockAdapter from 'axios-mock-adapter';
import * as GroupsApi from 'ee/api/groups_api';
import { DEFAULT_PER_PAGE } from '~/api';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
describe('GroupsApi', () => {
const dummyApiVersion = 'v3000';
const dummyUrlRoot = '/gitlab';
const dummyGon = {
api_version: dummyApiVersion,
relative_url_root: dummyUrlRoot,
};
let originalGon;
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
originalGon = window.gon;
window.gon = { ...dummyGon };
});
afterEach(() => {
mock.restore();
window.gon = originalGon;
});
describe('Billable members list', () => {
const namespaceId = 1000;
describe('fetchBillableGroupMembersList', () => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${namespaceId}/billable_members`;
it('GETs the right url', async () => {
jest.spyOn(axios, 'get');
mock.onGet(expectedUrl).replyOnce(httpStatus.OK, []);
const { data } = await GroupsApi.fetchBillableGroupMembersList(namespaceId);
expect(data).toEqual([]);
expect(axios.get).toHaveBeenCalledWith(expectedUrl, {
params: { page: 1, per_page: DEFAULT_PER_PAGE },
});
});
});
describe('fetchBillableGroupMemberMemberships', () => {
const memberId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${namespaceId}/billable_members/${memberId}/memberships`;
it('fetches memberships for the member', async () => {
jest.spyOn(axios, 'get');
mock.onGet(expectedUrl).replyOnce(httpStatus.OK, []);
const { data } = await GroupsApi.fetchBillableGroupMemberMemberships(namespaceId, memberId);
expect(data).toEqual([]);
expect(axios.get).toHaveBeenCalledWith(expectedUrl);
});
});
describe('removeBillableMemberFromGroup', () => {
const memberId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${namespaceId}/billable_members/${memberId}`;
it('removes a billable member from a group', async () => {
jest.spyOn(axios, 'delete');
mock.onDelete(expectedUrl).replyOnce(httpStatus.OK, []);
const { data } = await GroupsApi.removeBillableMemberFromGroup(namespaceId, memberId);
expect(data).toEqual([]);
expect(axios.delete).toHaveBeenCalledWith(expectedUrl);
});
});
});
});
...@@ -742,37 +742,6 @@ describe('Api', () => { ...@@ -742,37 +742,6 @@ describe('Api', () => {
}); });
}); });
describe('Billable members list', () => {
const namespaceId = 1000;
describe('fetchBillableGroupMembersList', () => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${namespaceId}/billable_members`;
it('GETs the right url', () => {
mock.onGet(expectedUrl).replyOnce(httpStatus.OK, []);
return Api.fetchBillableGroupMembersList(namespaceId).then(({ data }) => {
expect(data).toEqual([]);
});
});
});
describe('fetchBillableGroupMemberMemberships', () => {
const memberId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${namespaceId}/billable_members/${memberId}/memberships`;
it('fetches memberships for the member', async () => {
jest.spyOn(axios, 'get');
mock.onGet(expectedUrl).replyOnce(httpStatus.OK, []);
const { data } = await Api.fetchBillableGroupMemberMemberships(namespaceId, memberId);
expect(data).toEqual([]);
expect(axios.get).toHaveBeenCalledWith(expectedUrl);
});
});
});
describe('Project analytics: deployment frequency', () => { describe('Project analytics: deployment frequency', () => {
const projectPath = 'test/project'; const projectPath = 'test/project';
const encodedProjectPath = encodeURIComponent(projectPath); const encodedProjectPath = encodeURIComponent(projectPath);
......
...@@ -4,7 +4,6 @@ import { ...@@ -4,7 +4,6 @@ import {
GlTable, GlTable,
GlAvatarLink, GlAvatarLink,
GlAvatarLabeled, GlAvatarLabeled,
GlSearchBoxByType,
GlBadge, GlBadge,
GlModal, GlModal,
} from '@gitlab/ui'; } from '@gitlab/ui';
...@@ -14,6 +13,7 @@ import SubscriptionSeats from 'ee/billings/seat_usage/components/subscription_se ...@@ -14,6 +13,7 @@ import SubscriptionSeats from 'ee/billings/seat_usage/components/subscription_se
import { CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT } from 'ee/billings/seat_usage/constants'; import { CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT } from 'ee/billings/seat_usage/constants';
import { mockDataSeats, mockTableItems } from 'ee_jest/billings/mock_data'; import { mockDataSeats, mockTableItems } from 'ee_jest/billings/mock_data';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import FilterSortContainerRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -22,6 +22,7 @@ const actionSpies = { ...@@ -22,6 +22,7 @@ const actionSpies = {
fetchBillableMembersList: jest.fn(), fetchBillableMembersList: jest.fn(),
resetBillableMembers: jest.fn(), resetBillableMembers: jest.fn(),
setBillableMemberToRemove: jest.fn(), setBillableMemberToRemove: jest.fn(),
setSearchQuery: jest.fn(),
}; };
const providedFields = { const providedFields = {
...@@ -39,11 +40,12 @@ const fakeStore = ({ initialState, initialGetters }) => ...@@ -39,11 +40,12 @@ const fakeStore = ({ initialState, initialGetters }) =>
state: { state: {
isLoading: false, isLoading: false,
hasError: false, hasError: false,
namespaceId: null, namespaceId: 1,
members: [...mockDataSeats.data], members: [...mockDataSeats.data],
total: 300, total: 300,
page: 1, page: 1,
perPage: 5, perPage: 5,
sort: 'last_activity_on_desc',
...providedFields, ...providedFields,
...initialState, ...initialState,
}, },
...@@ -65,22 +67,21 @@ describe('Subscription Seats', () => { ...@@ -65,22 +67,21 @@ describe('Subscription Seats', () => {
); );
}; };
const findTable = () => wrapper.find(GlTable); const findTable = () => wrapper.findComponent(GlTable);
const findTableEmptyText = () => findTable().attributes('empty-text');
const findPageHeading = () => wrapper.find('[data-testid="heading-info"]'); const findPageHeading = () => wrapper.find('[data-testid="heading-info"]');
const findPageHeadingText = () => findPageHeading().find('[data-testid="heading-info-text"]'); const findPageHeadingText = () => findPageHeading().find('[data-testid="heading-info-text"]');
const findPageHeadingBadge = () => findPageHeading().find(GlBadge); const findPageHeadingBadge = () => findPageHeading().find(GlBadge);
const findSearchBox = () => wrapper.find(GlSearchBoxByType); const findSearchBox = () => wrapper.findComponent(FilterSortContainerRoot);
const findPagination = () => wrapper.find(GlPagination); const findPagination = () => wrapper.findComponent(GlPagination);
const findAllRemoveUserItems = () => wrapper.findAllByTestId('remove-user'); const findAllRemoveUserItems = () => wrapper.findAllByTestId('remove-user');
const findErrorModal = () => wrapper.findComponent(GlModal); const findErrorModal = () => wrapper.findComponent(GlModal);
const serializeUser = (rowWrapper) => { const serializeUser = (rowWrapper) => {
const avatarLink = rowWrapper.find(GlAvatarLink); const avatarLink = rowWrapper.findComponent(GlAvatarLink);
const avatarLabeled = rowWrapper.find(GlAvatarLabeled); const avatarLabeled = rowWrapper.findComponent(GlAvatarLabeled);
return { return {
avatarLink: { avatarLink: {
...@@ -102,7 +103,7 @@ describe('Subscription Seats', () => { ...@@ -102,7 +103,7 @@ describe('Subscription Seats', () => {
user: serializeUser(rowWrapper), user: serializeUser(rowWrapper),
email: emailWrapper.text(), email: emailWrapper.text(),
tooltip: emailWrapper.find('span').attributes('title'), tooltip: emailWrapper.find('span').attributes('title'),
dropdownExists: rowWrapper.find(GlDropdown).exists(), dropdownExists: rowWrapper.findComponent(GlDropdown).exists(),
}; };
}; };
...@@ -204,22 +205,6 @@ describe('Subscription Seats', () => { ...@@ -204,22 +205,6 @@ describe('Subscription Seats', () => {
}); });
}); });
describe('pagination', () => {
it.each([null, NaN, undefined, 'a string', false])(
'will not render given %s for currentPage',
(value) => {
wrapper = createComponent({
initialState: {
page: value,
},
});
expect(findPagination().exists()).toBe(false);
wrapper.destroy();
},
);
});
describe('is loading', () => { describe('is loading', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ initialState: { isLoading: true } }); wrapper = createComponent({ initialState: { isLoading: true } });
...@@ -239,82 +224,17 @@ describe('Subscription Seats', () => { ...@@ -239,82 +224,17 @@ describe('Subscription Seats', () => {
wrapper = createComponent(); wrapper = createComponent();
}); });
it('input event triggers the fetchBillableMembersList action', async () => { it('input event triggers the setSearchQuery action', async () => {
const SEARCH_STRING = 'search string'; const SEARCH_STRING = 'search string';
// fetchBillableMembersList is called once on created() // fetchBillableMembersList is called once on created()
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(1); expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(1);
await findSearchBox().vm.$emit('input', SEARCH_STRING); await findSearchBox().vm.$emit('onFilter', [
{ type: 'filtered-search-term', value: { data: 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.resetBillableMembers action when 1 or 2 characters have been typed', async () => {
expect(actionSpies.resetBillableMembers).not.toHaveBeenCalled();
await findSearchBox().vm.$emit('input', 'a');
expect(actionSpies.resetBillableMembers).toHaveBeenCalledTimes(1);
await findSearchBox().vm.$emit('input', 'aa');
expect(actionSpies.resetBillableMembers).toHaveBeenCalledTimes(2);
await findSearchBox().vm.$emit('input', 'aaa');
expect(actionSpies.resetBillableMembers).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.setSearchQuery).toHaveBeenCalledWith(expect.any(Object), SEARCH_STRING);
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledTimes(3);
}); });
}); });
}); });
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Api from 'ee/api';
import * as GroupsApi from 'ee/api/groups_api'; import * as GroupsApi from 'ee/api/groups_api';
import * as actions from 'ee/billings/seat_usage/store/actions'; import * as actions from 'ee/billings/seat_usage/store/actions';
import * as types from 'ee/billings/seat_usage/store/mutation_types'; import * as types from 'ee/billings/seat_usage/store/mutation_types';
...@@ -32,8 +31,9 @@ describe('seats actions', () => { ...@@ -32,8 +31,9 @@ describe('seats actions', () => {
}); });
it('passes correct arguments to Api call', () => { it('passes correct arguments to Api call', () => {
const payload = { page: 5, search: 'search string' }; const payload = { page: 5, search: 'search string', sort: 'last_activity_on_desc' };
const spy = jest.spyOn(Api, 'fetchBillableGroupMembersList'); state = Object.assign(state, payload);
const spy = jest.spyOn(GroupsApi, 'fetchBillableGroupMembersList');
testAction({ testAction({
action: actions.fetchBillableMembersList, action: actions.fetchBillableMembersList,
...@@ -58,12 +58,12 @@ describe('seats actions', () => { ...@@ -58,12 +58,12 @@ describe('seats actions', () => {
action: actions.fetchBillableMembersList, action: actions.fetchBillableMembersList,
state, state,
expectedActions: [ expectedActions: [
{ type: 'requestBillableMembersList' },
{ {
type: 'receiveBillableMembersListSuccess', type: 'receiveBillableMembersListSuccess',
payload: mockDataSeats, payload: mockDataSeats,
}, },
], ],
expectedMutations: [{ type: types.REQUEST_BILLABLE_MEMBERS }],
}); });
}); });
}); });
...@@ -77,25 +77,13 @@ describe('seats actions', () => { ...@@ -77,25 +77,13 @@ describe('seats actions', () => {
testAction({ testAction({
action: actions.fetchBillableMembersList, action: actions.fetchBillableMembersList,
state, state,
expectedActions: [ expectedActions: [{ type: 'receiveBillableMembersListError' }],
{ type: 'requestBillableMembersList' }, expectedMutations: [{ type: types.REQUEST_BILLABLE_MEMBERS }],
{ type: 'receiveBillableMembersListError' },
],
}); });
}); });
}); });
}); });
describe('requestBillableMembersList', () => {
it('should commit the request mutation', () => {
testAction({
action: actions.requestBillableMembersList,
state,
expectedMutations: [{ type: types.REQUEST_BILLABLE_MEMBERS }],
});
});
});
describe('receiveBillableMembersListSuccess', () => { describe('receiveBillableMembersListSuccess', () => {
it('should commit the success mutation', () => { it('should commit the success mutation', () => {
testAction({ testAction({
...@@ -224,7 +212,7 @@ describe('seats actions', () => { ...@@ -224,7 +212,7 @@ describe('seats actions', () => {
const member = mockDataSeats.data[0]; const member = mockDataSeats.data[0];
beforeAll(() => { beforeAll(() => {
Api.fetchBillableGroupMemberMemberships = jest GroupsApi.fetchBillableGroupMemberMemberships = jest
.fn() .fn()
.mockResolvedValue({ data: mockMemberDetails }); .mockResolvedValue({ data: mockMemberDetails });
}); });
...@@ -258,7 +246,7 @@ describe('seats actions', () => { ...@@ -258,7 +246,7 @@ describe('seats actions', () => {
], ],
}); });
expect(Api.fetchBillableGroupMemberMemberships).toHaveBeenCalledWith(null, 2); expect(GroupsApi.fetchBillableGroupMemberMemberships).toHaveBeenCalledWith(null, 2);
}); });
it('calls fetchBillableGroupMemberMemberships api only once', async () => { it('calls fetchBillableGroupMemberMemberships api only once', async () => {
...@@ -289,12 +277,12 @@ describe('seats actions', () => { ...@@ -289,12 +277,12 @@ describe('seats actions', () => {
], ],
}); });
expect(Api.fetchBillableGroupMemberMemberships).toHaveBeenCalledTimes(1); expect(GroupsApi.fetchBillableGroupMemberMemberships).toHaveBeenCalledTimes(1);
}); });
describe('on API error', () => { describe('on API error', () => {
beforeAll(() => { beforeAll(() => {
Api.fetchBillableGroupMemberMemberships = jest.fn().mockRejectedValue(); GroupsApi.fetchBillableGroupMemberMemberships = jest.fn().mockRejectedValue();
}); });
it('dispatches fetchBillableMemberDetailsError', async () => { it('dispatches fetchBillableMemberDetailsError', async () => {
......
...@@ -32,9 +32,9 @@ describe('EE billings seats module mutations', () => { ...@@ -32,9 +32,9 @@ describe('EE billings seats module mutations', () => {
it('sets state as expected', () => { it('sets state as expected', () => {
expect(state.members).toMatchObject(mockDataSeats.data); 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);
}); });
it('sets isLoading to false', () => { it('sets isLoading to false', () => {
...@@ -56,16 +56,20 @@ describe('EE billings seats module mutations', () => { ...@@ -56,16 +56,20 @@ describe('EE billings seats module mutations', () => {
}); });
}); });
describe(types.SET_SEARCH, () => { describe(types.SET_SEARCH_QUERY, () => {
const SEARCH_STRING = 'a search string'; it('sets the search state', () => {
const SEARCH_STRING = 'a search string';
beforeEach(() => { mutations[types.SET_SEARCH_QUERY](state, SEARCH_STRING);
mutations[types.SET_SEARCH](state, SEARCH_STRING);
});
it('sets the search state', () => {
expect(state.search).toBe(SEARCH_STRING); expect(state.search).toBe(SEARCH_STRING);
}); });
it('sets the search state item to null', () => {
mutations[types.SET_SEARCH_QUERY](state);
expect(state.search).toBe(null);
});
}); });
describe(types.RESET_BILLABLE_MEMBERS, () => { describe(types.RESET_BILLABLE_MEMBERS, () => {
...@@ -176,5 +180,21 @@ describe('EE billings seats module mutations', () => { ...@@ -176,5 +180,21 @@ describe('EE billings seats module mutations', () => {
expect(state.userDetails[member.id.toString()].isLoading).toBe(false); expect(state.userDetails[member.id.toString()].isLoading).toBe(false);
}); });
}); });
describe(types.SET_CURRENT_PAGE, () => {
it('sets the page state', () => {
mutations[types.SET_CURRENT_PAGE](state, 1);
expect(state.page).toBe(1);
});
});
describe(types.SET_SORT_OPTION, () => {
it('sets the sort state', () => {
mutations[types.SET_SORT_OPTION](state, 'last_activity_on_desc');
expect(state.sort).toBe('last_activity_on_desc');
});
});
}); });
}); });
...@@ -5234,9 +5234,6 @@ msgstr "" ...@@ -5234,9 +5234,6 @@ msgstr ""
msgid "Billing|Type %{username} to confirm" msgid "Billing|Type %{username} to confirm"
msgstr "" msgstr ""
msgid "Billing|Type to search"
msgstr ""
msgid "Billing|User was successfully removed" msgid "Billing|User was successfully removed"
msgstr "" msgstr ""
...@@ -14074,6 +14071,9 @@ msgstr "" ...@@ -14074,6 +14071,9 @@ msgstr ""
msgid "Filter results..." msgid "Filter results..."
msgstr "" msgstr ""
msgid "Filter users"
msgstr ""
msgid "Filter your repositories by name" msgid "Filter your repositories by name"
msgstr "" msgstr ""
...@@ -19110,6 +19110,9 @@ msgstr "" ...@@ -19110,6 +19110,9 @@ msgstr ""
msgid "Last Accessed On" msgid "Last Accessed On"
msgstr "" msgstr ""
msgid "Last Activity"
msgstr ""
msgid "Last Pipeline" msgid "Last Pipeline"
msgstr "" msgstr ""
......
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