Commit 9d1ae0d4 authored by Andrei Stoicescu's avatar Andrei Stoicescu Committed by Paul Slaughter

Add "Seats in use" table to its own page

Adds route and a controller
for Billing->Seat usage page.

Moves table to new page.
parent aeef33f8
<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 {
GlPagination,
GlLoadingIcon,
},
props: {
namespaceName: {
type: String,
required: true,
},
namespaceId: {
type: String,
required: true,
},
},
data() {
return {
fields: ['user'],
};
},
computed: {
...mapState('seats', ['members', 'isLoading', 'page', 'perPage', 'total']),
...mapState([
'members',
'isLoading',
'page',
'perPage',
'total',
'namespaceId',
'namespaceName',
]),
items() {
return this.members.map(({ name, username, avatar_url, web_url }) => {
const formattedUserName = `@${username}`;
......@@ -63,11 +61,10 @@ export default {
},
},
created() {
this.setNamespaceId(this.namespaceId);
this.fetchBillableMembersList(1);
},
methods: {
...mapActions('seats', ['setNamespaceId', 'fetchBillableMembersList']),
...mapActions(['fetchBillableMembersList']),
inputHandler(val) {
this.fetchBillableMembersList(val);
},
......@@ -77,7 +74,7 @@ export default {
</script>
<template>
<div>
<div class="gl-pt-4">
<h4 data-testid="heading">{{ headingText }}</h4>
<p>{{ subHeadingText }}</p>
<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';
import createFlash from '~/flash';
import { s__ } from '~/locale';
export const setNamespaceId = ({ commit }, namespaceId) => {
commit(types.SET_NAMESPACE_ID, namespaceId);
};
export const fetchBillableMembersList = ({ dispatch, state }, page) => {
dispatch('requestBillableMembersList');
......
......@@ -2,9 +2,8 @@ import * as actions from './actions';
import mutations from './mutations';
import state from './state';
export default {
namespaced: true,
export default (initState = {}) => ({
actions,
mutations,
state,
};
state: state(initState),
});
export const SET_NAMESPACE_ID = 'SET_NAMESPACE_ID';
export const REQUEST_BILLABLE_MEMBERS = 'REQUEST_BILLABLE_MEMBERS';
export const RECEIVE_BILLABLE_MEMBERS_SUCCESS = 'RECEIVE_BILLABLE_MEMBERS_SUCCESS';
export const RECEIVE_BILLABLE_MEMBERS_ERROR = 'RECEIVE_BILLABLE_MEMBERS_ERROR';
import * as types from './mutation_types';
import {
HEADER_TOTAL_ENTRIES,
HEADER_PAGE_NUMBER,
HEADER_ITEMS_PER_PAGE,
} from '../../../constants';
} from 'ee/billings/constants';
import * as types from './mutation_types';
export default {
[types.SET_NAMESPACE_ID](state, payload) {
state.namespaceId = payload;
},
[types.REQUEST_BILLABLE_MEMBERS](state) {
state.isLoading = true;
state.hasError = false;
......
export default () => ({
export default ({ namespaceId = null, namespaceName = null } = {}) => ({
isLoading: false,
hasError: false,
namespaceId: null,
namespaceId,
namespaceName,
members: [],
total: 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 @@
import { escape } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import { TABLE_TYPE_DEFAULT, TABLE_TYPE_FREE, TABLE_TYPE_TRIAL } from 'ee/billings/constants';
import { s__ } from '~/locale';
import SubscriptionTableRow from './subscription_table_row.vue';
import { TABLE_TYPE_DEFAULT, TABLE_TYPE_FREE, TABLE_TYPE_TRIAL } from '../constants';
export default {
name: 'SubscriptionTable',
......@@ -29,14 +29,8 @@ export default {
},
},
computed: {
...mapState('subscription', [
'isLoadingSubscription',
'hasErrorSubscription',
'plan',
'tables',
'endpoint',
]),
...mapGetters('subscription', ['isFreePlan']),
...mapState(['isLoadingSubscription', 'hasErrorSubscription', 'plan', 'tables', 'endpoint']),
...mapGetters(['isFreePlan']),
subscriptionHeader() {
const planName = this.isFreePlan ? s__('SubscriptionTable|Free') : escape(this.plan.name);
const suffix = !this.isFreePlan && this.plan.trial ? s__('SubscriptionTable|Trial') : '';
......@@ -83,7 +77,7 @@ export default {
this.fetchSubscription();
},
methods: {
...mapActions('subscription', ['fetchSubscription']),
...mapActions(['fetchSubscription']),
},
};
</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 Popover from '~/vue_shared/components/help_popover.vue';
export default {
name: 'SubscriptionTableRow',
components: {
GlButton,
GlIcon,
Popover,
},
......@@ -24,7 +26,17 @@ export default {
default: false,
},
},
inject: ['billableSeatsHref', 'apiBillableMemberListFeatureEnabled'],
computed: {
...mapState(['hasBillableGroupMembers']),
},
created() {
if (this.apiBillableMemberListFeatureEnabled) {
this.fetchHasBillableGroupMembers();
}
},
methods: {
...mapActions(['fetchHasBillableGroupMembers']),
getPopoverOptions(col) {
const defaults = {
placement: 'bottom',
......@@ -46,13 +58,16 @@ export default {
return typeof col.value !== 'undefined' && col.value !== null ? col.value : ' - ';
},
isSeatsUsageButtonShown(col) {
return this.hasBillableGroupMembers && this.billableSeatsHref && col.id === 'seatsInUse';
},
},
};
</script>
<template>
<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">
<gl-icon v-if="header.icon" class="gl-mr-3" :name="header.icon" aria-hidden="true" />
{{ header.title }}
......@@ -62,13 +77,26 @@ export default {
<div
:key="`subscription-col-${i}`"
class="grid-cell"
data-testid="content-cell"
: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)" />
<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) }}
</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>
</template>
</div>
......
import Vue from 'vue';
import Vuex from 'vuex';
import SubscriptionApp from './components/app.vue';
import store from './stores';
import initialStore from './store';
Vue.use(Vuex);
export default (containerId = 'js-billing-plans') => {
const containerEl = document.getElementById(containerId);
......@@ -9,32 +12,27 @@ export default (containerId = 'js-billing-plans') => {
return false;
}
const {
namespaceId,
namespaceName,
planUpgradeHref,
customerPortalUrl,
billableSeatsHref,
} = containerEl.dataset;
return new Vue({
el: containerEl,
store,
components: {
SubscriptionApp,
},
data() {
const { dataset } = this.$options.el;
const { namespaceId, namespaceName, planUpgradeHref, customerPortalUrl } = dataset;
return {
namespaceId,
namespaceName,
planUpgradeHref,
customerPortalUrl,
};
store: new Vuex.Store(initialStore()),
provide: {
namespaceId,
namespaceName,
planUpgradeHref,
customerPortalUrl,
billableSeatsHref,
apiBillableMemberListFeatureEnabled: gon?.features?.apiBillableMemberList || false,
},
render(createElement) {
return createElement('subscription-app', {
props: {
namespaceId: this.namespaceId,
namespaceName: this.namespaceName,
planUpgradeHref: this.planUpgradeHref,
customerPortalUrl: this.customerPortalUrl,
},
});
return createElement(SubscriptionApp);
},
});
};
......@@ -3,10 +3,9 @@ import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export default {
namespaced: true,
export default () => ({
actions,
mutations,
getters,
state,
};
});
import initSubscriptions from 'ee/billings';
import initSubscriptions from 'ee/billings/subscriptions';
import PersistentUserCallout from '~/persistent_user_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';
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
namespace_id: group.id,
namespace_name: group.name,
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
......@@ -93,4 +94,10 @@ module BillingPlansHelper
"#{EE::SUBSCRIPTIONS_URL}/gitlab/namespaces/#{group.id}/upgrade/#{plan.id}"
end
def billable_seats_href(group)
return unless Feature.enabled?(:api_billable_member_list, group)
group_seat_usage_path(group)
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
end
resources :billings, only: [:index]
get :seat_usage, to: 'seat_usage#show'
resources :epics, concerns: :awardable, constraints: { id: /\d+/ } do
member do
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 => {
const newState = {
......
import { GlPagination } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import SubscriptionSeats from 'ee/billings/components/subscription_seats.vue';
import { mockDataSeats, seatsTableItems } from '../mock_data';
import SubscriptionSeats from 'ee/billings/seat_usage/components/subscription_seats.vue';
import { mockDataSeats, seatsTableItems } from 'ee_jest/billings/mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -12,32 +12,26 @@ const actionSpies = {
fetchBillableMembersList: jest.fn(),
};
const tableProps = {
const providedFields = {
namespaceName: 'Test Group Name',
namespaceId: '1000',
};
const fakeStore = ({ initialState }) =>
new Vuex.Store({
modules: {
seats: {
namespaced: true,
actions: actionSpies,
state: {
isLoading: false,
hasError: false,
...initialState,
},
},
actions: actionSpies,
state: {
isLoading: false,
hasError: false,
...providedFields,
...initialState,
},
});
const createComponent = ({ props = {}, options = {}, initialState = {} } = {}) => {
const createComponent = (initialState = {}) => {
return shallowMount(SubscriptionSeats, {
propsData: { ...tableProps, ...props },
store: fakeStore({ initialState }),
localVue,
...options,
stubs: {
GlTable: { template: '<div></div>', props: { items: Array, fields: Array, busy: Boolean } },
},
......@@ -53,27 +47,21 @@ describe('Subscription Seats', () => {
beforeEach(() => {
wrapper = createComponent({
initialState: {
namespaceId: null,
members: [...mockDataSeats.data],
total: 300,
page: 1,
perPage: 5,
},
namespaceId: null,
members: [...mockDataSeats.data],
total: 300,
page: 1,
perPage: 5,
});
});
it('correct actions are called on create', () => {
expect(actionSpies.setNamespaceId).toHaveBeenCalledWith(
expect.any(Object),
tableProps.namespaceId,
);
expect(actionSpies.fetchBillableMembersList).toHaveBeenCalledWith(expect.any(Object), 1);
});
describe('heading text', () => {
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');
});
});
......@@ -97,13 +85,11 @@ describe('Subscription Seats', () => {
'will not render given %s for currentPage',
value => {
wrapper = createComponent({
initialState: {
namespaceId: null,
members: [...mockDataSeats.data],
total: 300,
page: value,
perPage: 5,
},
namespaceId: null,
members: [...mockDataSeats.data],
total: 300,
page: value,
perPage: 5,
});
expect(findPagination().exists()).toBe(false);
},
......
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 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 { mockDataSeats } from '../../../mock_data';
import createFlash from '~/flash';
jest.mock('~/flash');
......@@ -23,25 +23,6 @@ describe('seats actions', () => {
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', () => {
beforeEach(() => {
gon.api_version = 'v4';
......
import * as types from 'ee/billings/stores/modules/seats/mutation_types';
import mutations from 'ee/billings/stores/modules/seats/mutations';
import createState from 'ee/billings/stores/modules/seats/state';
import { mockDataSeats } from '../../../mock_data';
import createState from 'ee/billings/seat_usage/store/state';
import * as types from 'ee/billings/seat_usage/store/mutation_types';
import mutations from 'ee/billings/seat_usage/store/mutations';
import { mockDataSeats } from 'ee_jest/billings/mock_data';
describe('EE billings seats module mutations', () => {
let state;
......@@ -10,18 +10,6 @@ describe('EE billings seats module mutations', () => {
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, () => {
beforeEach(() => {
mutations[types.REQUEST_BILLABLE_MEMBERS](state);
......
import { shallowMount } from '@vue/test-utils';
import SubscriptionApp from 'ee/billings/components/app.vue';
import SubscriptionSeats from 'ee/billings/components/subscription_seats.vue';
import SubscriptionTable from 'ee/billings/components/subscription_table.vue';
import createStore from 'ee/billings/stores';
import * as types from 'ee/billings/stores/modules/subscription/mutation_types';
import { mockDataSeats } from '../mock_data';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import initialStore from 'ee/billings/subscriptions/store';
import SubscriptionApp from 'ee/billings/subscriptions/components/app.vue';
import SubscriptionTable from 'ee/billings/subscriptions/components/subscription_table.vue';
import * as types from 'ee/billings/subscriptions/store/mutation_types';
import { mockDataSeats } from 'ee_jest/billings/mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('SubscriptionApp component', () => {
let store;
let wrapper;
const appProps = {
const providedFields = {
namespaceId: '42',
namespaceName: 'bronze',
planUpgradeHref: '/url',
customerPortalUrl: 'https://customers.gitlab.com/subscriptions',
};
const factory = (props = appProps, isFeatureEnabledApiBillableMemberList = true) => {
store = createStore();
const factory = () => {
store = new Vuex.Store(initialStore());
jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = shallowMount(SubscriptionApp, {
store,
propsData: { ...props },
provide: {
glFeatures: { apiBillableMemberList: isFeatureEnabledApiBillableMemberList },
...providedFields,
},
localVue,
});
};
......@@ -37,8 +40,6 @@ describe('SubscriptionApp component', () => {
expect(componentWrapper.props()).toEqual(expect.objectContaining(props));
};
const findSubscriptionSeatsTable = () => wrapper.find(SubscriptionSeats);
afterEach(() => {
wrapper.destroy();
});
......@@ -46,60 +47,19 @@ describe('SubscriptionApp component', () => {
describe('on creation', () => {
beforeEach(() => {
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', () => {
expect(store.dispatch.mock.calls).toEqual([
['subscription/setNamespaceId', '42'],
['subscription/fetchHasBillableGroupMembers', undefined],
]);
expect(store.dispatch.mock.calls).toEqual([['setNamespaceId', '42']]);
});
it('passes the correct props to the subscriptions table', () => {
expectComponentWithProps(SubscriptionTable, {
namespaceName: appProps.namespaceName,
planUpgradeHref: appProps.planUpgradeHref,
customerPortalUrl: appProps.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: {},
namespaceName: providedFields.namespaceName,
planUpgradeHref: providedFields.planUpgradeHref,
customerPortalUrl: providedFields.customerPortalUrl,
});
});
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 { 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 { 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 CUSTOMER_PORTAL_URL = 'https://customers.gitlab.com/subscriptions';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('SubscriptionTable component', () => {
let store;
let wrapper;
......@@ -18,12 +22,13 @@ describe('SubscriptionTable component', () => {
wrapper.findAll('a').wrappers.map(x => ({ text: x.text(), href: x.attributes('href') }));
const factory = (options = {}) => {
store = createStore();
store = new Vuex.Store(initialStore());
jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = shallowMount(SubscriptionTable, {
...options,
store,
localVue,
});
};
......@@ -41,7 +46,7 @@ describe('SubscriptionTable component', () => {
},
});
Object.assign(store.state.subscription, { isLoadingSubscription: true });
Object.assign(store.state, { isLoadingSubscription: true });
return wrapper.vm.$nextTick();
});
......@@ -51,7 +56,7 @@ describe('SubscriptionTable component', () => {
});
it('dispatches the correct actions', () => {
expect(store.dispatch).toHaveBeenCalledWith('subscription/fetchSubscription', undefined);
expect(store.dispatch).toHaveBeenCalledWith('fetchSubscription');
});
it('matches the snapshot', () => {
......@@ -63,8 +68,8 @@ describe('SubscriptionTable component', () => {
beforeEach(() => {
factory({ propsData: { namespaceName: TEST_NAMESPACE_NAME } });
store.state.subscription.isLoadingSubscription = false;
store.commit(`subscription/${types.RECEIVE_SUBSCRIPTION_SUCCESS}`, mockDataSubscription.gold);
store.state.isLoadingSubscription = false;
store.commit(`${types.RECEIVE_SUBSCRIPTION_SUCCESS}`, mockDataSubscription.gold);
return wrapper.vm.$nextTick();
});
......@@ -103,7 +108,7 @@ describe('SubscriptionTable component', () => {
},
});
Object.assign(store.state.subscription, {
Object.assign(store.state, {
isLoadingSubscription: false,
isFreePlan,
plan: {
......
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 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 { mockDataSubscription } from '../../../mock_data';
describe('subscription actions', () => {
let mockedState;
let mock;
......
import * as getters from 'ee/billings/stores/modules/subscription/getters';
import State from 'ee/billings/stores/modules/subscription/state';
import State from 'ee/billings/subscriptions/store/state';
import * as getters from 'ee/billings/subscriptions/store/getters';
describe('EE billings subscription module getters', () => {
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 * as types from 'ee/billings/stores/modules/subscription/mutation_types';
import mutations from 'ee/billings/stores/modules/subscription/mutations';
import createState from 'ee/billings/stores/modules/subscription/state';
import { mockDataSubscription } from 'ee_jest/billings/mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { mockDataSubscription } from '../../../mock_data';
describe('EE billings subscription module mutations', () => {
let state;
......
......@@ -15,12 +15,14 @@ RSpec.describe BillingPlansHelper do
it 'returns data attributes' do
upgrade_href =
"#{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))
.to eq(namespace_id: group.id,
namespace_name: group.name,
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
......@@ -36,10 +38,13 @@ RSpec.describe BillingPlansHelper do
let(:plan) { Hashie::Mash.new(id: nil) }
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))
.to eq(namespace_id: group.id,
namespace_name: group.name,
customer_portal_url: customer_portal_url,
billable_seats_href: billable_seats_href,
plan_upgrade_href: nil)
end
end
......
......@@ -23985,6 +23985,9 @@ msgstr ""
msgid "Seat Link is disabled, and cannot be configured through this form."
msgstr ""
msgid "SeatUsage|Seat usage"
msgstr ""
msgid "Seats usage data as of %{last_enqueue_time} (Updated daily)"
msgstr ""
......@@ -26256,6 +26259,9 @@ msgstr ""
msgid "SubscriptionTable|Seats owed"
msgstr ""
msgid "SubscriptionTable|See usage"
msgstr ""
msgid "SubscriptionTable|Subscription end date"
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