Commit 816f2263 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '333064-add-pending-members-page' into 'master'

Show pending members in Seats Usage -> Pending Members

See merge request gitlab-org/gitlab!74814
parents 4571d402 a217ce30
......@@ -37,3 +37,22 @@ export const removeBillableMemberFromGroup = (groupId, memberId) => {
return axios.delete(url);
};
const GROUPS_PENDING_MEMBERS_PATH = '/api/:version/groups/:id/pending_members';
const GROUPS_PENDING_MEMBERS_STATE = 'awaiting';
export const fetchPendingGroupMembersList = (namespaceId, options = {}) => {
const url = buildApiUrl(GROUPS_PENDING_MEMBERS_PATH).replace(':id', namespaceId);
const defaults = {
state: GROUPS_PENDING_MEMBERS_STATE,
per_page: DEFAULT_PER_PAGE,
page: 1,
};
return axios.get(url, {
params: {
...defaults,
...options,
},
});
};
import initPendingMembersApp from 'ee/pending_members';
if (document.querySelector('#js-pending-members-app')) {
initPendingMembersApp();
}
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlAvatarLabeled, GlAvatarLink, GlBadge, GlPagination, GlLoadingIcon } from '@gitlab/ui';
import { AVATAR_SIZE } from 'ee/seat_usage/constants';
import { AWAITING_MEMBER_SIGNUP_TEXT } from 'ee/pending_members/constants';
export default {
name: 'PendingMembersApp',
components: { GlAvatarLabeled, GlAvatarLink, GlBadge, GlPagination, GlLoadingIcon },
computed: {
...mapState([
'isLoading',
'page',
'perPage',
'total',
'namespaceName',
'namespaceId',
'seatUsageExportPath',
'pendingMembersPagePath',
'pendingMembersCount',
'search',
]),
...mapGetters(['tableItems']),
currentPage: {
get() {
return this.page;
},
set(val) {
this.setCurrentPage(val);
},
},
},
created() {
this.fetchPendingMembersList();
},
AWAITING_MEMBER_SIGNUP_TEXT,
methods: {
...mapActions(['fetchPendingMembersList', 'setCurrentPage']),
avatarLabel(member) {
if (member.invited) {
return member.email;
}
return member.name ?? '';
},
},
avatarSize: AVATAR_SIZE,
};
</script>
<template>
<div>
<div v-if="isLoading" class="gl-text-center loading">
<gl-loading-icon class="mt-5" size="lg" />
</div>
<template v-else>
<div
v-for="item in tableItems"
:key="item.id"
class="gl-p-5 gl-border-0 gl-border-b-1! gl-border-gray-100 gl-border-solid"
data-testid="pending-members-row"
>
<gl-avatar-link target="blank" :href="item.web_url" :alt="item.name">
<gl-avatar-labeled
:src="item.avatar_url"
:size="$options.avatarSize"
:label="avatarLabel(item)"
>
<template #meta>
<gl-badge v-if="item.invited && item.approved" size="sm" variant="muted">
{{ $options.AWAITING_MEMBER_SIGNUP_TEXT }}
</gl-badge>
</template>
</gl-avatar-labeled>
</gl-avatar-link>
</div>
</template>
<gl-pagination
v-if="currentPage"
v-model="currentPage"
:per-page="perPage"
:total-items="total"
align="center"
class="gl-mt-5"
/>
</div>
</template>
import { s__ } from '~/locale';
// Pending members HTTP headers
export const HEADER_TOTAL_ENTRIES = 'x-total';
export const HEADER_PAGE_NUMBER = 'x-page';
export const HEADER_ITEMS_PER_PAGE = 'x-per-page';
export const AWAITING_MEMBER_SIGNUP_TEXT = s__('Billing|Awaiting member signup');
export const PENDING_MEMBERS_LIST_ERROR = s__(
'Billing|An error occurred while loading pending members list',
);
import Vue from 'vue';
import Vuex from 'vuex';
import PendingMembersApp from './components/app.vue';
import initialStore from './store';
Vue.use(Vuex);
export default (containerId = 'js-pending-members-app') => {
const el = document.getElementById(containerId);
if (!el) {
return false;
}
const { namespaceId, namespaceName } = el.dataset;
return new Vue({
el,
apolloProvider: {},
store: new Vuex.Store(initialStore({ namespaceId, namespaceName })),
render(createElement) {
return createElement(PendingMembersApp);
},
});
};
import * as GroupsApi from 'ee/api/groups_api';
import createFlash from '~/flash';
import { PENDING_MEMBERS_LIST_ERROR } from 'ee/pending_members/constants';
import * as types from './mutation_types';
export const fetchPendingMembersList = ({ commit, state }) => {
commit(types.REQUEST_PENDING_MEMBERS);
const { page, search } = state;
return GroupsApi.fetchPendingGroupMembersList(state.namespaceId, { page, search })
.then(({ data, headers }) => commit(types.RECEIVE_PENDING_MEMBERS_SUCCESS, { data, headers }))
.catch(() => {
commit(types.RECEIVE_PENDING_MEMBERS_ERROR);
createFlash({
message: PENDING_MEMBERS_LIST_ERROR,
});
});
};
export const setCurrentPage = ({ commit, dispatch }, page) => {
commit(types.SET_CURRENT_PAGE, page);
dispatch('fetchPendingMembersList');
};
export const tableItems = (state) => {
return state.members ?? [];
};
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export default (initState = {}) => ({
actions,
mutations,
getters,
state: state(initState),
});
export const REQUEST_PENDING_MEMBERS = 'REQUEST_PENDING_MEMBERS';
export const RECEIVE_PENDING_MEMBERS_SUCCESS = 'RECEIVE_PENDING_MEMBERS_SUCCESS';
export const RECEIVE_PENDING_MEMBERS_ERROR = 'RECEIVE_PENDING_MEMBERS_ERROR';
export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE';
import { HEADER_TOTAL_ENTRIES, HEADER_PAGE_NUMBER, HEADER_ITEMS_PER_PAGE } from '../constants';
import * as types from './mutation_types';
export default {
[types.REQUEST_PENDING_MEMBERS](state) {
state.isLoading = true;
state.hasError = false;
},
[types.RECEIVE_PENDING_MEMBERS_SUCCESS](state, payload) {
const { data, headers } = payload;
state.members = data;
state.total = Number(headers[HEADER_TOTAL_ENTRIES]);
state.page = Number(headers[HEADER_PAGE_NUMBER]);
state.perPage = Number(headers[HEADER_ITEMS_PER_PAGE]);
state.isLoading = false;
},
[types.RECEIVE_PENDING_MEMBERS_ERROR](state) {
state.isLoading = false;
state.hasError = true;
},
[types.SET_CURRENT_PAGE](state, pageNumber) {
state.page = pageNumber;
},
};
export default ({ namespaceId = null, namespaceName = null } = {}) => ({
isLoading: false,
hasError: false,
namespaceId,
namespaceName,
members: [],
total: null,
page: null,
perPage: null,
});
- page_title s_("UsageQuota|Pending Members")
- add_to_breadcrumbs s_('UsageQuota|Usage Quotas'), group_usage_quotas_path(@group)
%h3.page-title
= s_('UsageQuota|Pending Members')
#js-pending-members-app{ data: { } }
#js-pending-members-app{ data: { namespace_id: @group.id, namespace_name: @group.name } }
......@@ -11,6 +11,7 @@ describe('GroupsApi', () => {
api_version: dummyApiVersion,
relative_url_root: dummyUrlRoot,
};
const namespaceId = 1000;
let originalGon;
let mock;
......@@ -27,8 +28,6 @@ describe('GroupsApi', () => {
});
describe('Billable members list', () => {
const namespaceId = 1000;
describe('fetchBillableGroupMembersList', () => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${namespaceId}/billable_members`;
......@@ -75,4 +74,20 @@ describe('GroupsApi', () => {
});
});
});
describe('Pending group members list', () => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${namespaceId}/pending_members`;
it('sends GET request using the right URL', async () => {
jest.spyOn(axios, 'get');
mock.onGet(expectedUrl).replyOnce(httpStatus.OK, []);
const { data } = await GroupsApi.fetchPendingGroupMembersList(namespaceId);
expect(data).toEqual([]);
expect(axios.get).toHaveBeenCalledWith(expectedUrl, {
params: { page: 1, per_page: DEFAULT_PER_PAGE, state: 'awaiting' },
});
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PendingMembersApp renders pending members 1`] = `
Array [
"<div data-testid=\\"pending-members-row\\" class=\\"gl-p-5 gl-border-0 gl-border-b-1! gl-border-gray-100 gl-border-solid\\">
<gl-avatar-link-stub target=\\"blank\\" href=\\"http://127.0.0.1:3000/334050-1\\" alt=\\"334050-1 334050-1\\">
<gl-avatar-labeled-stub label=\\"334050-1 334050-1\\" sublabel=\\"\\" labellink=\\"\\" sublabellink=\\"\\" src=\\"https://www.gravatar.com/avatar/9987bae8f71451bb2d422d0596367b25?s=80&amp;d=identicon\\" size=\\"32\\"></gl-avatar-labeled-stub>
</gl-avatar-link-stub>
</div>",
"<div data-testid=\\"pending-members-row\\" class=\\"gl-p-5 gl-border-0 gl-border-b-1! gl-border-gray-100 gl-border-solid\\">
<gl-avatar-link-stub target=\\"blank\\">
<gl-avatar-labeled-stub label=\\"first-invite@gitlab.com\\" sublabel=\\"\\" labellink=\\"\\" sublabellink=\\"\\" src=\\"https://www.gravatar.com/avatar/8bad6be3d5070e7f7865d91a50f44f1f?s=80&amp;d=identicon\\" size=\\"32\\"></gl-avatar-labeled-stub>
</gl-avatar-link-stub>
</div>",
]
`;
import { GlPagination, GlBadge, GlAvatarLabeled } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { mockDataMembers, mockInvitedApprovedMember } from 'ee_jest/pending_members/mock_data';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import PendingMembersApp from 'ee/pending_members/components/app.vue';
Vue.use(Vuex);
const actionSpies = {
fetchPendingMembersList: jest.fn(),
};
const providedFields = {
namespaceId: '1000',
namespaceName: 'Test Group Name',
};
const fakeStore = ({ initialState, initialGetters }) =>
new Vuex.Store({
actions: actionSpies,
getters: {
tableItems: () => mockDataMembers.data,
...initialGetters,
},
state: {
isLoading: false,
hasError: false,
namespaceId: 1,
members: mockDataMembers.data,
total: 300,
page: 1,
perPage: 5,
...providedFields,
...initialState,
},
});
describe('PendingMembersApp', () => {
let wrapper;
const createComponent = ({ initialState = {}, initialGetters = {}, stubs = {} } = {}) => {
wrapper = extendedWrapper(
shallowMount(PendingMembersApp, {
store: fakeStore({ initialState, initialGetters }),
stubs,
}),
);
};
const findMemberRows = () => wrapper.findAllByTestId('pending-members-row');
const findPagination = () => wrapper.findComponent(GlPagination);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders pending members', () => {
const memberRows = findMemberRows();
expect(memberRows.length).toBe(mockDataMembers.data.length);
expect(findMemberRows().wrappers.map((w) => w.html())).toMatchSnapshot();
});
it('pagination is rendered and passed correct values', () => {
const pagination = findPagination();
expect(pagination.props()).toMatchObject({
perPage: 5,
totalItems: 300,
});
});
it('render badge for approved invited members', () => {
createComponent({
stubs: { GlBadge, GlAvatarLabeled },
initialGetters: { tableItems: () => [mockInvitedApprovedMember] },
initialState: { members: [mockInvitedApprovedMember] },
});
expect(wrapper.find(GlBadge).text()).toEqual('Awaiting member signup');
});
});
import {
HEADER_TOTAL_ENTRIES,
HEADER_PAGE_NUMBER,
HEADER_ITEMS_PER_PAGE,
} from 'ee/pending_members/constants';
export const mockDataMembers = {
data: [
{
id: 177,
name: '334050-1 334050-1',
username: '334050-1',
email: '334050-1@gitlab.com',
web_url: 'http://127.0.0.1:3000/334050-1',
avatar_url:
'https://www.gravatar.com/avatar/9987bae8f71451bb2d422d0596367b25?s=80&d=identicon',
approved: false,
invited: false,
},
{
id: 178,
email: 'first-invite@gitlab.com',
avatar_url:
'https://www.gravatar.com/avatar/8bad6be3d5070e7f7865d91a50f44f1f?s=80&d=identicon',
approved: false,
invited: true,
},
],
headers: {
[HEADER_TOTAL_ENTRIES]: '3',
[HEADER_PAGE_NUMBER]: '1',
[HEADER_ITEMS_PER_PAGE]: '1',
},
};
export const mockInvitedApprovedMember = {
id: 179,
email: 'second-invite@gitlab.com',
avatar_url: 'https://www.gravatar.com/avatar/c96806e80ab8c4ea4c668d795fcfed0f?s=80&d=identicon',
approved: true,
invited: true,
};
import MockAdapter from 'axios-mock-adapter';
import State from 'ee/pending_members/store/state';
import * as GroupsApi from 'ee/api/groups_api';
import * as actions from 'ee/pending_members/store/actions';
import * as types from 'ee/pending_members/store/mutation_types';
import { mockDataMembers } from 'ee_jest/pending_members/mock_data';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
jest.mock('~/flash');
describe('Pending members actions', () => {
let state;
let mock;
beforeEach(() => {
state = State();
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.reset();
});
describe('fetchPendingGroupMembersList', () => {
beforeEach(() => {
gon.api_version = 'v4';
state.namespaceId = 1;
});
it('passes correct arguments to API call', () => {
const payload = { page: 5 };
state = Object.assign(state, payload);
const spy = jest.spyOn(GroupsApi, 'fetchPendingGroupMembersList');
testAction({
action: actions.fetchPendingMembersList,
payload,
state,
expectedMutations: expect.anything(),
expectedActions: expect.anything(),
});
expect(spy).toBeCalledWith(state.namespaceId, expect.objectContaining(payload));
});
describe('on success', () => {
beforeEach(() => {
mock
.onGet('/api/v4/groups/1/pending_members')
.replyOnce(httpStatusCodes.OK, mockDataMembers.data, mockDataMembers.headers);
});
it('dispatches the request and success action', () => {
testAction({
action: actions.fetchPendingMembersList,
state,
expectedMutations: [
{ type: types.REQUEST_PENDING_MEMBERS },
{ type: types.RECEIVE_PENDING_MEMBERS_SUCCESS, payload: mockDataMembers },
],
});
});
});
describe('on error', () => {
beforeEach(() => {
mock.onGet('/api/v4/groups/1/pending_members').replyOnce(httpStatusCodes.NOT_FOUND, {});
});
it('dispatches the request and error action', async () => {
await testAction({
action: actions.fetchPendingMembersList,
state,
expectedMutations: [
{ type: types.REQUEST_PENDING_MEMBERS },
{ type: types.RECEIVE_PENDING_MEMBERS_ERROR },
],
});
expect(createFlash).toHaveBeenCalled();
});
});
});
});
import * as getters from 'ee/pending_members/store/getters';
import State from 'ee/pending_members/store/state';
import { mockDataMembers } from 'ee_jest/pending_members/mock_data';
describe('Pending members getters', () => {
let state;
beforeEach(() => {
state = State();
});
describe('Table items', () => {
it('returns the expected value if data is provided', () => {
state.members = [...mockDataMembers.data];
expect(getters.tableItems(state)).toEqual(mockDataMembers.data);
});
it('returns an empty array if data is not provided', () => {
state.members = [];
expect(getters.tableItems(state)).toEqual([]);
});
});
});
import { mockDataMembers } from 'ee_jest/pending_members/mock_data';
import * as types from 'ee/pending_members/store/mutation_types';
import mutations from 'ee/pending_members/store/mutations';
import createState from 'ee/pending_members/store/state';
describe('Pending members mutations', () => {
let state;
beforeEach(() => {
state = createState();
});
describe(types.REQUEST_PENDING_MEMBERS, () => {
beforeEach(() => {
mutations[types.REQUEST_PENDING_MEMBERS](state);
});
it('sets isLoading to true', () => {
expect(state.isLoading).toBeTruthy();
});
it('sets hasError to false', () => {
expect(state.hasError).toBeFalsy();
});
});
describe(types.RECEIVE_PENDING_MEMBERS_SUCCESS, () => {
beforeEach(() => {
mutations[types.RECEIVE_PENDING_MEMBERS_SUCCESS](state, mockDataMembers);
});
it('sets state as expected', () => {
expect(state.members).toMatchObject(mockDataMembers.data);
expect(state.total).toBe(3);
expect(state.page).toBe(1);
expect(state.perPage).toBe(1);
});
it('sets isLoading to false', () => {
expect(state.isLoading).toBeFalsy();
});
});
describe(types.RECEIVE_PENDING_MEMBERS_ERROR, () => {
beforeEach(() => {
mutations[types.RECEIVE_PENDING_MEMBERS_ERROR](state);
});
it('sets isLoading to false', () => {
expect(state.isLoading).toBeFalsy();
});
it('sets hasError to true', () => {
expect(state.hasError).toBeTruthy();
});
});
describe(types.SET_CURRENT_PAGE, () => {
it('sets the page state', () => {
mutations[types.SET_CURRENT_PAGE](state, 1);
expect(state.page).toBe(1);
});
});
});
......@@ -5481,9 +5481,15 @@ msgstr ""
msgid "Billing|An error occurred while loading billable members list"
msgstr ""
msgid "Billing|An error occurred while loading pending members list"
msgstr ""
msgid "Billing|An error occurred while removing a billable member"
msgstr ""
msgid "Billing|Awaiting member signup"
msgstr ""
msgid "Billing|Cannot remove user"
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