Commit f3d8fb97 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch 'vs-billable-members-list-drilldown' into 'master'

Add details table to seat usage table

See merge request gitlab-org/gitlab!59357
parents 72309019 825e8f8b
......@@ -51,6 +51,8 @@ export default {
issueMetricSingleImagePath:
'/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) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
......@@ -430,4 +432,12 @@ export default {
return { data, headers };
});
},
fetchBillableGroupMemberMemberships(namespaceId, memberId) {
const url = Api.buildUrl(this.billableGroupMemberMembershipsPath)
.replace(':group_id', namespaceId)
.replace(':member_id', memberId);
return axios.get(url);
},
};
<script>
import { GlTable, GlBadge, GlLink } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
import { formatDate } from '~/lib/utils/datetime_utility';
import { DETAILS_FIELDS } from '../constants';
import SubscriptionSeatDetailsLoader from './subscription_seat_details_loader.vue';
export default {
name: 'SubscriptionSeatDetails',
components: {
GlBadge,
GlTable,
GlLink,
SubscriptionSeatDetailsLoader,
},
props: {
seatMemberId: {
type: Number,
required: true,
},
},
computed: {
...mapGetters(['membershipsById']),
state() {
return this.membershipsById(this.seatMemberId);
},
items() {
return this.state.items;
},
isLoading() {
return this.state.isLoading;
},
},
created() {
this.fetchBillableMemberDetails(this.seatMemberId);
},
methods: {
...mapActions(['fetchBillableMemberDetails']),
formatDate,
},
fields: DETAILS_FIELDS,
};
</script>
<template>
<div v-if="isLoading">
<subscription-seat-details-loader />
</div>
<gl-table v-else :fields="$options.fields" :items="items" data-testid="seat-usage-details">
<template #cell(source_full_name)="{ item }">
<gl-link :href="item.source_members_url" target="_blank">{{ item.source_full_name }}</gl-link>
</template>
<template #cell(created_at)="{ item }">
<span>{{ formatDate(item.created_at, 'yyyy-mm-dd') }}</span>
</template>
<template #cell(expires_at)="{ item }">
<span>{{ item.expires_at ? formatDate(item.expires_at, 'yyyy-mm-dd') : __('Never') }}</span>
</template>
<template #cell(role)="{ item }">
<gl-badge>{{ item.access_level.string_value }}</gl-badge>
</template>
</gl-table>
</template>
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
export default {
components: {
GlSkeletonLoader,
},
shapes: [
{ type: 'rect', width: '100', height: '10', x: '20', y: '20' },
{ type: 'rect', width: '100', height: '10', x: '385', y: '20' },
{ type: 'rect', width: '100', height: '10', x: '760', y: '20' },
{ type: 'rect', width: '30', height: '10', x: '970', y: '20' },
],
rowsToRender: {
mobile: 1,
desktop: 5,
},
};
</script>
<template>
<div>
<div class="gl-flex-direction-column gl-sm-display-none" data-testid="mobile-loader">
<gl-skeleton-loader
v-for="index in $options.rowsToRender.mobile"
:key="index"
:width="500"
:height="170"
preserve-aspect-ratio="xMinYMax meet"
>
<rect width="500" height="10" x="0" y="15" rx="4" />
</gl-skeleton-loader>
</div>
<div
class="gl-display-none gl-sm-display-flex gl-flex-direction-column"
data-testid="desktop-loader"
>
<gl-skeleton-loader
v-for="index in $options.rowsToRender.desktop"
:key="index"
:width="1000"
:height="54"
preserve-aspect-ratio="xMinYMax meet"
>
<component
:is="r.type"
v-for="(r, rIndex) in $options.shapes"
:key="rIndex"
rx="4"
v-bind="r"
/>
</gl-skeleton-loader>
</div>
</div>
</template>
......@@ -3,9 +3,11 @@ import {
GlAvatarLabeled,
GlAvatarLink,
GlBadge,
GlButton,
GlDropdown,
GlDropdownItem,
GlModalDirective,
GlIcon,
GlPagination,
GlSearchBoxByType,
GlTable,
......@@ -21,6 +23,7 @@ import {
} from 'ee/billings/seat_usage/constants';
import { s__ } from '~/locale';
import RemoveBillableMemberModal from './remove_billable_member_modal.vue';
import SubscriptionSeatDetails from './subscription_seat_details.vue';
export default {
directives: {
......@@ -31,12 +34,15 @@ export default {
GlAvatarLabeled,
GlAvatarLink,
GlBadge,
GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
GlPagination,
GlSearchBoxByType,
GlTable,
RemoveBillableMemberModal,
SubscriptionSeatDetails,
},
data() {
return {
......@@ -156,22 +162,35 @@ export default {
data-testid="table"
:empty-text="emptyText"
>
<template #cell(user)="data">
<template #cell(user)="{ item, toggleDetails, detailsShowing }">
<div class="gl-display-flex">
<gl-avatar-link target="blank" :href="data.value.web_url" :alt="data.value.name">
<gl-button
variant="link"
class="gl-mr-2"
:aria-label="s__('Billing|Toggle seat details')"
data-testid="toggle-seat-usage-details"
@click="toggleDetails"
>
<gl-icon
:name="detailsShowing ? 'angle-down' : 'angle-right'"
class="text-secondary-900"
/>
</gl-button>
<gl-avatar-link target="blank" :href="item.user.web_url" :alt="item.user.name">
<gl-avatar-labeled
:src="data.value.avatar_url"
:src="item.user.avatar_url"
:size="$options.avatarSize"
:label="data.value.name"
:sub-label="data.value.username"
:label="item.user.name"
:sub-label="item.user.username"
/>
</gl-avatar-link>
</div>
</template>
<template #cell(email)="data">
<template #cell(email)="{ item }">
<div data-testid="email">
<span v-if="data.value" class="gl-text-gray-900">{{ data.value }}</span>
<span v-if="item.email" class="gl-text-gray-900">{{ item.email }}</span>
<span
v-else
v-gl-tooltip
......@@ -199,6 +218,10 @@ export default {
</gl-dropdown-item>
</gl-dropdown>
</template>
<template #row-details="{ item }">
<subscription-seat-details :seat-member-id="item.user.id" />
</template>
</gl-table>
<gl-pagination
......
......@@ -28,6 +28,13 @@ export const FIELDS = [
},
];
export const DETAILS_FIELDS = [
{ key: 'source_full_name', label: s__('Billing|Direct memberships'), thClass: thWidthClass(40) },
{ key: 'created_at', label: __('Access granted'), thClass: thWidthClass(40) },
{ key: 'expires_at', label: __('Access expires'), thClass: thWidthClass(40) },
{ key: 'role', label: __('Role'), thClass: thWidthClass(40) },
];
export const REMOVE_BILLABLE_MEMBER_MODAL_ID = 'billable-member-remove-modal';
export const REMOVE_BILLABLE_MEMBER_MODAL_CONTENT_TEXT_TEMPLATE = s__(
`Billing|You are about to remove user %{username} from your subscription.
......
......@@ -55,3 +55,30 @@ export const removeBillableMemberError = ({ commit }) => {
});
commit(types.REMOVE_BILLABLE_MEMBER_ERROR);
};
export const fetchBillableMemberDetails = ({ dispatch, commit, state }, memberId) => {
if (state.userDetails[memberId]) {
commit(types.FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS, {
memberId,
memberships: state.userDetails[memberId].items,
});
return Promise.resolve();
}
commit(types.FETCH_BILLABLE_MEMBER_DETAILS, memberId);
return Api.fetchBillableGroupMemberMemberships(state.namespaceId, memberId)
.then(({ data }) =>
commit(types.FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS, { memberId, memberships: data }),
)
.catch(() => dispatch('fetchBillableMemberDetailsError', memberId));
};
export const fetchBillableMemberDetailsError = ({ commit }, memberId) => {
commit(types.FETCH_BILLABLE_MEMBER_DETAILS_ERROR, memberId);
createFlash({
message: s__('Billing|An error occurred while getting a billable member details'),
});
};
......@@ -20,3 +20,7 @@ export const tableItems = (state) => {
}
return [];
};
export const membershipsById = (state) => (memberId) => {
return state.userDetails[memberId] || { isLoading: true, items: [] };
};
......@@ -9,3 +9,7 @@ export const REMOVE_BILLABLE_MEMBER = 'REMOVE_BILLABLE_MEMBER';
export const REMOVE_BILLABLE_MEMBER_SUCCESS = 'REMOVE_BILLABLE_MEMBER_SUCCESS';
export const REMOVE_BILLABLE_MEMBER_ERROR = 'REMOVE_BILLABLE_MEMBER_ERROR';
export const SET_BILLABLE_MEMBER_TO_REMOVE = 'SET_BILLABLE_MEMBER_TO_REMOVE';
export const FETCH_BILLABLE_MEMBER_DETAILS = 'FETCH_BILLABLE_MEMBER_DETAILS';
export const FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS = 'FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS';
export const FETCH_BILLABLE_MEMBER_DETAILS_ERROR = 'FETCH_BILLABLE_MEMBER_DETAILS_ERROR';
import Vue from 'vue';
import {
HEADER_TOTAL_ENTRIES,
HEADER_PAGE_NUMBER,
......@@ -67,4 +68,25 @@ export default {
state.hasError = true;
state.billableMemberToRemove = null;
},
[types.FETCH_BILLABLE_MEMBER_DETAILS](state, { memberId }) {
Vue.set(state.userDetails, memberId, {
isLoading: true,
items: [],
});
},
[types.FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS](state, { memberId, memberships }) {
Vue.set(state.userDetails, memberId, {
isLoading: false,
items: memberships,
});
},
[types.FETCH_BILLABLE_MEMBER_DETAILS_ERROR](state, { memberId }) {
Vue.set(state.userDetails, memberId, {
isLoading: false,
items: [],
});
},
};
......@@ -8,4 +8,5 @@ export default ({ namespaceId = null, namespaceName = null } = {}) => ({
page: null,
perPage: null,
billableMemberToRemove: null,
userDetails: {},
});
---
title: Add tests for the details table
merge_request: 59357
author:
type: added
......@@ -30,6 +30,28 @@ RSpec.describe 'Groups > Billing > Seat Usage', :js do
expect(all('tbody tr').count).to eq(3)
end
end
context 'seat usage details table' do
it 'expands the details on click' do
first('[data-testid="toggle-seat-usage-details"]').click
wait_for_requests
expect(page).to have_selector('[data-testid="seat-usage-details"]')
end
it 'hides the details table on click' do
# expand the details table first
first('[data-testid="toggle-seat-usage-details"]').click
wait_for_requests
# and collapse it
first('[data-testid="toggle-seat-usage-details"]').click
expect(page).not_to have_selector('[data-testid="seat-usage-details"]')
end
end
end
context 'when removing user' do
......
......@@ -835,15 +835,11 @@ describe('Api', () => {
});
describe('Billable members list', () => {
let expectedUrl;
let namespaceId;
beforeEach(() => {
namespaceId = 1000;
expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${namespaceId}/billable_members`;
});
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, []);
......@@ -852,6 +848,21 @@ describe('Api', () => {
});
});
});
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', () => {
......
......@@ -102,6 +102,17 @@ export const mockDataSeats = {
},
};
export const mockMemberDetails = [
{
id: 173,
source_id: 155,
source_full_name: 'group_with_ultimate_plan / subgroup',
created_at: '2021-02-25T08:21:32.257Z',
expires_at: null,
access_level: { string_value: 'Owner', integer_value: 50 },
},
];
export const mockTableItems = [
{
email: 'administrator@email.com',
......
import { GlTable } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import Api from 'ee/api';
import SubscriptionSeatDetails from 'ee/billings/seat_usage/components/subscription_seat_details.vue';
import SubscriptionSeatDetailsLoader from 'ee/billings/seat_usage/components/subscription_seat_details_loader.vue';
import createStore from 'ee/billings/seat_usage/store';
import initState from 'ee/billings/seat_usage/store/state';
import { mockMemberDetails } from 'ee_jest/billings/mock_data';
import { stubComponent } from 'helpers/stub_component';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('SubscriptionSeatDetails', () => {
let wrapper;
const actions = {
fetchBillableMemberDetails: jest.fn(),
};
const createComponent = () => {
const store = createStore(initState({ namespaceId: 1, isLoading: true }));
wrapper = shallowMount(SubscriptionSeatDetails, {
propsData: {
seatMemberId: 1,
},
store: new Vuex.Store({ ...store, actions }),
localVue,
stubs: {
GlTable: stubComponent(GlTable),
},
});
};
beforeEach(() => {
Api.fetchBillableGroupMemberMemberships = jest.fn(() =>
Promise.resolve({ data: mockMemberDetails }),
);
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('on created', () => {
it('calls fetchBillableMemberDetails', () => {
expect(actions.fetchBillableMemberDetails).toHaveBeenCalledWith(expect.any(Object), 1);
});
it('displays skeleton loader', () => {
expect(wrapper.findComponent(SubscriptionSeatDetailsLoader).isVisible()).toBe(true);
});
});
});
......@@ -4,7 +4,7 @@ import * as GroupsApi from 'ee/api/groups_api';
import * as actions from 'ee/billings/seat_usage/store/actions';
import * as types from 'ee/billings/seat_usage/store/mutation_types';
import State from 'ee/billings/seat_usage/store/state';
import { mockDataSeats } from 'ee_jest/billings/mock_data';
import { mockDataSeats, mockMemberDetails } from 'ee_jest/billings/mock_data';
import testAction from 'helpers/vuex_action_helper';
import createFlash, { FLASH_TYPES } from '~/flash';
import axios from '~/lib/utils/axios_utils';
......@@ -219,4 +219,113 @@ describe('seats actions', () => {
});
});
});
describe('fetchBillableMemberDetails', () => {
const member = mockDataSeats.data[0];
beforeAll(() => {
Api.fetchBillableGroupMemberMemberships = jest
.fn()
.mockResolvedValue({ data: mockMemberDetails });
});
it('commits fetchBillableMemberDetails', async () => {
await testAction({
action: actions.fetchBillableMemberDetails,
payload: member.id,
state,
expectedMutations: [
{ type: types.FETCH_BILLABLE_MEMBER_DETAILS, payload: member.id },
{
type: types.FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS,
payload: { memberId: member.id, memberships: mockMemberDetails },
},
],
});
});
it('calls fetchBillableGroupMemberMemberships api', async () => {
await testAction({
action: actions.fetchBillableMemberDetails,
payload: member.id,
state,
expectedMutations: [
{ type: types.FETCH_BILLABLE_MEMBER_DETAILS, payload: member.id },
{
type: types.FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS,
payload: { memberId: member.id, memberships: mockMemberDetails },
},
],
});
expect(Api.fetchBillableGroupMemberMemberships).toHaveBeenCalledWith(null, 2);
});
it('calls fetchBillableGroupMemberMemberships api only once', async () => {
await testAction({
action: actions.fetchBillableMemberDetails,
payload: member.id,
state,
expectedMutations: [
{ type: types.FETCH_BILLABLE_MEMBER_DETAILS, payload: member.id },
{
type: types.FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS,
payload: { memberId: member.id, memberships: mockMemberDetails },
},
],
});
state.userDetails[member.id] = { items: mockMemberDetails, isLoading: false };
await testAction({
action: actions.fetchBillableMemberDetails,
payload: member.id,
state,
expectedMutations: [
{
type: types.FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS,
payload: { memberId: member.id, memberships: mockMemberDetails },
},
],
});
expect(Api.fetchBillableGroupMemberMemberships).toHaveBeenCalledTimes(1);
});
describe('on API error', () => {
beforeAll(() => {
Api.fetchBillableGroupMemberMemberships = jest.fn().mockRejectedValue();
});
it('dispatches fetchBillableMemberDetailsError', async () => {
await testAction({
action: actions.fetchBillableMemberDetailsError,
state,
expectedMutations: [{ type: types.FETCH_BILLABLE_MEMBER_DETAILS_ERROR }],
});
});
});
});
describe('fetchBillableMemberDetailsError', () => {
it('commits fetch billable member details error', async () => {
await testAction({
action: actions.fetchBillableMemberDetailsError,
state,
expectedMutations: [{ type: types.FETCH_BILLABLE_MEMBER_DETAILS_ERROR }],
});
});
it('calls createFlash', async () => {
await testAction({
action: actions.fetchBillableMemberDetailsError,
state,
expectedMutations: [{ type: types.FETCH_BILLABLE_MEMBER_DETAILS_ERROR }],
});
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while getting a billable member details',
});
});
});
});
import * as getters from 'ee/billings/seat_usage/store/getters';
import State from 'ee/billings/seat_usage/store/state';
import { mockDataSeats, mockTableItems } from 'ee_jest/billings/mock_data';
import { mockDataSeats, mockTableItems, mockMemberDetails } from 'ee_jest/billings/mock_data';
describe('Seat usage table getters', () => {
let state;
......@@ -22,4 +22,29 @@ describe('Seat usage table getters', () => {
expect(getters.tableItems(state)).toEqual([]);
});
});
describe('membershipsById', () => {
describe('when data is not availlable', () => {
it('returns a base state', () => {
expect(getters.membershipsById(state)(0)).toEqual({
isLoading: true,
items: [],
});
});
});
describe('when data is available', () => {
it('returns user details statep', () => {
state.userDetails[0] = {
isLoading: false,
items: mockMemberDetails,
};
expect(getters.membershipsById(state)(0)).toEqual({
isLoading: false,
items: mockMemberDetails,
});
});
});
});
});
import * as types from 'ee/billings/seat_usage/store/mutation_types';
import mutations from 'ee/billings/seat_usage/store/mutations';
import createState from 'ee/billings/seat_usage/store/state';
import { mockDataSeats } from 'ee_jest/billings/mock_data';
import { mockDataSeats, mockMemberDetails } from 'ee_jest/billings/mock_data';
describe('EE billings seats module mutations', () => {
let state;
......@@ -136,4 +136,45 @@ describe('EE billings seats module mutations', () => {
});
});
});
describe('fetching billable member details', () => {
const member = mockDataSeats.data[0];
describe(types.FETCH_BILLABLE_MEMBER_DETAILS, () => {
it('sets the state to loading', () => {
mutations[types.FETCH_BILLABLE_MEMBER_DETAILS](state, { memberId: member.id });
expect(state.userDetails).toMatchObject({
[member.id.toString()]: {
isLoading: true,
},
});
});
});
describe(types.FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS, () => {
beforeEach(() => {
mutations[types.FETCH_BILLABLE_MEMBER_DETAILS_SUCCESS](state, {
memberId: member.id,
memberships: mockMemberDetails,
});
});
it('sets the state to not loading', () => {
expect(state.userDetails[member.id.toString()].isLoading).toBe(false);
});
it('sets the memberships to the state', () => {
expect(state.userDetails[member.id.toString()].items).toEqual(mockMemberDetails);
});
});
describe(types.FETCH_BILLABLE_MEMBER_DETAILS_ERROR, () => {
it('sets the state to not loading', () => {
mutations[types.FETCH_BILLABLE_MEMBER_DETAILS_ERROR](state, { memberId: member.id });
expect(state.userDetails[member.id.toString()].isLoading).toBe(false);
});
});
});
});
......@@ -5043,12 +5043,18 @@ msgstr ""
msgid "Billing|An email address is only visible for users with public emails."
msgstr ""
msgid "Billing|An error occurred while getting a billable member details"
msgstr ""
msgid "Billing|An error occurred while loading billable members list"
msgstr ""
msgid "Billing|An error occurred while removing a billable member"
msgstr ""
msgid "Billing|Direct memberships"
msgstr ""
msgid "Billing|Enter at least three characters to search."
msgstr ""
......@@ -5064,6 +5070,9 @@ msgstr ""
msgid "Billing|Remove user %{username} from your subscription"
msgstr ""
msgid "Billing|Toggle seat details"
msgstr ""
msgid "Billing|Type %{username} to confirm"
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