Commit 5f651fd8 authored by Peter Hegman's avatar Peter Hegman

Merge branch '350788-use-statistics-seats-card' into 'master'

Use StatisticsCard in usage_quotas/seats

See merge request gitlab-org/gitlab!82399
parents 1f9ba955 d7e1c5e0
...@@ -16,10 +16,8 @@ const Template = (_, { argTypes }) => ({ ...@@ -16,10 +16,8 @@ const Template = (_, { argTypes }) => ({
}); });
export const Default = Template.bind({}); export const Default = Template.bind({});
/* eslint-disable @gitlab/require-i18n-strings */
Default.args = { Default.args = {
seatsUsed: 160, seatsUsed: 160,
seatsOwed: 10, seatsOwed: 10,
purchaseButtonLink: 'purchase.com/test', purchaseButtonLink: 'purchase.com/test',
purchaseButtonText: 'Add seats',
}; };
<script> <script>
import { GlLink, GlIcon, GlButton } from '@gitlab/ui'; import { GlLink, GlIcon, GlButton } from '@gitlab/ui';
import { __ } from '~/locale'; import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper'; import { helpPagePath } from '~/helpers/help_page_helper';
export default { export default {
...@@ -11,6 +11,7 @@ export default { ...@@ -11,6 +11,7 @@ export default {
seatsUsedHelpText: __('Learn more about max seats used'), seatsUsedHelpText: __('Learn more about max seats used'),
seatsOwedText: __('Seats owed'), seatsOwedText: __('Seats owed'),
seatsOwedHelpText: __('Learn more about seats owed'), seatsOwedHelpText: __('Learn more about seats owed'),
addSeatsText: s__('Billing|Add seats'),
}, },
helpLinks: { helpLinks: {
seatsOwedLink: helpPagePath('subscriptions/gitlab_com/index', { anchor: 'seats-owed' }), seatsOwedLink: helpPagePath('subscriptions/gitlab_com/index', { anchor: 'seats-owed' }),
...@@ -73,7 +74,9 @@ export default { ...@@ -73,7 +74,9 @@ export default {
class="gl-font-size-h-display gl-font-weight-bold gl-mb-3" class="gl-font-size-h-display gl-font-weight-bold gl-mb-3"
data-testid="seats-used-block" data-testid="seats-used-block"
> >
{{ seatsUsed }} <span class="gl-relative gl-top-1">
{{ seatsUsed }}
</span>
<span class="gl-font-lg"> <span class="gl-font-lg">
{{ $options.i18n.seatsUsedText }} {{ $options.i18n.seatsUsedText }}
</span> </span>
...@@ -90,7 +93,9 @@ export default { ...@@ -90,7 +93,9 @@ export default {
class="gl-font-size-h-display gl-font-weight-bold gl-mb-0" class="gl-font-size-h-display gl-font-weight-bold gl-mb-0"
data-testid="seats-owed-block" data-testid="seats-owed-block"
> >
{{ seatsOwed }} <span class="gl-relative gl-top-1">
{{ seatsOwed }}
</span>
<span class="gl-font-lg"> <span class="gl-font-lg">
{{ $options.i18n.seatsOwedText }} {{ $options.i18n.seatsOwedText }}
</span> </span>
...@@ -104,14 +109,15 @@ export default { ...@@ -104,14 +109,15 @@ export default {
</p> </p>
</div> </div>
<gl-button <gl-button
v-if="purchaseButtonLink && purchaseButtonText" v-if="purchaseButtonLink"
:href="purchaseButtonLink" :href="purchaseButtonLink"
category="primary" category="primary"
target="_blank"
variant="confirm" variant="confirm"
class="gl-ml-3 gl-align-self-start" class="gl-ml-3 gl-align-self-start"
data-testid="purchase-button" data-testid="purchase-button"
> >
{{ purchaseButtonText }} {{ $options.i18n.addSeatsText }}
</gl-button> </gl-button>
</div> </div>
</template> </template>
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { helpPagePath } from '~/helpers/help_page_helper';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import { import {
FIELDS, FIELDS,
...@@ -25,6 +26,8 @@ import { ...@@ -25,6 +26,8 @@ import {
} from 'ee/usage_quotas/seats/constants'; } from 'ee/usage_quotas/seats/constants';
import { s__, __, sprintf, n__ } from '~/locale'; import { s__, __, sprintf, n__ } from '~/locale';
import FilterSortContainerRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import FilterSortContainerRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import StatisticsCard from 'ee/usage_quotas/components/statistics_card.vue';
import StatisticsSeatsCard from 'ee/usage_quotas/components/statistics_seats_card.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';
...@@ -46,6 +49,8 @@ export default { ...@@ -46,6 +49,8 @@ export default {
RemoveBillableMemberModal, RemoveBillableMemberModal,
SubscriptionSeatDetails, SubscriptionSeatDetails,
FilterSortContainerRoot, FilterSortContainerRoot,
StatisticsCard,
StatisticsSeatsCard,
}, },
computed: { computed: {
...mapState([ ...mapState([
...@@ -61,6 +66,12 @@ export default { ...@@ -61,6 +66,12 @@ export default {
'billableMemberToRemove', 'billableMemberToRemove',
'search', 'search',
'sort', 'sort',
'seatsInSubscription',
'seatsInUse',
'maxSeatsUsed',
'seatsOwed',
'addSeatsHref',
'hasNoSubscription',
]), ]),
...mapGetters(['tableItems']), ...mapGetters(['tableItems']),
currentPage: { currentPage: {
...@@ -92,13 +103,24 @@ export default { ...@@ -92,13 +103,24 @@ export default {
shouldShowPendingMembersAlert() { shouldShowPendingMembersAlert() {
return this.pendingMembersCount > 0 && this.pendingMembersPagePath; return this.pendingMembersCount > 0 && this.pendingMembersPagePath;
}, },
seatsInUsePercentage() {
return Math.round((this.seatsInUse * 100) / this.seatsInSubscription);
},
totalSeatsInSubscription() {
return this.hasNoSubscription ? '-' : String(this.seatsInSubscription);
},
totalSeatsInUse() {
return this.hasNoSubscription ? String(this.total) : String(this.seatsInUse);
},
}, },
created() { created() {
this.fetchBillableMembersList(); this.fetchBillableMembersList();
this.fetchGitlabSubscription();
}, },
methods: { methods: {
...mapActions([ ...mapActions([
'fetchBillableMembersList', 'fetchBillableMembersList',
'fetchGitlabSubscription',
'resetBillableMembers', 'resetBillableMembers',
'setBillableMemberToRemove', 'setBillableMemberToRemove',
'setSearchQuery', 'setSearchQuery',
...@@ -141,6 +163,10 @@ export default { ...@@ -141,6 +163,10 @@ export default {
), ),
filterUsersPlaceholder: __('Filter users'), filterUsersPlaceholder: __('Filter users'),
pendingMembersAlertButtonText: s__('Billing|View pending approvals'), pendingMembersAlertButtonText: s__('Billing|View pending approvals'),
seatsInUseText: s__('Billings|Seats in use / Seats in subscription'),
seatsInUseLink: helpPagePath('subscription/gitlab_com/index', {
anchor: 'how-seat-usage-is-determined',
}),
}, },
avatarSize: AVATAR_SIZE, avatarSize: AVATAR_SIZE,
fields: FIELDS, fields: FIELDS,
...@@ -164,35 +190,43 @@ export default { ...@@ -164,35 +190,43 @@ export default {
> >
{{ pendingMembersAlertMessage }} {{ pendingMembersAlertMessage }}
</gl-alert> </gl-alert>
<div <div class="gl-bg-gray-10 gl-display-flex gl-sm-flex-direction-column gl-p-5">
class="gl-bg-gray-10 gl-p-6 gl-md-display-flex gl-justify-content-space-between gl-align-items-center" <statistics-card
> :help-link="$options.i18n.seatsInUseLink"
<div data-testid="heading-info"> :description="$options.i18n.seatsInUseText"
<h4 :percentage="seatsInUsePercentage"
data-testid="heading-info-text" :usage-value="totalSeatsInUse"
class="gl-font-base gl-display-inline-block gl-font-weight-normal" :total-value="totalSeatsInSubscription"
> class="gl-w-full gl-md-w-half gl-md-mr-5"
{{ 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-button v-if="seatUsageExportPath" data-testid="export-button" :href="seatUsageExportPath"> <statistics-seats-card
{{ s__('Billing|Export list') }} :seats-used="maxSeatsUsed"
</gl-button> :seats-owed="seatsOwed"
:purchase-button-link="addSeatsHref"
class="gl-w-full gl-md-w-half gl-md-mt-0 gl-mt-5"
/>
</div> </div>
<div class="gl-bg-gray-10 gl-p-3"> <div class="gl-bg-gray-10 gl-p-5 gl-display-flex">
<filter-sort-container-root <filter-sort-container-root
:namespace="namespaceId" :namespace="namespaceId"
:tokens="[]" :tokens="[]"
:search-input-placeholder="$options.i18n.filterUsersPlaceholder" :search-input-placeholder="$options.i18n.filterUsersPlaceholder"
:sort-options="$options.sortOptions" :sort-options="$options.sortOptions"
initial-sort-by="last_activity_on_desc" initial-sort-by="last_activity_on_desc"
class="gl-flex-grow-1"
@onFilter="applyFilter" @onFilter="applyFilter"
@onSort="setSortOption" @onSort="setSortOption"
/> />
<gl-button
v-if="seatUsageExportPath"
data-testid="export-button"
:href="seatUsageExportPath"
class="gl-ml-3"
>
{{ s__('Billing|Export list') }}
</gl-button>
</div> </div>
<gl-table <gl-table
......
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import SubscriptionSeats from './components/subscription_seats.vue'; import SubscriptionSeats from './components/subscription_seats.vue';
import initialStore from './store'; import initialStore from './store';
...@@ -18,6 +19,8 @@ export default (containerId = 'js-seat-usage-app') => { ...@@ -18,6 +19,8 @@ export default (containerId = 'js-seat-usage-app') => {
seatUsageExportPath, seatUsageExportPath,
pendingMembersPagePath, pendingMembersPagePath,
pendingMembersCount, pendingMembersCount,
addSeatsHref,
hasNoSubscription,
} = el.dataset; } = el.dataset;
return new Vue({ return new Vue({
...@@ -31,6 +34,8 @@ export default (containerId = 'js-seat-usage-app') => { ...@@ -31,6 +34,8 @@ export default (containerId = 'js-seat-usage-app') => {
seatUsageExportPath, seatUsageExportPath,
pendingMembersPagePath, pendingMembersPagePath,
pendingMembersCount, pendingMembersCount,
addSeatsHref,
hasNoSubscription: parseBoolean(hasNoSubscription),
}), }),
), ),
render(createElement) { render(createElement) {
......
import * as GroupsApi from 'ee/api/groups_api'; import * as GroupsApi from 'ee/api/groups_api';
import createFlash, { FLASH_TYPES } from '~/flash'; import Api from 'ee/api';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import * as types from './mutation_types'; import * as types from './mutation_types';
...@@ -13,16 +14,34 @@ export const fetchBillableMembersList = ({ commit, dispatch, state }) => { ...@@ -13,16 +14,34 @@ export const fetchBillableMembersList = ({ commit, dispatch, state }) => {
.catch(() => dispatch('receiveBillableMembersListError')); .catch(() => dispatch('receiveBillableMembersListError'));
}; };
export const fetchGitlabSubscription = ({ commit, dispatch, state }) => {
commit(types.REQUEST_GITLAB_SUBSCRIPTION);
return Api.userSubscription(state.namespaceId)
.then(({ data }) => dispatch('receiveGitlabSubscriptionSuccess', data))
.catch(() => dispatch('receiveGitlabSubscriptionError'));
};
export const receiveBillableMembersListSuccess = ({ commit }, response) => export const receiveBillableMembersListSuccess = ({ commit }, response) =>
commit(types.RECEIVE_BILLABLE_MEMBERS_SUCCESS, response); commit(types.RECEIVE_BILLABLE_MEMBERS_SUCCESS, response);
export const receiveBillableMembersListError = ({ commit }) => { export const receiveBillableMembersListError = ({ commit }) => {
createFlash({ createAlert({
message: s__('Billing|An error occurred while loading billable members list'), message: s__('Billing|An error occurred while loading billable members list.'),
}); });
commit(types.RECEIVE_BILLABLE_MEMBERS_ERROR); commit(types.RECEIVE_BILLABLE_MEMBERS_ERROR);
}; };
export const receiveGitlabSubscriptionSuccess = ({ commit }, response) =>
commit(types.RECEIVE_GITLAB_SUBSCRIPTION_SUCCESS, response);
export const receiveGitlabSubscriptionError = ({ commit }) => {
createAlert({
message: s__('Billing|An error occurred while loading GitLab subscription details.'),
});
commit(types.RECEIVE_GITLAB_SUBSCRIPTION_ERROR);
};
export const resetBillableMembers = ({ commit }) => { export const resetBillableMembers = ({ commit }) => {
commit(types.RESET_BILLABLE_MEMBERS); commit(types.RESET_BILLABLE_MEMBERS);
}; };
...@@ -40,17 +59,17 @@ export const removeBillableMember = ({ dispatch, state }) => { ...@@ -40,17 +59,17 @@ export const removeBillableMember = ({ dispatch, state }) => {
export const removeBillableMemberSuccess = ({ dispatch, commit }) => { export const removeBillableMemberSuccess = ({ dispatch, commit }) => {
dispatch('fetchBillableMembersList'); dispatch('fetchBillableMembersList');
createFlash({ createAlert({
message: s__('Billing|User was successfully removed'), message: s__('Billing|User was successfully removed'),
type: FLASH_TYPES.SUCCESS, variant: VARIANT_SUCCESS,
}); });
commit(types.REMOVE_BILLABLE_MEMBER_SUCCESS); commit(types.REMOVE_BILLABLE_MEMBER_SUCCESS);
}; };
export const removeBillableMemberError = ({ commit }) => { export const removeBillableMemberError = ({ commit }) => {
createFlash({ createAlert({
message: s__('Billing|An error occurred while removing a billable member'), message: s__('Billing|An error occurred while removing a billable member.'),
}); });
commit(types.REMOVE_BILLABLE_MEMBER_ERROR); commit(types.REMOVE_BILLABLE_MEMBER_ERROR);
}; };
...@@ -77,8 +96,8 @@ export const fetchBillableMemberDetails = ({ dispatch, commit, state }, memberId ...@@ -77,8 +96,8 @@ export const fetchBillableMemberDetails = ({ dispatch, commit, state }, memberId
export const fetchBillableMemberDetailsError = ({ commit }, memberId) => { export const fetchBillableMemberDetailsError = ({ commit }, memberId) => {
commit(types.FETCH_BILLABLE_MEMBER_DETAILS_ERROR, memberId); commit(types.FETCH_BILLABLE_MEMBER_DETAILS_ERROR, memberId);
createFlash({ createAlert({
message: s__('Billing|An error occurred while getting a billable member details'), message: s__('Billing|An error occurred while getting a billable member details.'),
}); });
}; };
......
...@@ -2,6 +2,10 @@ export const REQUEST_BILLABLE_MEMBERS = 'REQUEST_BILLABLE_MEMBERS'; ...@@ -2,6 +2,10 @@ 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 REQUEST_GITLAB_SUBSCRIPTION = 'REQUEST_GITLAB_SUBSCRIPTION';
export const RECEIVE_GITLAB_SUBSCRIPTION_SUCCESS = 'RECEIVE_GITLAB_SUBSCRIPTION_SUCCESS';
export const RECEIVE_GITLAB_SUBSCRIPTION_ERROR = 'RECEIVE_GITLAB_SUBSCRIPTION_ERROR';
export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE';
export const SET_SORT_OPTION = 'SET_SORT_OPTION'; export const SET_SORT_OPTION = 'SET_SORT_OPTION';
......
...@@ -12,6 +12,11 @@ export default { ...@@ -12,6 +12,11 @@ export default {
state.hasError = false; state.hasError = false;
}, },
[types.REQUEST_GITLAB_SUBSCRIPTION](state) {
state.isLoading = true;
state.hasError = false;
},
[types.RECEIVE_BILLABLE_MEMBERS_SUCCESS](state, payload) { [types.RECEIVE_BILLABLE_MEMBERS_SUCCESS](state, payload) {
const { data, headers } = payload; const { data, headers } = payload;
state.members = data; state.members = data;
...@@ -23,11 +28,27 @@ export default { ...@@ -23,11 +28,27 @@ export default {
state.isLoading = false; state.isLoading = false;
}, },
[types.RECEIVE_GITLAB_SUBSCRIPTION_SUCCESS](state, payload) {
const { usage } = payload;
state.seatsInSubscription = usage?.seats_in_subscription ?? 0;
state.seatsInUse = usage?.seats_in_use ?? 0;
state.maxSeatsUsed = usage?.max_seats_used ?? 0;
state.seatsOwed = usage?.seats_owed ?? 0;
state.isLoading = false;
},
[types.RECEIVE_BILLABLE_MEMBERS_ERROR](state) { [types.RECEIVE_BILLABLE_MEMBERS_ERROR](state) {
state.isLoading = false; state.isLoading = false;
state.hasError = true; state.hasError = true;
}, },
[types.RECEIVE_GITLAB_SUBSCRIPTION_ERROR](state) {
state.isLoading = false;
state.hasError = true;
},
[types.SET_SEARCH_QUERY](state, searchString) { [types.SET_SEARCH_QUERY](state, searchString) {
state.search = searchString ?? null; state.search = searchString ?? null;
}, },
......
...@@ -4,6 +4,8 @@ export default ({ ...@@ -4,6 +4,8 @@ export default ({
seatUsageExportPath = null, seatUsageExportPath = null,
pendingMembersPagePath = null, pendingMembersPagePath = null,
pendingMembersCount = 0, pendingMembersCount = 0,
addSeatsHref = '',
hasNoSubscription = null,
} = {}) => ({ } = {}) => ({
isLoading: false, isLoading: false,
hasError: false, hasError: false,
...@@ -20,4 +22,10 @@ export default ({ ...@@ -20,4 +22,10 @@ export default ({
userDetails: {}, userDetails: {},
search: null, search: null,
sort: 'last_activity_on_desc', sort: 'last_activity_on_desc',
seatsInSubscription: null,
seatsInUse: null,
maxSeatsUsed: null,
seatsOwed: null,
hasNoSubscription,
addSeatsHref,
}); });
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
= s_('UsageQuota|Storage') = s_('UsageQuota|Storage')
.tab-content .tab-content
.tab-pane#seats-quota-tab .tab-pane#seats-quota-tab
#js-seat-usage-app{ data: { namespace_id: @group.id, namespace_name: @group.name, seat_usage_export_path: group_seat_usage_path(@group, format: :csv), pending_members_page_path: pending_members_page_path, pending_members_count: pending_members_count } } #js-seat-usage-app{ data: { namespace_id: @group.id, namespace_name: @group.name, seat_usage_export_path: group_seat_usage_path(@group, format: :csv), pending_members_page_path: pending_members_page_path, pending_members_count: pending_members_count, add_seats_href: add_seats_url(@group), has_no_subscription: @group.has_free_or_no_subscription?.to_s } }
.tab-pane#pipelines-quota-tab .tab-pane#pipelines-quota-tab
#js-ci-minutes-usage-group{ data: { namespace_id: @group.id } } #js-ci-minutes-usage-group{ data: { namespace_id: @group.id } }
= render "namespaces/pipelines_quota/list", = render "namespaces/pipelines_quota/list",
......
...@@ -5,12 +5,10 @@ import StatisticsSeatsCard from 'ee/usage_quotas/components/statistics_seats_car ...@@ -5,12 +5,10 @@ import StatisticsSeatsCard from 'ee/usage_quotas/components/statistics_seats_car
describe('StatisticsSeatsCard', () => { describe('StatisticsSeatsCard', () => {
let wrapper; let wrapper;
const purchaseButtonLink = 'https://gitlab.com/purchase-more-seats'; const purchaseButtonLink = 'https://gitlab.com/purchase-more-seats';
const purchaseButtonText = 'Add seats';
const defaultProps = { const defaultProps = {
seatsUsed: 20, seatsUsed: 20,
seatsOwed: 5, seatsOwed: 5,
purchaseButtonLink, purchaseButtonLink,
purchaseButtonText,
}; };
const createComponent = (props = {}) => { const createComponent = (props = {}) => {
...@@ -71,7 +69,7 @@ describe('StatisticsSeatsCard', () => { ...@@ -71,7 +69,7 @@ describe('StatisticsSeatsCard', () => {
expect(purchaseButton.exists()).toBe(true); expect(purchaseButton.exists()).toBe(true);
expect(purchaseButton.attributes('href')).toBe(purchaseButtonLink); expect(purchaseButton.attributes('href')).toBe(purchaseButtonLink);
expect(purchaseButton.text()).toBe(purchaseButtonText); expect(purchaseButton.attributes('target')).toBe('_blank');
}); });
it('does not render purchase button if purchase link is not passed', () => { it('does not render purchase button if purchase link is not passed', () => {
...@@ -79,11 +77,5 @@ describe('StatisticsSeatsCard', () => { ...@@ -79,11 +77,5 @@ describe('StatisticsSeatsCard', () => {
expect(findPurchaseButton().exists()).toBe(false); expect(findPurchaseButton().exists()).toBe(false);
}); });
it('does not render purchase button if purchase text is not passed', () => {
createComponent({ purchaseButtonText: null });
expect(findPurchaseButton().exists()).toBe(false);
});
}); });
}); });
...@@ -11,6 +11,8 @@ import { ...@@ -11,6 +11,8 @@ import {
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import StatisticsCard from 'ee/usage_quotas/components/statistics_card.vue';
import StatisticsSeatsCard from 'ee/usage_quotas/components/statistics_seats_card.vue';
import SubscriptionSeats from 'ee/usage_quotas/seats/components/subscription_seats.vue'; import SubscriptionSeats from 'ee/usage_quotas/seats/components/subscription_seats.vue';
import { CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT } from 'ee/usage_quotas/seats/constants'; import { CANNOT_REMOVE_BILLABLE_MEMBER_MODAL_CONTENT } from 'ee/usage_quotas/seats/constants';
import { mockDataSeats, mockTableItems } from 'ee_jest/usage_quotas/seats/mock_data'; import { mockDataSeats, mockTableItems } from 'ee_jest/usage_quotas/seats/mock_data';
...@@ -21,6 +23,7 @@ Vue.use(Vuex); ...@@ -21,6 +23,7 @@ Vue.use(Vuex);
const actionSpies = { const actionSpies = {
fetchBillableMembersList: jest.fn(), fetchBillableMembersList: jest.fn(),
fetchGitlabSubscription: jest.fn(),
resetBillableMembers: jest.fn(), resetBillableMembers: jest.fn(),
setBillableMemberToRemove: jest.fn(), setBillableMemberToRemove: jest.fn(),
setSearchQuery: jest.fn(), setSearchQuery: jest.fn(),
...@@ -70,10 +73,6 @@ describe('Subscription Seats', () => { ...@@ -70,10 +73,6 @@ describe('Subscription Seats', () => {
const findTable = () => wrapper.findComponent(GlTable); const findTable = () => wrapper.findComponent(GlTable);
const findPageHeading = () => wrapper.find('[data-testid="heading-info"]');
const findPageHeadingText = () => findPageHeading().find('[data-testid="heading-info-text"]');
const findPageHeadingBadge = () => findPageHeading().findComponent(GlBadge);
const findExportButton = () => wrapper.findByTestId('export-button'); const findExportButton = () => wrapper.findByTestId('export-button');
const findSearchBox = () => wrapper.findComponent(FilterSortContainerRoot); const findSearchBox = () => wrapper.findComponent(FilterSortContainerRoot);
...@@ -81,6 +80,8 @@ describe('Subscription Seats', () => { ...@@ -81,6 +80,8 @@ describe('Subscription Seats', () => {
const findAllRemoveUserItems = () => wrapper.findAllByTestId('remove-user'); const findAllRemoveUserItems = () => wrapper.findAllByTestId('remove-user');
const findErrorModal = () => wrapper.findComponent(GlModal); const findErrorModal = () => wrapper.findComponent(GlModal);
const findStatisticsCard = () => wrapper.findComponent(StatisticsCard);
const findStatisticsSeatsCard = () => wrapper.findComponent(StatisticsSeatsCard);
const serializeUser = (rowWrapper) => { const serializeUser = (rowWrapper) => {
const avatarLink = rowWrapper.findComponent(GlAvatarLink); const avatarLink = rowWrapper.findComponent(GlAvatarLink);
...@@ -142,13 +143,6 @@ describe('Subscription Seats', () => { ...@@ -142,13 +143,6 @@ describe('Subscription Seats', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('heading text', () => {
it('contains the group name and total seats number', () => {
expect(findPageHeadingText().text()).toMatch(providedFields.namespaceName);
expect(findPageHeadingBadge().text()).toMatch('300');
});
});
describe('export button', () => { describe('export button', () => {
it('has the correct href', () => { it('has the correct href', () => {
expect(findExportButton().attributes().href).toBe(providedFields.seatUsageExportPath); expect(findExportButton().attributes().href).toBe(providedFields.seatUsageExportPath);
...@@ -241,6 +235,52 @@ describe('Subscription Seats', () => { ...@@ -241,6 +235,52 @@ describe('Subscription Seats', () => {
}); });
}); });
describe('statistics cards', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: {
seatsInSubscription: 3,
seatsInUse: 2,
maxSeatsUsed: 3,
seatsOwed: 1,
},
});
});
it('calls the correct action on create', () => {
expect(actionSpies.fetchGitlabSubscription).toHaveBeenCalled();
});
it('renders <statistics-card> with the necessary props', () => {
const statisticsCard = findStatisticsCard();
expect(statisticsCard.exists()).toBe(true);
expect(statisticsCard.props()).toEqual(
expect.objectContaining({
description: 'Seats in use / Seats in subscription',
helpLink: '/help/subscription/gitlab_com/index#how-seat-usage-is-determined',
percentage: 67,
totalUnit: null,
totalValue: '3',
usageUnit: null,
usageValue: '2',
}),
);
});
it('renders <statistics-seats-card> with the necessary props', () => {
const statisticsSeatsCard = findStatisticsSeatsCard();
expect(statisticsSeatsCard.exists()).toBe(true);
expect(statisticsSeatsCard.props()).toEqual(
expect.objectContaining({
seatsOwed: 1,
seatsUsed: 3,
}),
);
});
});
describe('is loading', () => { describe('is loading', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ initialState: { isLoading: true } }); wrapper = createComponent({ initialState: { isLoading: true } });
......
...@@ -123,3 +123,24 @@ export const mockTableItems = [ ...@@ -123,3 +123,24 @@ export const mockTableItems = [
}, },
}, },
]; ];
export const mockUserSubscription = {
plan: {
code: null,
name: null,
trial: false,
auto_renew: null,
upgradable: false,
},
usage: {
seats_in_subscription: 10,
seats_in_use: 5,
max_seats_used: 2,
seats_owed: 3,
},
billing: {
subscription_start_date: '2022-03-08',
subscription_end_date: null,
trial_ends_on: null,
},
};
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import * as GroupsApi from 'ee/api/groups_api'; import * as GroupsApi from 'ee/api/groups_api';
import Api from 'ee/api';
import * as actions from 'ee/usage_quotas/seats/store/actions'; import * as actions from 'ee/usage_quotas/seats/store/actions';
import * as types from 'ee/usage_quotas/seats/store/mutation_types'; import * as types from 'ee/usage_quotas/seats/store/mutation_types';
import State from 'ee/usage_quotas/seats/store/state'; import State from 'ee/usage_quotas/seats/store/state';
import { mockDataSeats, mockMemberDetails } from 'ee_jest/usage_quotas/seats/mock_data'; import {
mockDataSeats,
mockMemberDetails,
mockUserSubscription,
} from 'ee_jest/usage_quotas/seats/mock_data';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import createFlash, { FLASH_TYPES } from '~/flash'; import { createAlert, VARIANT_SUCCESS } from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
...@@ -105,7 +110,91 @@ describe('seats actions', () => { ...@@ -105,7 +110,91 @@ describe('seats actions', () => {
expectedMutations: [{ type: types.RECEIVE_BILLABLE_MEMBERS_ERROR }], expectedMutations: [{ type: types.RECEIVE_BILLABLE_MEMBERS_ERROR }],
}); });
expect(createFlash).toHaveBeenCalled(); expect(createAlert).toHaveBeenCalled();
});
});
describe('fetchGitlabSubscription', () => {
beforeEach(() => {
gon.api_version = 'v4';
state.namespaceId = 1;
});
it('passes correct arguments to Api call', () => {
const spy = jest.spyOn(Api, 'userSubscription');
testAction({
action: actions.fetchGitlabSubscription,
state,
expectedMutations: expect.anything(),
expectedActions: expect.anything(),
});
expect(spy).toBeCalledWith(state.namespaceId);
});
describe('on success', () => {
beforeEach(() => {
mock
.onGet('/api/v4/namespaces/1/gitlab_subscription')
.replyOnce(httpStatusCodes.OK, mockUserSubscription);
});
it('should dispatch the request and success actions', () => {
testAction({
action: actions.fetchGitlabSubscription,
state,
expectedActions: [
{
type: 'receiveGitlabSubscriptionSuccess',
payload: mockUserSubscription,
},
],
expectedMutations: [{ type: types.REQUEST_GITLAB_SUBSCRIPTION }],
});
});
});
describe('on error', () => {
beforeEach(() => {
mock
.onGet('/api/v4/namespaces/1/gitlab_subscription')
.replyOnce(httpStatusCodes.NOT_FOUND, {});
});
it('should dispatch the request and error actions', () => {
testAction({
action: actions.fetchGitlabSubscription,
state,
expectedActions: [{ type: 'receiveGitlabSubscriptionError' }],
expectedMutations: [{ type: types.REQUEST_GITLAB_SUBSCRIPTION }],
});
});
});
});
describe('receiveGitlabSubscriptionSuccess', () => {
it('should commit the success mutation', () => {
testAction({
action: actions.receiveGitlabSubscriptionSuccess,
payload: mockDataSeats,
state,
expectedMutations: [
{ type: types.RECEIVE_GITLAB_SUBSCRIPTION_SUCCESS, payload: mockDataSeats },
],
});
});
});
describe('receiveGitlabSubscriptionError', () => {
it('should commit the error mutation', async () => {
await testAction({
action: actions.receiveGitlabSubscriptionError,
state,
expectedMutations: [{ type: types.RECEIVE_GITLAB_SUBSCRIPTION_ERROR }],
});
expect(createAlert).toHaveBeenCalled();
}); });
}); });
...@@ -187,9 +276,9 @@ describe('seats actions', () => { ...@@ -187,9 +276,9 @@ describe('seats actions', () => {
expectedMutations: [{ type: types.REMOVE_BILLABLE_MEMBER_SUCCESS }], expectedMutations: [{ type: types.REMOVE_BILLABLE_MEMBER_SUCCESS }],
}); });
expect(createFlash).toHaveBeenCalledWith({ expect(createAlert).toHaveBeenCalledWith({
message: 'User was successfully removed', message: 'User was successfully removed',
type: FLASH_TYPES.SUCCESS, variant: VARIANT_SUCCESS,
}); });
}); });
}); });
...@@ -202,8 +291,8 @@ describe('seats actions', () => { ...@@ -202,8 +291,8 @@ describe('seats actions', () => {
expectedMutations: [{ type: types.REMOVE_BILLABLE_MEMBER_ERROR }], expectedMutations: [{ type: types.REMOVE_BILLABLE_MEMBER_ERROR }],
}); });
expect(createFlash).toHaveBeenCalledWith({ expect(createAlert).toHaveBeenCalledWith({
message: 'An error occurred while removing a billable member', message: 'An error occurred while removing a billable member.',
}); });
}); });
}); });
...@@ -304,15 +393,15 @@ describe('seats actions', () => { ...@@ -304,15 +393,15 @@ describe('seats actions', () => {
}); });
}); });
it('calls createFlash', async () => { it('calls createAlert', async () => {
await testAction({ await testAction({
action: actions.fetchBillableMemberDetailsError, action: actions.fetchBillableMemberDetailsError,
state, state,
expectedMutations: [{ type: types.FETCH_BILLABLE_MEMBER_DETAILS_ERROR }], expectedMutations: [{ type: types.FETCH_BILLABLE_MEMBER_DETAILS_ERROR }],
}); });
expect(createFlash).toHaveBeenCalledWith({ expect(createAlert).toHaveBeenCalledWith({
message: 'An error occurred while getting a billable member details', message: 'An error occurred while getting a billable member details.',
}); });
}); });
}); });
......
import * as types from 'ee/usage_quotas/seats/store/mutation_types'; import * as types from 'ee/usage_quotas/seats/store/mutation_types';
import mutations from 'ee/usage_quotas/seats/store/mutations'; import mutations from 'ee/usage_quotas/seats/store/mutations';
import createState from 'ee/usage_quotas/seats/store/state'; import createState from 'ee/usage_quotas/seats/store/state';
import { mockDataSeats, mockMemberDetails } from 'ee_jest/usage_quotas/seats/mock_data'; import {
mockDataSeats,
mockMemberDetails,
mockUserSubscription,
} from 'ee_jest/usage_quotas/seats/mock_data';
describe('EE seats module mutations', () => { describe('EE seats module mutations', () => {
let state; let state;
...@@ -16,11 +20,11 @@ describe('EE seats module mutations', () => { ...@@ -16,11 +20,11 @@ describe('EE seats module mutations', () => {
}); });
it('sets isLoading to true', () => { it('sets isLoading to true', () => {
expect(state.isLoading).toBeTruthy(); expect(state.isLoading).toBe(true);
}); });
it('sets hasError to false', () => { it('sets hasError to false', () => {
expect(state.hasError).toBeFalsy(); expect(state.hasError).toBe(false);
}); });
}); });
...@@ -38,7 +42,7 @@ describe('EE seats module mutations', () => { ...@@ -38,7 +42,7 @@ describe('EE seats module mutations', () => {
}); });
it('sets isLoading to false', () => { it('sets isLoading to false', () => {
expect(state.isLoading).toBeFalsy(); expect(state.isLoading).toBe(false);
}); });
}); });
...@@ -48,11 +52,75 @@ describe('EE seats module mutations', () => { ...@@ -48,11 +52,75 @@ describe('EE seats module mutations', () => {
}); });
it('sets isLoading to false', () => { it('sets isLoading to false', () => {
expect(state.isLoading).toBeFalsy(); expect(state.isLoading).toBe(false);
}); });
it('sets hasError to true', () => { it('sets hasError to true', () => {
expect(state.hasError).toBeTruthy(); expect(state.hasError).toBe(true);
});
});
describe(types.REQUEST_GITLAB_SUBSCRIPTION, () => {
beforeEach(() => {
mutations[types.REQUEST_GITLAB_SUBSCRIPTION](state);
});
it('sets isLoading to true', () => {
expect(state.isLoading).toBe(true);
});
it('sets hasError to false', () => {
expect(state.hasError).toBe(false);
});
});
describe(types.RECEIVE_GITLAB_SUBSCRIPTION_SUCCESS, () => {
describe('when subscription data is passed', () => {
beforeEach(() => {
mutations[types.RECEIVE_GITLAB_SUBSCRIPTION_SUCCESS](state, mockUserSubscription);
});
it('sets state as expected', () => {
expect(state.seatsInSubscription).toBe(mockUserSubscription.usage.seats_in_subscription);
expect(state.seatsInUse).toBe(mockUserSubscription.usage.seats_in_use);
expect(state.maxSeatsUsed).toBe(mockUserSubscription.usage.max_seats_used);
expect(state.seatsOwed).toBe(mockUserSubscription.usage.seats_owed);
});
it('sets isLoading to false', () => {
expect(state.isLoading).toBe(false);
});
});
describe('when subscription data is not passed', () => {
beforeEach(() => {
mutations[types.RECEIVE_GITLAB_SUBSCRIPTION_SUCCESS](state, {});
});
it('sets state as expected', () => {
expect(state.seatsInSubscription).toBe(0);
expect(state.seatsInUse).toBe(0);
expect(state.maxSeatsUsed).toBe(0);
expect(state.seatsOwed).toBe(0);
});
it('sets isLoading to false', () => {
expect(state.isLoading).toBe(false);
});
});
});
describe(types.RECEIVE_GITLAB_SUBSCRIPTION_ERROR, () => {
beforeEach(() => {
mutations[types.RECEIVE_GITLAB_SUBSCRIPTION_ERROR](state);
});
it('sets isLoading to false', () => {
expect(state.isLoading).toBe(false);
});
it('sets hasError to true', () => {
expect(state.hasError).toBe(true);
}); });
}); });
...@@ -85,11 +153,11 @@ describe('EE seats module mutations', () => { ...@@ -85,11 +153,11 @@ describe('EE seats module mutations', () => {
expect(state.page).toBeNull(); expect(state.page).toBeNull();
expect(state.perPage).toBeNull(); expect(state.perPage).toBeNull();
expect(state.isLoading).toBeFalsy(); expect(state.isLoading).toBe(false);
}); });
it('sets isLoading to false', () => { it('sets isLoading to false', () => {
expect(state.isLoading).toBeFalsy(); expect(state.isLoading).toBe(false);
}); });
}); });
......
...@@ -5779,6 +5779,9 @@ msgstr "" ...@@ -5779,6 +5779,9 @@ msgstr ""
msgid "Billings|Reactivate trial" msgid "Billings|Reactivate trial"
msgstr "" msgstr ""
msgid "Billings|Seats in use / Seats in subscription"
msgstr ""
msgid "Billings|Shared runners cannot be enabled until a valid credit card is on file." msgid "Billings|Shared runners cannot be enabled until a valid credit card is on file."
msgstr "" msgstr ""
...@@ -5806,22 +5809,28 @@ msgstr "" ...@@ -5806,22 +5809,28 @@ msgstr ""
msgid "Billing|%{user} was successfully approved" msgid "Billing|%{user} was successfully approved"
msgstr "" msgstr ""
msgid "Billing|Add seats"
msgstr ""
msgid "Billing|An email address is only visible for users with public emails." msgid "Billing|An email address is only visible for users with public emails."
msgstr "" msgstr ""
msgid "Billing|An error occurred while approving %{user}" msgid "Billing|An error occurred while approving %{user}"
msgstr "" msgstr ""
msgid "Billing|An error occurred while getting a billable member details" msgid "Billing|An error occurred while getting a billable member details."
msgstr ""
msgid "Billing|An error occurred while loading GitLab subscription details."
msgstr "" 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|An error occurred while loading pending members list" msgid "Billing|An error occurred while loading pending members list"
msgstr "" msgstr ""
msgid "Billing|An error occurred while removing a billable member" msgid "Billing|An error occurred while removing a billable member."
msgstr "" msgstr ""
msgid "Billing|Awaiting member signup" msgid "Billing|Awaiting member signup"
...@@ -5839,9 +5848,6 @@ msgstr "" ...@@ -5839,9 +5848,6 @@ msgstr ""
msgid "Billing|Export list" msgid "Billing|Export list"
msgstr "" msgstr ""
msgid "Billing|Group"
msgstr ""
msgid "Billing|Group invite" msgid "Billing|Group invite"
msgstr "" msgstr ""
...@@ -5869,9 +5875,6 @@ msgstr "" ...@@ -5869,9 +5875,6 @@ msgstr ""
msgid "Billing|User was successfully removed" msgid "Billing|User was successfully removed"
msgstr "" msgstr ""
msgid "Billing|Users occupying seats in"
msgstr ""
msgid "Billing|View pending approvals" msgid "Billing|View pending approvals"
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