Commit 4050f448 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'astoicescu-seats-route' into 'master'

Add "Seats in use" view to its own page

See merge request gitlab-org/gitlab!45382
parents 28f73949 9d1ae0d4
<script>
import { mapActions, mapState } from 'vuex';
import SubscriptionTable from './subscription_table.vue';
import SubscriptionSeats from './subscription_seats.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'SubscriptionApp',
components: {
SubscriptionTable,
SubscriptionSeats,
},
mixins: [glFeatureFlagsMixin()],
props: {
planUpgradeHref: {
type: String,
required: false,
default: '',
},
namespaceId: {
type: String,
required: false,
default: '',
},
customerPortalUrl: {
type: String,
required: false,
default: '',
},
namespaceName: {
type: String,
required: true,
},
},
computed: {
...mapState('subscription', ['hasBillableGroupMembers']),
isFeatureFlagEnabled() {
return this.glFeatures?.apiBillableMemberList;
},
},
created() {
this.setNamespaceId(this.namespaceId);
if (this.isFeatureFlagEnabled) {
this.fetchHasBillableGroupMembers();
}
},
methods: {
...mapActions('subscription', ['setNamespaceId', 'fetchHasBillableGroupMembers']),
},
};
</script>
<template>
<div>
<subscription-table
:namespace-name="namespaceName"
:plan-upgrade-href="planUpgradeHref"
:customer-portal-url="customerPortalUrl"
/>
<subscription-seats
v-if="isFeatureFlagEnabled && hasBillableGroupMembers"
:namespace-name="namespaceName"
:namespace-id="namespaceId"
class="gl-mt-7"
/>
</div>
</template>
...@@ -14,23 +14,21 @@ export default { ...@@ -14,23 +14,21 @@ export default {
GlPagination, GlPagination,
GlLoadingIcon, GlLoadingIcon,
}, },
props: {
namespaceName: {
type: String,
required: true,
},
namespaceId: {
type: String,
required: true,
},
},
data() { data() {
return { return {
fields: ['user'], fields: ['user'],
}; };
}, },
computed: { computed: {
...mapState('seats', ['members', 'isLoading', 'page', 'perPage', 'total']), ...mapState([
'members',
'isLoading',
'page',
'perPage',
'total',
'namespaceId',
'namespaceName',
]),
items() { items() {
return this.members.map(({ name, username, avatar_url, web_url }) => { return this.members.map(({ name, username, avatar_url, web_url }) => {
const formattedUserName = `@${username}`; const formattedUserName = `@${username}`;
...@@ -63,11 +61,10 @@ export default { ...@@ -63,11 +61,10 @@ export default {
}, },
}, },
created() { created() {
this.setNamespaceId(this.namespaceId);
this.fetchBillableMembersList(1); this.fetchBillableMembersList(1);
}, },
methods: { methods: {
...mapActions('seats', ['setNamespaceId', 'fetchBillableMembersList']), ...mapActions(['fetchBillableMembersList']),
inputHandler(val) { inputHandler(val) {
this.fetchBillableMembersList(val); this.fetchBillableMembersList(val);
}, },
...@@ -77,7 +74,7 @@ export default { ...@@ -77,7 +74,7 @@ export default {
</script> </script>
<template> <template>
<div> <div class="gl-pt-4">
<h4 data-testid="heading">{{ headingText }}</h4> <h4 data-testid="heading">{{ headingText }}</h4>
<p>{{ subHeadingText }}</p> <p>{{ subHeadingText }}</p>
<gl-table <gl-table
......
import Vue from 'vue';
import Vuex from 'vuex';
import SubscriptionSeats from './components/subscription_seats.vue';
import initialStore from './store';
Vue.use(Vuex);
export default (containerId = 'js-seat-usage') => {
const containerEl = document.getElementById(containerId);
if (!containerEl) {
return false;
}
const { namespaceId, namespaceName } = containerEl.dataset;
return new Vue({
el: containerEl,
store: new Vuex.Store(initialStore({ namespaceId, namespaceName })),
render(createElement) {
return createElement(SubscriptionSeats);
},
});
};
...@@ -3,10 +3,6 @@ import * as types from './mutation_types'; ...@@ -3,10 +3,6 @@ import * as types from './mutation_types';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
export const setNamespaceId = ({ commit }, namespaceId) => {
commit(types.SET_NAMESPACE_ID, namespaceId);
};
export const fetchBillableMembersList = ({ dispatch, state }, page) => { export const fetchBillableMembersList = ({ dispatch, state }, page) => {
dispatch('requestBillableMembersList'); dispatch('requestBillableMembersList');
......
...@@ -2,9 +2,8 @@ import * as actions from './actions'; ...@@ -2,9 +2,8 @@ import * as actions from './actions';
import mutations from './mutations'; import mutations from './mutations';
import state from './state'; import state from './state';
export default { export default (initState = {}) => ({
namespaced: true,
actions, actions,
mutations, mutations,
state, state: state(initState),
}; });
export const SET_NAMESPACE_ID = 'SET_NAMESPACE_ID';
export const REQUEST_BILLABLE_MEMBERS = 'REQUEST_BILLABLE_MEMBERS'; 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';
import * as types from './mutation_types';
import { import {
HEADER_TOTAL_ENTRIES, HEADER_TOTAL_ENTRIES,
HEADER_PAGE_NUMBER, HEADER_PAGE_NUMBER,
HEADER_ITEMS_PER_PAGE, HEADER_ITEMS_PER_PAGE,
} from '../../../constants'; } from 'ee/billings/constants';
import * as types from './mutation_types';
export default { export default {
[types.SET_NAMESPACE_ID](state, payload) {
state.namespaceId = payload;
},
[types.REQUEST_BILLABLE_MEMBERS](state) { [types.REQUEST_BILLABLE_MEMBERS](state) {
state.isLoading = true; state.isLoading = true;
state.hasError = false; state.hasError = false;
......
export default () => ({ export default ({ namespaceId = null, namespaceName = null } = {}) => ({
isLoading: false, isLoading: false,
hasError: false, hasError: false,
namespaceId: null, namespaceId,
namespaceName,
members: [], members: [],
total: null, total: null,
page: null, page: null,
......
import Vue from 'vue';
import Vuex from 'vuex';
import subscription from './modules/subscription/index';
import seats from './modules/seats/index';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
modules: {
subscription,
seats,
},
});
<script>
import { mapActions } from 'vuex';
import SubscriptionTable from './subscription_table.vue';
export default {
name: 'SubscriptionApp',
components: {
SubscriptionTable,
},
inject: ['planUpgradeHref', 'namespaceId', 'customerPortalUrl', 'namespaceName'],
created() {
this.setNamespaceId(this.namespaceId);
},
methods: {
...mapActions(['setNamespaceId']),
},
};
</script>
<template>
<subscription-table
:namespace-name="namespaceName"
:plan-upgrade-href="planUpgradeHref"
:customer-portal-url="customerPortalUrl"
/>
</template>
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
import { escape } from 'lodash'; import { escape } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { TABLE_TYPE_DEFAULT, TABLE_TYPE_FREE, TABLE_TYPE_TRIAL } from 'ee/billings/constants';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import SubscriptionTableRow from './subscription_table_row.vue'; import SubscriptionTableRow from './subscription_table_row.vue';
import { TABLE_TYPE_DEFAULT, TABLE_TYPE_FREE, TABLE_TYPE_TRIAL } from '../constants';
export default { export default {
name: 'SubscriptionTable', name: 'SubscriptionTable',
...@@ -29,14 +29,8 @@ export default { ...@@ -29,14 +29,8 @@ export default {
}, },
}, },
computed: { computed: {
...mapState('subscription', [ ...mapState(['isLoadingSubscription', 'hasErrorSubscription', 'plan', 'tables', 'endpoint']),
'isLoadingSubscription', ...mapGetters(['isFreePlan']),
'hasErrorSubscription',
'plan',
'tables',
'endpoint',
]),
...mapGetters('subscription', ['isFreePlan']),
subscriptionHeader() { subscriptionHeader() {
const planName = this.isFreePlan ? s__('SubscriptionTable|Free') : escape(this.plan.name); const planName = this.isFreePlan ? s__('SubscriptionTable|Free') : escape(this.plan.name);
const suffix = !this.isFreePlan && this.plan.trial ? s__('SubscriptionTable|Trial') : ''; const suffix = !this.isFreePlan && this.plan.trial ? s__('SubscriptionTable|Trial') : '';
...@@ -83,7 +77,7 @@ export default { ...@@ -83,7 +77,7 @@ export default {
this.fetchSubscription(); this.fetchSubscription();
}, },
methods: { methods: {
...mapActions('subscription', ['fetchSubscription']), ...mapActions(['fetchSubscription']),
}, },
}; };
</script> </script>
......
<script> <script>
import { GlIcon } from '@gitlab/ui'; import { GlIcon, GlButton } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { dateInWords } from '~/lib/utils/datetime_utility'; import { dateInWords } from '~/lib/utils/datetime_utility';
import Popover from '~/vue_shared/components/help_popover.vue'; import Popover from '~/vue_shared/components/help_popover.vue';
export default { export default {
name: 'SubscriptionTableRow', name: 'SubscriptionTableRow',
components: { components: {
GlButton,
GlIcon, GlIcon,
Popover, Popover,
}, },
...@@ -24,7 +26,17 @@ export default { ...@@ -24,7 +26,17 @@ export default {
default: false, default: false,
}, },
}, },
inject: ['billableSeatsHref', 'apiBillableMemberListFeatureEnabled'],
computed: {
...mapState(['hasBillableGroupMembers']),
},
created() {
if (this.apiBillableMemberListFeatureEnabled) {
this.fetchHasBillableGroupMembers();
}
},
methods: { methods: {
...mapActions(['fetchHasBillableGroupMembers']),
getPopoverOptions(col) { getPopoverOptions(col) {
const defaults = { const defaults = {
placement: 'bottom', placement: 'bottom',
...@@ -46,13 +58,16 @@ export default { ...@@ -46,13 +58,16 @@ export default {
return typeof col.value !== 'undefined' && col.value !== null ? col.value : ' - '; return typeof col.value !== 'undefined' && col.value !== null ? col.value : ' - ';
}, },
isSeatsUsageButtonShown(col) {
return this.hasBillableGroupMembers && this.billableSeatsHref && col.id === 'seatsInUse';
},
}, },
}; };
</script> </script>
<template> <template>
<div class="grid-row d-flex flex-grow-1 flex-column flex-sm-column flex-md-column flex-lg-row"> <div class="grid-row d-flex flex-grow-1 flex-column flex-sm-column flex-md-column flex-lg-row">
<div class="grid-cell header-cell"> <div class="grid-cell header-cell" data-testid="header-cell">
<span class="icon-wrapper"> <span class="icon-wrapper">
<gl-icon v-if="header.icon" class="gl-mr-3" :name="header.icon" aria-hidden="true" /> <gl-icon v-if="header.icon" class="gl-mr-3" :name="header.icon" aria-hidden="true" />
{{ header.title }} {{ header.title }}
...@@ -62,13 +77,26 @@ export default { ...@@ -62,13 +77,26 @@ export default {
<div <div
:key="`subscription-col-${i}`" :key="`subscription-col-${i}`"
class="grid-cell" class="grid-cell"
data-testid="content-cell"
:class="[col.hideContent ? 'no-value' : '']" :class="[col.hideContent ? 'no-value' : '']"
> >
<span class="property-label"> {{ col.label }} </span> <span data-testid="property-label" class="property-label"> {{ col.label }} </span>
<popover v-if="col.popover" :options="getPopoverOptions(col)" /> <popover v-if="col.popover" :options="getPopoverOptions(col)" />
<p class="property-value gl-mt-2 gl-mb-0" :class="[col.colClass ? col.colClass : '']"> <p
data-testid="property-value"
class="property-value gl-mt-2 gl-mb-0"
:class="[col.colClass ? col.colClass : '']"
>
{{ getDisplayValue(col) }} {{ getDisplayValue(col) }}
</p> </p>
<gl-button
v-if="isSeatsUsageButtonShown(col)"
:href="billableSeatsHref"
data-testid="seats-usage-button"
size="small"
class="gl-mt-3"
>{{ s__('SubscriptionTable|See usage') }}</gl-button
>
</div> </div>
</template> </template>
</div> </div>
......
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex';
import SubscriptionApp from './components/app.vue'; import SubscriptionApp from './components/app.vue';
import store from './stores'; import initialStore from './store';
Vue.use(Vuex);
export default (containerId = 'js-billing-plans') => { export default (containerId = 'js-billing-plans') => {
const containerEl = document.getElementById(containerId); const containerEl = document.getElementById(containerId);
...@@ -9,32 +12,27 @@ export default (containerId = 'js-billing-plans') => { ...@@ -9,32 +12,27 @@ export default (containerId = 'js-billing-plans') => {
return false; return false;
} }
const {
namespaceId,
namespaceName,
planUpgradeHref,
customerPortalUrl,
billableSeatsHref,
} = containerEl.dataset;
return new Vue({ return new Vue({
el: containerEl, el: containerEl,
store, store: new Vuex.Store(initialStore()),
components: { provide: {
SubscriptionApp, namespaceId,
}, namespaceName,
data() { planUpgradeHref,
const { dataset } = this.$options.el; customerPortalUrl,
const { namespaceId, namespaceName, planUpgradeHref, customerPortalUrl } = dataset; billableSeatsHref,
apiBillableMemberListFeatureEnabled: gon?.features?.apiBillableMemberList || false,
return {
namespaceId,
namespaceName,
planUpgradeHref,
customerPortalUrl,
};
}, },
render(createElement) { render(createElement) {
return createElement('subscription-app', { return createElement(SubscriptionApp);
props: {
namespaceId: this.namespaceId,
namespaceName: this.namespaceName,
planUpgradeHref: this.planUpgradeHref,
customerPortalUrl: this.customerPortalUrl,
},
});
}, },
}); });
}; };
...@@ -3,10 +3,9 @@ import * as getters from './getters'; ...@@ -3,10 +3,9 @@ import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
import state from './state'; import state from './state';
export default { export default () => ({
namespaced: true,
actions, actions,
mutations, mutations,
getters, getters,
state, state,
}; });
import initSubscriptions from 'ee/billings'; import initSubscriptions from 'ee/billings/subscriptions';
import PersistentUserCallout from '~/persistent_user_callout'; import PersistentUserCallout from '~/persistent_user_callout';
PersistentUserCallout.factory(document.querySelector('.js-gold-trial-callout')); PersistentUserCallout.factory(document.querySelector('.js-gold-trial-callout'));
......
import initSeatUsage from 'ee/billings/seat_usage';
initSeatUsage();
import initSubscriptions from 'ee/billings'; import initSubscriptions from 'ee/billings/subscriptions';
import PersistentUserCallout from '~/persistent_user_callout'; import PersistentUserCallout from '~/persistent_user_callout';
PersistentUserCallout.factory(document.querySelector('.js-gold-trial-callout')); PersistentUserCallout.factory(document.querySelector('.js-gold-trial-callout'));
......
# frozen_string_literal: true
class Groups::SeatUsageController < Groups::ApplicationController
before_action :authorize_admin_group!
before_action :verify_namespace_plan_check_enabled
layout "group_settings"
feature_category :purchase
def show
render_404 unless Feature.enabled?(:api_billable_member_list, @group)
end
end
...@@ -16,7 +16,8 @@ module BillingPlansHelper ...@@ -16,7 +16,8 @@ module BillingPlansHelper
namespace_id: group.id, namespace_id: group.id,
namespace_name: group.name, namespace_name: group.name,
plan_upgrade_href: plan_upgrade_url(group, plan), plan_upgrade_href: plan_upgrade_url(group, plan),
customer_portal_url: "#{EE::SUBSCRIPTIONS_URL}/subscriptions" customer_portal_url: "#{EE::SUBSCRIPTIONS_URL}/subscriptions",
billable_seats_href: billable_seats_href(group)
} }
end end
...@@ -93,4 +94,10 @@ module BillingPlansHelper ...@@ -93,4 +94,10 @@ module BillingPlansHelper
"#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/upgrade/#{plan.id}" "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/upgrade/#{plan.id}"
end end
def billable_seats_href(group)
return unless Feature.enabled?(:api_billable_member_list, group)
group_seat_usage_path(group)
end
end end
- page_title s_('SeatUsage|Seat usage')
- add_to_breadcrumbs _('Billing'), group_billings_path(@group)
#js-seat-usage{ data: { namespace_id: @group.id, namespace_name: @group.name } }
...@@ -92,6 +92,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -92,6 +92,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end end
resources :billings, only: [:index] resources :billings, only: [:index]
get :seat_usage, to: 'seat_usage#show'
resources :epics, concerns: :awardable, constraints: { id: /\d+/ } do resources :epics, concerns: :awardable, constraints: { id: /\d+/ } do
member do member do
get '/descriptions/:version_id/diff', action: :description_diff, as: :description_diff get '/descriptions/:version_id/diff', action: :description_diff, as: :description_diff
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Groups::SeatUsageController do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
describe 'GET show' do
before do
sign_in(user)
stub_application_setting(check_namespace_plan: true)
end
def get_show
get :show, params: { group_id: group }
end
subject { response }
context 'when authorized' do
before do
group.add_owner(user)
end
it 'renders show with 200 status code' do
get_show
is_expected.to have_gitlab_http_status(:ok)
is_expected.to render_template(:show)
end
end
context 'when unauthorized' do
before do
group.add_developer(user)
end
it 'renders 404 when user is not an owner' do
get_show
is_expected.to have_gitlab_http_status(:not_found)
end
end
end
end
import Vue from 'vue';
import component from 'ee/billings/components/subscription_table_row.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { dateInWords } from '~/lib/utils/datetime_utility';
describe('Subscription Table Row', () => {
let vm;
let props;
const Component = Vue.extend(component);
const header = {
icon: 'monitor',
title: 'Test title',
};
const columns = [
{
id: 'a',
label: 'Column A',
value: 100,
colClass: 'number',
},
{
id: 'b',
label: 'Column B',
value: 200,
popover: {
content: 'This is a tooltip',
},
},
];
afterEach(() => {
vm.$destroy();
});
describe('when loaded', () => {
beforeEach(() => {
props = { header, columns };
vm = mountComponent(Component, props);
});
it(`should render one header cell and ${columns.length} visible columns in total`, () => {
expect(vm.$el.querySelectorAll('.grid-cell')).toHaveLength(columns.length + 1);
});
it(`should not render a hidden column`, () => {
const hiddenColIdx = columns.find(c => !c.display);
const hiddenCol = vm.$el.querySelectorAll('.grid-cell:not(.header-cell)')[hiddenColIdx];
expect(hiddenCol).toBe(undefined);
});
it('should render a title in the header cell', () => {
expect(vm.$el.querySelector('.header-cell').textContent).toContain(props.header.title);
});
it('should render an icon in the header cell', () => {
expect(vm.$el.querySelector(`.header-cell [data-testid="${header.icon}-icon"]`)).not.toBe(
null,
);
});
columns.forEach((col, idx) => {
it(`should render label and value in column ${col.label}`, () => {
const currentCol = vm.$el.querySelectorAll('.grid-cell:not(.header-cell)')[idx];
expect(currentCol.querySelector('.property-label').textContent).toContain(col.label);
expect(currentCol.querySelector('.property-value').textContent).toContain(col.value);
});
});
it('should append the "number" css class to property value in "Column A"', () => {
const currentCol = vm.$el.querySelectorAll('.grid-cell:not(.header-cell)')[0];
expect(currentCol.querySelector('.property-value').classList.contains('number')).toBe(true);
});
it('should render an info icon in "Column B"', () => {
const currentCol = vm.$el.querySelectorAll('.grid-cell:not(.header-cell)')[1];
expect(currentCol.querySelector('.btn-help')).not.toBe(null);
});
describe('date column', () => {
const dateColumn = {
id: 'c',
label: 'Column C',
value: '2018-01-31',
isDate: true,
};
beforeEach(() => {
props = { header, columns: [dateColumn] };
vm = mountComponent(Component, props);
});
it('should render the date in UTC', () => {
const currentCol = vm.$el.querySelectorAll('.grid-cell:not(.header-cell)')[0];
const d = dateColumn.value.split('-');
const outputDate = dateInWords(new Date(d[0], d[1] - 1, d[2]));
expect(currentCol.querySelector('.property-label').textContent).toContain(dateColumn.label);
expect(currentCol.querySelector('.property-value').textContent).toContain(outputDate);
});
});
});
});
import subscriptionState from 'ee/billings/stores/modules/subscription/state'; import subscriptionState from 'ee/billings/subscriptions/store/state';
export const resetStore = store => { export const resetStore = store => {
const newState = { const newState = {
......
import { GlPagination } from '@gitlab/ui'; import { GlPagination } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import SubscriptionSeats from 'ee/billings/components/subscription_seats.vue'; import SubscriptionSeats from 'ee/billings/seat_usage/components/subscription_seats.vue';
import { mockDataSeats, seatsTableItems } from '../mock_data'; import { mockDataSeats, seatsTableItems } from 'ee_jest/billings/mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -12,32 +12,26 @@ const actionSpies = { ...@@ -12,32 +12,26 @@ const actionSpies = {
fetchBillableMembersList: jest.fn(), fetchBillableMembersList: jest.fn(),
}; };
const tableProps = { const providedFields = {
namespaceName: 'Test Group Name', namespaceName: 'Test Group Name',
namespaceId: '1000', namespaceId: '1000',
}; };
const fakeStore = ({ initialState }) => const fakeStore = ({ initialState }) =>
new Vuex.Store({ new Vuex.Store({
modules: { actions: actionSpies,
seats: { state: {
namespaced: true, isLoading: false,
actions: actionSpies, hasError: false,
state: { ...providedFields,
isLoading: false, ...initialState,
hasError: false,
...initialState,
},
},
}, },
}); });
const createComponent = ({ props = {}, options = {}, initialState = {} } = {}) => { const createComponent = (initialState = {}) => {
return shallowMount(SubscriptionSeats, { return shallowMount(SubscriptionSeats, {
propsData: { ...tableProps, ...props },
store: fakeStore({ initialState }), store: fakeStore({ initialState }),
localVue, localVue,
...options,
stubs: { stubs: {
GlTable: { template: '<div></div>', props: { items: Array, fields: Array, busy: Boolean } }, GlTable: { template: '<div></div>', props: { items: Array, fields: Array, busy: Boolean } },
}, },
...@@ -53,27 +47,21 @@ describe('Subscription Seats', () => { ...@@ -53,27 +47,21 @@ describe('Subscription Seats', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
initialState: { namespaceId: null,
namespaceId: null, members: [...mockDataSeats.data],
members: [...mockDataSeats.data], total: 300,
total: 300, page: 1,
page: 1, perPage: 5,
perPage: 5,
},
}); });
}); });
it('correct actions are called on create', () => { it('correct actions are called on create', () => {
expect(actionSpies.setNamespaceId).toHaveBeenCalledWith(
expect.any(Object),
tableProps.namespaceId,
);
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledWith(expect.any(Object), 1); expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledWith(expect.any(Object), 1);
}); });
describe('heading text', () => { describe('heading text', () => {
it('contains the group name and total seats number', () => { it('contains the group name and total seats number', () => {
expect(findHeading().text()).toMatch(tableProps.namespaceName); expect(findHeading().text()).toMatch(providedFields.namespaceName);
expect(findHeading().text()).toMatch('300'); expect(findHeading().text()).toMatch('300');
}); });
}); });
...@@ -97,13 +85,11 @@ describe('Subscription Seats', () => { ...@@ -97,13 +85,11 @@ describe('Subscription Seats', () => {
'will not render given %s for currentPage', 'will not render given %s for currentPage',
value => { value => {
wrapper = createComponent({ wrapper = createComponent({
initialState: { namespaceId: null,
namespaceId: null, members: [...mockDataSeats.data],
members: [...mockDataSeats.data], total: 300,
total: 300, page: value,
page: value, perPage: 5,
perPage: 5,
},
}); });
expect(findPagination().exists()).toBe(false); expect(findPagination().exists()).toBe(false);
}, },
......
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import * as actions from 'ee/billings/stores/modules/seats/actions';
import * as types from 'ee/billings/stores/modules/seats/mutation_types';
import state from 'ee/billings/stores/modules/seats/state';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash'; import state from 'ee/billings/seat_usage/store/state';
import * as types from 'ee/billings/seat_usage/store/mutation_types';
import * as actions from 'ee/billings/seat_usage/store/actions';
import { mockDataSeats } from 'ee_jest/billings/mock_data';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { mockDataSeats } from '../../../mock_data'; import createFlash from '~/flash';
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -23,25 +23,6 @@ describe('seats actions', () => { ...@@ -23,25 +23,6 @@ describe('seats actions', () => {
createFlash.mockClear(); createFlash.mockClear();
}); });
describe('setNamespaceId', () => {
it('should commit the correct mutuation', () => {
const namespaceId = 1;
testAction(
actions.setNamespaceId,
namespaceId,
mockedState,
[
{
type: types.SET_NAMESPACE_ID,
payload: namespaceId,
},
],
[],
);
});
});
describe('fetchBillableMembersList', () => { describe('fetchBillableMembersList', () => {
beforeEach(() => { beforeEach(() => {
gon.api_version = 'v4'; gon.api_version = 'v4';
......
import * as types from 'ee/billings/stores/modules/seats/mutation_types'; import createState from 'ee/billings/seat_usage/store/state';
import mutations from 'ee/billings/stores/modules/seats/mutations'; import * as types from 'ee/billings/seat_usage/store/mutation_types';
import createState from 'ee/billings/stores/modules/seats/state'; import mutations from 'ee/billings/seat_usage/store/mutations';
import { mockDataSeats } from '../../../mock_data'; import { mockDataSeats } from 'ee_jest/billings/mock_data';
describe('EE billings seats module mutations', () => { describe('EE billings seats module mutations', () => {
let state; let state;
...@@ -10,18 +10,6 @@ describe('EE billings seats module mutations', () => { ...@@ -10,18 +10,6 @@ describe('EE billings seats module mutations', () => {
state = createState(); state = createState();
}); });
describe(types.SET_NAMESPACE_ID, () => {
it('sets namespaceId', () => {
const expectedNamespaceId = 'test';
expect(state.namespaceId).toBeNull();
mutations[types.SET_NAMESPACE_ID](state, expectedNamespaceId);
expect(state.namespaceId).toEqual(expectedNamespaceId);
});
});
describe(types.REQUEST_BILLABLE_MEMBERS, () => { describe(types.REQUEST_BILLABLE_MEMBERS, () => {
beforeEach(() => { beforeEach(() => {
mutations[types.REQUEST_BILLABLE_MEMBERS](state); mutations[types.REQUEST_BILLABLE_MEMBERS](state);
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import SubscriptionApp from 'ee/billings/components/app.vue'; import Vuex from 'vuex';
import SubscriptionSeats from 'ee/billings/components/subscription_seats.vue'; import initialStore from 'ee/billings/subscriptions/store';
import SubscriptionTable from 'ee/billings/components/subscription_table.vue'; import SubscriptionApp from 'ee/billings/subscriptions/components/app.vue';
import createStore from 'ee/billings/stores'; import SubscriptionTable from 'ee/billings/subscriptions/components/subscription_table.vue';
import * as types from 'ee/billings/stores/modules/subscription/mutation_types'; import * as types from 'ee/billings/subscriptions/store/mutation_types';
import { mockDataSeats } from '../mock_data'; import { mockDataSeats } from 'ee_jest/billings/mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('SubscriptionApp component', () => { describe('SubscriptionApp component', () => {
let store; let store;
let wrapper; let wrapper;
const appProps = { const providedFields = {
namespaceId: '42', namespaceId: '42',
namespaceName: 'bronze', namespaceName: 'bronze',
planUpgradeHref: '/url', planUpgradeHref: '/url',
customerPortalUrl: 'https://customers.gitlab.com/subscriptions', customerPortalUrl: 'https://customers.gitlab.com/subscriptions',
}; };
const factory = (props = appProps, isFeatureEnabledApiBillableMemberList = true) => { const factory = () => {
store = createStore(); store = new Vuex.Store(initialStore());
jest.spyOn(store, 'dispatch').mockImplementation(); jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = shallowMount(SubscriptionApp, { wrapper = shallowMount(SubscriptionApp, {
store, store,
propsData: { ...props },
provide: { provide: {
glFeatures: { apiBillableMemberList: isFeatureEnabledApiBillableMemberList }, ...providedFields,
}, },
localVue,
}); });
}; };
...@@ -37,8 +40,6 @@ describe('SubscriptionApp component', () => { ...@@ -37,8 +40,6 @@ describe('SubscriptionApp component', () => {
expect(componentWrapper.props()).toEqual(expect.objectContaining(props)); expect(componentWrapper.props()).toEqual(expect.objectContaining(props));
}; };
const findSubscriptionSeatsTable = () => wrapper.find(SubscriptionSeats);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
...@@ -46,60 +47,19 @@ describe('SubscriptionApp component', () => { ...@@ -46,60 +47,19 @@ describe('SubscriptionApp component', () => {
describe('on creation', () => { describe('on creation', () => {
beforeEach(() => { beforeEach(() => {
factory(); factory();
store.commit(`subscription/${types.RECEIVE_HAS_BILLABLE_MEMBERS_SUCCESS}`, mockDataSeats); store.commit(`${types.RECEIVE_HAS_BILLABLE_MEMBERS_SUCCESS}`, mockDataSeats);
}); });
it('dispatches expected actions on created', () => { it('dispatches expected actions on created', () => {
expect(store.dispatch.mock.calls).toEqual([ expect(store.dispatch.mock.calls).toEqual([['setNamespaceId', '42']]);
['subscription/setNamespaceId', '42'],
['subscription/fetchHasBillableGroupMembers', undefined],
]);
}); });
it('passes the correct props to the subscriptions table', () => { it('passes the correct props to the subscriptions table', () => {
expectComponentWithProps(SubscriptionTable, { expectComponentWithProps(SubscriptionTable, {
namespaceName: appProps.namespaceName, namespaceName: providedFields.namespaceName,
planUpgradeHref: appProps.planUpgradeHref, planUpgradeHref: providedFields.planUpgradeHref,
customerPortalUrl: appProps.customerPortalUrl, customerPortalUrl: providedFields.customerPortalUrl,
});
});
it('passes the correct props to the subscriptions seats component', () => {
expectComponentWithProps(SubscriptionSeats, {
namespaceName: appProps.namespaceName,
namespaceId: appProps.namespaceId,
});
});
});
describe('when there are no billable members', () => {
beforeEach(() => {
factory();
store.commit(`subscription/${types.RECEIVE_HAS_BILLABLE_MEMBERS_SUCCESS}`, {
data: [],
headers: {},
}); });
}); });
it('does not render the subscription seats table', () => {
expect(findSubscriptionSeatsTable().exists()).toBe(false);
});
});
describe('when feature flag is disabled', () => {
beforeEach(() => {
factory(appProps, false);
});
it('does not dispatch fetchBillableGroupMembers action on created', () => {
expect(store.dispatch.mock.calls).not.toContainEqual([
'subscription/fetchBillableGroupMembers',
undefined,
]);
});
it('does not render the subscription seats table', () => {
expect(findSubscriptionSeatsTable().exists()).toBe(false);
});
}); });
}); });
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlIcon } from '@gitlab/ui';
import SubscriptionTableRow from 'ee/billings/subscriptions/components/subscription_table_row.vue';
import initialStore from 'ee/billings/subscriptions/store';
import Popover from '~/vue_shared/components/help_popover.vue';
import { dateInWords } from '~/lib/utils/datetime_utility';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('subscription table row', () => {
let store;
let wrapper;
const HEADER = {
icon: 'monitor',
title: 'Test title',
};
const COLUMNS = [
{
id: 'a',
label: 'Column A',
value: 100,
colClass: 'number',
},
{
id: 'b',
label: 'Column B',
value: 200,
popover: {
content: 'This is a tooltip',
},
},
];
const BILLABLE_SEATS_URL = 'http://billable/seats';
const defaultProps = { header: HEADER, columns: COLUMNS };
const createComponent = ({
props = {},
apiBillableMemberListFeatureEnabled = true,
billableSeatsHref = BILLABLE_SEATS_URL,
} = {}) => {
if (wrapper) {
throw new Error('wrapper already exists!');
}
wrapper = shallowMount(SubscriptionTableRow, {
propsData: {
...defaultProps,
...props,
},
provide: {
apiBillableMemberListFeatureEnabled,
billableSeatsHref,
},
store,
localVue,
});
};
beforeEach(() => {
store = new Vuex.Store(initialStore());
jest.spyOn(store, 'dispatch').mockImplementation();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findHeaderCell = () => wrapper.find('[data-testid="header-cell"]');
const findContentCells = () => wrapper.findAll('[data-testid="content-cell"]');
const findHeaderIcon = () => findHeaderCell().find(GlIcon);
const findColumnLabelAndTitle = columnWrapper => {
const label = columnWrapper.find('[data-testid="property-label"]');
const value = columnWrapper.find('[data-testid="property-value"]');
return expect.objectContaining({
label: label.text(),
value: Number(value.text()),
});
};
const findUsageButton = () =>
findContentCells()
.at(0)
.find('[data-testid="seats-usage-button"]');
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('dispatches correct actions when created', () => {
expect(store.dispatch).toHaveBeenCalledWith('fetchHasBillableGroupMembers');
});
it(`should render one header cell and ${COLUMNS.length} visible columns in total`, () => {
expect(findHeaderCell().isVisible()).toBe(true);
expect(findContentCells()).toHaveLength(COLUMNS.length);
});
it(`should not render a hidden column`, () => {
const hiddenColIdx = COLUMNS.find(c => !c.display);
const hiddenCol = findContentCells().at(hiddenColIdx);
expect(hiddenCol).toBe(undefined);
});
it('should render a title in the header cell', () => {
expect(findHeaderCell().text()).toMatch(HEADER.title);
});
it(`should render a ${HEADER.icon} icon in the header cell`, () => {
expect(findHeaderIcon().exists()).toBe(true);
expect(findHeaderIcon().props('name')).toBe(HEADER.icon);
});
it('renders correct column structure', () => {
const columnsStructure = findContentCells().wrappers.map(findColumnLabelAndTitle);
expect(columnsStructure).toEqual(expect.arrayContaining(COLUMNS));
});
it('should append the "number" css class to property value in "Column A"', () => {
const currentCol = findContentCells().at(0);
expect(
currentCol.find('[data-testid="property-value"]').element.classList.contains('number'),
).toBe(true);
});
it('should render an info icon in "Column B"', () => {
const currentCol = findContentCells().at(1);
expect(currentCol.find(Popover).exists()).toBe(true);
});
});
describe('date column', () => {
const dateColumn = {
id: 'c',
label: 'Column C',
value: '2018-01-31',
isDate: true,
};
beforeEach(() => {
createComponent({ props: { columns: [dateColumn] } });
});
it('should render the date in UTC', () => {
const currentCol = findContentCells().at(0);
const d = dateColumn.value.split('-');
const outputDate = dateInWords(new Date(d[0], d[1] - 1, d[2]));
expect(currentCol.find('[data-testid="property-label"]').text()).toMatch(dateColumn.label);
expect(currentCol.find('[data-testid="property-value"]').text()).toMatch(outputDate);
});
});
it.each`
state | columnId | provide | exists | attrs
${{ hasBillableGroupMembers: true }} | ${'seatsInUse'} | ${{}} | ${true} | ${{ href: BILLABLE_SEATS_URL }}
${{ hasBillableGroupMembers: true }} | ${'seatsInUse'} | ${{ billableSeatsHref: '' }} | ${false} | ${{}}
${{ hasBillableGroupMembers: true }} | ${'some_value'} | ${{}} | ${false} | ${{}}
${{ hasBillableGroupMembers: false }} | ${'seatsInUse'} | ${{}} | ${false} | ${{}}
`(
'should exists=$exists with (state=$state, columnId=$columnId, provide=$provide)',
({ state, columnId, provide, exists, attrs }) => {
Object.assign(store.state, state);
createComponent({ props: { columns: [{ id: columnId }] }, ...provide });
expect(findUsageButton().exists()).toBe(exists);
if (exists) {
expect(findUsageButton().attributes()).toMatchObject(attrs);
}
},
);
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SubscriptionTable from 'ee/billings/components/subscription_table.vue';
import SubscriptionTableRow from 'ee/billings/components/subscription_table_row.vue';
import createStore from 'ee/billings/stores';
import * as types from 'ee/billings/stores/modules/subscription/mutation_types';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { mockDataSubscription } from '../mock_data'; import initialStore from 'ee/billings/subscriptions/store';
import * as types from 'ee/billings/subscriptions/store/mutation_types';
import SubscriptionTable from 'ee/billings/subscriptions/components/subscription_table.vue';
import SubscriptionTableRow from 'ee/billings/subscriptions/components/subscription_table_row.vue';
import { mockDataSubscription } from 'ee_jest/billings/mock_data';
const TEST_NAMESPACE_NAME = 'GitLab.com'; const TEST_NAMESPACE_NAME = 'GitLab.com';
const CUSTOMER_PORTAL_URL = 'https://customers.gitlab.com/subscriptions'; const CUSTOMER_PORTAL_URL = 'https://customers.gitlab.com/subscriptions';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('SubscriptionTable component', () => { describe('SubscriptionTable component', () => {
let store; let store;
let wrapper; let wrapper;
...@@ -18,12 +22,13 @@ describe('SubscriptionTable component', () => { ...@@ -18,12 +22,13 @@ describe('SubscriptionTable component', () => {
wrapper.findAll('a').wrappers.map(x => ({ text: x.text(), href: x.attributes('href') })); wrapper.findAll('a').wrappers.map(x => ({ text: x.text(), href: x.attributes('href') }));
const factory = (options = {}) => { const factory = (options = {}) => {
store = createStore(); store = new Vuex.Store(initialStore());
jest.spyOn(store, 'dispatch').mockImplementation(); jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = shallowMount(SubscriptionTable, { wrapper = shallowMount(SubscriptionTable, {
...options, ...options,
store, store,
localVue,
}); });
}; };
...@@ -41,7 +46,7 @@ describe('SubscriptionTable component', () => { ...@@ -41,7 +46,7 @@ describe('SubscriptionTable component', () => {
}, },
}); });
Object.assign(store.state.subscription, { isLoadingSubscription: true }); Object.assign(store.state, { isLoadingSubscription: true });
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}); });
...@@ -51,7 +56,7 @@ describe('SubscriptionTable component', () => { ...@@ -51,7 +56,7 @@ describe('SubscriptionTable component', () => {
}); });
it('dispatches the correct actions', () => { it('dispatches the correct actions', () => {
expect(store.dispatch).toHaveBeenCalledWith('subscription/fetchSubscription', undefined); expect(store.dispatch).toHaveBeenCalledWith('fetchSubscription');
}); });
it('matches the snapshot', () => { it('matches the snapshot', () => {
...@@ -63,8 +68,8 @@ describe('SubscriptionTable component', () => { ...@@ -63,8 +68,8 @@ describe('SubscriptionTable component', () => {
beforeEach(() => { beforeEach(() => {
factory({ propsData: { namespaceName: TEST_NAMESPACE_NAME } }); factory({ propsData: { namespaceName: TEST_NAMESPACE_NAME } });
store.state.subscription.isLoadingSubscription = false; store.state.isLoadingSubscription = false;
store.commit(`subscription/${types.RECEIVE_SUBSCRIPTION_SUCCESS}`, mockDataSubscription.gold); store.commit(`${types.RECEIVE_SUBSCRIPTION_SUCCESS}`, mockDataSubscription.gold);
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}); });
...@@ -103,7 +108,7 @@ describe('SubscriptionTable component', () => { ...@@ -103,7 +108,7 @@ describe('SubscriptionTable component', () => {
}, },
}); });
Object.assign(store.state.subscription, { Object.assign(store.state, {
isLoadingSubscription: false, isLoadingSubscription: false,
isFreePlan, isFreePlan,
plan: { plan: {
......
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import * as actions from 'ee/billings/stores/modules/subscription/actions';
import * as types from 'ee/billings/stores/modules/subscription/mutation_types';
import state from 'ee/billings/stores/modules/subscription/state';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import state from 'ee/billings/subscriptions/store/state';
import * as types from 'ee/billings/subscriptions/store/mutation_types';
import * as actions from 'ee/billings/subscriptions/store/actions';
import { mockDataSubscription } from 'ee_jest/billings/mock_data';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { mockDataSubscription } from '../../../mock_data';
describe('subscription actions', () => { describe('subscription actions', () => {
let mockedState; let mockedState;
let mock; let mock;
......
import * as getters from 'ee/billings/stores/modules/subscription/getters'; import State from 'ee/billings/subscriptions/store/state';
import State from 'ee/billings/stores/modules/subscription/state'; import * as getters from 'ee/billings/subscriptions/store/getters';
describe('EE billings subscription module getters', () => { describe('EE billings subscription module getters', () => {
let state; let state;
......
import createState from 'ee/billings/subscriptions/store/state';
import * as types from 'ee/billings/subscriptions/store/mutation_types';
import mutations from 'ee/billings/subscriptions/store/mutations';
import { TABLE_TYPE_DEFAULT, TABLE_TYPE_FREE, TABLE_TYPE_TRIAL } from 'ee/billings/constants'; import { TABLE_TYPE_DEFAULT, TABLE_TYPE_FREE, TABLE_TYPE_TRIAL } from 'ee/billings/constants';
import * as types from 'ee/billings/stores/modules/subscription/mutation_types'; import { mockDataSubscription } from 'ee_jest/billings/mock_data';
import mutations from 'ee/billings/stores/modules/subscription/mutations';
import createState from 'ee/billings/stores/modules/subscription/state';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { mockDataSubscription } from '../../../mock_data';
describe('EE billings subscription module mutations', () => { describe('EE billings subscription module mutations', () => {
let state; let state;
......
...@@ -15,12 +15,14 @@ RSpec.describe BillingPlansHelper do ...@@ -15,12 +15,14 @@ RSpec.describe BillingPlansHelper do
it 'returns data attributes' do it 'returns data attributes' do
upgrade_href = upgrade_href =
"#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/upgrade/#{plan.id}" "#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/upgrade/#{plan.id}"
billable_seats_href = helper.group_seat_usage_path(group)
expect(helper.subscription_plan_data_attributes(group, plan)) expect(helper.subscription_plan_data_attributes(group, plan))
.to eq(namespace_id: group.id, .to eq(namespace_id: group.id,
namespace_name: group.name, namespace_name: group.name,
plan_upgrade_href: upgrade_href, plan_upgrade_href: upgrade_href,
customer_portal_url: customer_portal_url) customer_portal_url: customer_portal_url,
billable_seats_href: billable_seats_href)
end end
end end
...@@ -36,10 +38,13 @@ RSpec.describe BillingPlansHelper do ...@@ -36,10 +38,13 @@ RSpec.describe BillingPlansHelper do
let(:plan) { Hashie::Mash.new(id: nil) } let(:plan) { Hashie::Mash.new(id: nil) }
it 'returns data attributes without upgrade href' do it 'returns data attributes without upgrade href' do
billable_seats_href = helper.group_seat_usage_path(group)
expect(helper.subscription_plan_data_attributes(group, plan)) expect(helper.subscription_plan_data_attributes(group, plan))
.to eq(namespace_id: group.id, .to eq(namespace_id: group.id,
namespace_name: group.name, namespace_name: group.name,
customer_portal_url: customer_portal_url, customer_portal_url: customer_portal_url,
billable_seats_href: billable_seats_href,
plan_upgrade_href: nil) plan_upgrade_href: nil)
end end
end end
......
...@@ -23988,6 +23988,9 @@ msgstr "" ...@@ -23988,6 +23988,9 @@ msgstr ""
msgid "Seat Link is disabled, and cannot be configured through this form." msgid "Seat Link is disabled, and cannot be configured through this form."
msgstr "" msgstr ""
msgid "SeatUsage|Seat usage"
msgstr ""
msgid "Seats usage data as of %{last_enqueue_time} (Updated daily)" msgid "Seats usage data as of %{last_enqueue_time} (Updated daily)"
msgstr "" msgstr ""
...@@ -26259,6 +26262,9 @@ msgstr "" ...@@ -26259,6 +26262,9 @@ msgstr ""
msgid "SubscriptionTable|Seats owed" msgid "SubscriptionTable|Seats owed"
msgstr "" msgstr ""
msgid "SubscriptionTable|See usage"
msgstr ""
msgid "SubscriptionTable|Subscription end date" msgid "SubscriptionTable|Subscription end date"
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