Commit 831dadd8 authored by Andrei Stoicescu's avatar Andrei Stoicescu Committed by Vitaly Slobodin

Add "seats in use" table to billings page

  - add html, css and state
parent 56daee5b
......@@ -9,11 +9,15 @@ table {
* This is a temporary workaround until we fix the neutral
* color palette in https://gitlab.com/gitlab-org/gitlab/-/issues/213570
*
* The overwrites here affected the security dashboard tables, when removing
* this code, table-th-transparent and original-text-color classes should
* be removed there.
* The overwrites here affected the following areas:
* - The security dashboard tables. When removing
* this code, table-th-transparent and original-text-color classes should
* be removed there.
* - The subscription seats table. When removing this code, the .seats-table
* <th> and margin overrides should be removed there.
*
* Remove this code as soon as this happens
*
*/
&.gl-table {
@include gl-text-gray-500;
......
<script>
import { mapActions } from 'vuex';
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,
......@@ -28,19 +32,38 @@ export default {
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']),
...mapActions('subscription', ['setNamespaceId', 'fetchHasBillableGroupMembers']),
},
};
</script>
<template>
<subscription-table
:namespace-name="namespaceName"
:plan-upgrade-href="planUpgradeHref"
:customer-portal-url="customerPortalUrl"
/>
<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>
<script>
import { mapActions, mapState } from 'vuex';
import { GlTable, GlAvatarLabeled, GlAvatarLink, GlPagination, GlLoadingIcon } from '@gitlab/ui';
import { parseInt } from 'lodash';
import { s__, sprintf } from '~/locale';
const AVATAR_SIZE = 32;
export default {
components: {
GlTable,
GlAvatarLabeled,
GlAvatarLink,
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']),
items() {
return this.members.map(({ name, username, avatar_url, web_url }) => {
const formattedUserName = `@${username}`;
return { user: { name, username: formattedUserName, avatar_url, web_url } };
});
},
headingText() {
return sprintf(s__('Billing|Users occupying seats in %{namespaceName} Group (%{total})'), {
total: this.total,
namespaceName: this.namespaceName,
});
},
subHeadingText() {
return s__('Billing|Updated live');
},
currentPage: {
get() {
return parseInt(this.page, 10);
},
set(val) {
this.fetchBillableMembersList(val);
},
},
perPageFormatted() {
return parseInt(this.perPage, 10);
},
totalFormatted() {
return parseInt(this.total, 10);
},
},
created() {
this.setNamespaceId(this.namespaceId);
this.fetchBillableMembersList(1);
},
methods: {
...mapActions('seats', ['setNamespaceId', 'fetchBillableMembersList']),
inputHandler(val) {
this.fetchBillableMembersList(val);
},
},
avatarSize: AVATAR_SIZE,
};
</script>
<template>
<div>
<h4 data-testid="heading">{{ headingText }}</h4>
<p>{{ subHeadingText }}</p>
<gl-table
data-testid="seats-table"
class="seats-table"
:items="items"
:fields="fields"
:busy="isLoading"
:show-empty="true"
>
<template #cell(user)="data">
<gl-avatar-link target="blank" :href="data.value.web_url" :alt="data.value.name">
<gl-avatar-labeled
:src="data.value.avatar_url"
:size="$options.avatarSize"
:label="data.value.name"
:sub-label="data.value.username"
/>
</gl-avatar-link>
</template>
<template #empty>
{{ s__('Billing|No users to display.') }}
</template>
<template #table-busy>
<gl-loading-icon size="lg" color="dark" class="gl-mt-5" />
</template>
</gl-table>
<gl-pagination
v-if="currentPage"
v-model="currentPage"
:per-page="perPageFormatted"
:total-items="totalFormatted"
align="center"
class="gl-mt-5"
/>
</div>
</template>
......@@ -29,7 +29,13 @@ export default {
},
},
computed: {
...mapState('subscription', ['isLoading', 'hasError', 'plan', 'tables', 'endpoint']),
...mapState('subscription', [
'isLoadingSubscription',
'hasErrorSubscription',
'plan',
'tables',
'endpoint',
]),
...mapGetters('subscription', ['isFreePlan']),
subscriptionHeader() {
const planName = this.isFreePlan ? s__('SubscriptionTable|Free') : escape(this.plan.name);
......@@ -85,7 +91,7 @@ export default {
<template>
<div>
<div
v-if="!isLoading && !hasError"
v-if="!isLoadingSubscription && !hasErrorSubscription"
class="card gl-mt-3 subscription-table js-subscription-table"
>
<div class="js-subscription-header card-header">
......@@ -115,7 +121,7 @@ export default {
</div>
<gl-loading-icon
v-else-if="isLoading && !hasError"
v-else-if="isLoadingSubscription && !hasErrorSubscription"
:label="s__('SubscriptionTable|Loading subscriptions')"
size="lg"
class="gl-mt-3 gl-mb-3"
......
import API from 'ee/api';
import ApiEe from 'ee/api';
import Api from '~/api';
import * as types from './mutation_types';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '~/locale';
import createFlash from '~/flash';
import { s__ } from '~/locale';
/**
* SUBSCRIPTION TABLE
*/
export const setNamespaceId = ({ commit }, namespaceId) => {
commit(types.SET_NAMESPACE_ID, namespaceId);
};
/**
* Subscription Table
*/
export const fetchSubscription = ({ dispatch, state }) => {
dispatch('requestSubscription');
return API.userSubscription(state.namespaceId)
return ApiEe.userSubscription(state.namespaceId)
.then(({ data }) => dispatch('receiveSubscriptionSuccess', data))
.catch(() => dispatch('receiveSubscriptionError'));
};
......@@ -24,6 +25,32 @@ export const receiveSubscriptionSuccess = ({ commit }, response) =>
commit(types.RECEIVE_SUBSCRIPTION_SUCCESS, response);
export const receiveSubscriptionError = ({ commit }) => {
createFlash(__('An error occurred while loading the subscription details.'));
createFlash({
message: s__('SubscriptionTable|An error occurred while loading the subscription details.'),
});
commit(types.RECEIVE_SUBSCRIPTION_ERROR);
};
/**
* Billable Members
*/
export const fetchHasBillableGroupMembers = ({ dispatch, state }) => {
dispatch('requestHasBillableGroupMembers');
return Api.fetchBillableGroupMembersList(state.namespaceId, { per_page: 1, page: 1 })
.then(data => dispatch('receiveHasBillableGroupMembersSuccess', data))
.catch(() => dispatch('receiveHasBillableGroupMembersError'));
};
export const requestHasBillableGroupMembers = ({ commit }) =>
commit(types.REQUEST_HAS_BILLABLE_MEMBERS);
export const receiveHasBillableGroupMembersSuccess = ({ commit }, response) =>
commit(types.RECEIVE_HAS_BILLABLE_MEMBERS_SUCCESS, response);
export const receiveHasBillableGroupMembersError = ({ commit }) => {
createFlash({
message: s__('SubscriptionTable|An error occurred while loading billable members list'),
});
commit(types.RECEIVE_HAS_BILLABLE_MEMBERS_ERROR);
};
export const SET_NAMESPACE_ID = 'SET_NAMESPACE_ID';
export const REQUEST_SUBSCRIPTION = 'REQUEST_SUBSCRIPTION';
export const RECEIVE_SUBSCRIPTION_SUCCESS = 'RECEIVE_SUBSCRIPTION_SUCCESS';
export const RECEIVE_SUBSCRIPTION_ERROR = 'RECEIVE_SUBSCRIPTION_ERROR';
export const REQUEST_HAS_BILLABLE_MEMBERS = 'REQUEST_HAS_BILLABLE_MEMBERS';
export const RECEIVE_HAS_BILLABLE_MEMBERS_SUCCESS = 'RECEIVE_HAS_BILLABLE_MEMBERS_SUCCESS';
export const RECEIVE_HAS_BILLABLE_MEMBERS_ERROR = 'RECEIVE_HAS_BILLABLE_MEMBERS_ERROR';
import Vue from 'vue';
import { TABLE_TYPE_DEFAULT, TABLE_TYPE_FREE, TABLE_TYPE_TRIAL } from '../../../constants';
import { parseInt } from 'lodash';
import {
TABLE_TYPE_DEFAULT,
TABLE_TYPE_FREE,
TABLE_TYPE_TRIAL,
HEADER_TOTAL_ENTRIES,
} from 'ee/billings/constants';
import * as types from './mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
......@@ -9,8 +15,8 @@ export default {
},
[types.REQUEST_SUBSCRIPTION](state) {
state.isLoading = true;
state.hasError = false;
state.isLoadingSubscription = true;
state.hasErrorSubscription = false;
},
[types.RECEIVE_SUBSCRIPTION_SUCCESS](state, payload) {
......@@ -36,11 +42,29 @@ export default {
});
});
state.isLoading = false;
state.isLoadingSubscription = false;
},
[types.RECEIVE_SUBSCRIPTION_ERROR](state) {
state.isLoading = false;
state.hasError = true;
state.isLoadingSubscription = false;
state.hasErrorSubscription = true;
},
[types.REQUEST_HAS_BILLABLE_MEMBERS](state) {
state.isLoadingHasBillableMembers = true;
state.hasErrorHasBillableMembers = false;
},
[types.RECEIVE_HAS_BILLABLE_MEMBERS_SUCCESS](state, payload) {
const { headers } = payload;
const hasBillableGroupMembers = parseInt(headers[HEADER_TOTAL_ENTRIES], 10) > 0;
state.hasBillableGroupMembers = hasBillableGroupMembers;
state.isLoadingHasBillableMembers = false;
},
[types.RECEIVE_HAS_BILLABLE_MEMBERS_ERROR](state) {
state.isLoadinggHasBillableMembers = false;
state.hasErrorHasBillableMembers = true;
},
};
import { s__ } from '~/locale';
export default () => ({
isLoading: false,
hasError: false,
isLoadingSubscription: false,
hasErrorSubscription: false,
isLoadingBillableMembers: false,
hasErrorBillableMembers: false,
namespaceId: null,
plan: {
code: null,
......@@ -167,4 +169,5 @@ export default () => ({
],
},
},
hasBillableGroupMembers: false,
});
......@@ -107,3 +107,37 @@
}
}
}
.seats-table {
/*
* TODO
* Remove these overrides when the ones inside of
* app/assets/stylesheets/framework/tables.scss
* will be removed.
*
* Read more about this in the comments of that file.
*/
&.table.gl-table {
@include gl-mb-0;
@include gl-text-gray-500;
tr {
th,
td {
@include gl-display-flex;
@include gl-border-b-solid;
@include gl-border-b-1;
@include gl-p-5;
}
th {
@include gl-border-gray-200;
@include gl-bg-transparent;
}
td {
@include gl-border-gray-100;
}
}
}
}
......@@ -4,6 +4,10 @@ class Groups::BillingsController < Groups::ApplicationController
before_action :authorize_admin_group!
before_action :verify_namespace_plan_check_enabled
before_action only: [:index] do
push_frontend_feature_flag(:api_billable_member_list)
end
layout 'group_settings'
feature_category :purchase
......
......@@ -73,7 +73,7 @@ module BillingPlansHelper
def seats_data_last_update_info
last_enqueue_time = UpdateMaxSeatsUsedForGitlabComSubscriptionsWorker.last_enqueue_time&.utc
return _("Seats usage data as of %{last_enqueue_time}" % { last_enqueue_time: last_enqueue_time }) if last_enqueue_time
return _("Seats usage data as of %{last_enqueue_time} (Updated daily)" % { last_enqueue_time: last_enqueue_time }) if last_enqueue_time
_('Seats usage data is updated every day at 12:00pm UTC')
end
......
......@@ -10,7 +10,7 @@
= render 'shared/billings/billing_plan', namespace: namespace, plan: plan, current_plan: current_plan
- if namespace.actual_plan&.paid?
.center
.center.gl-mb-7
&= s_('BillingPlans|If you would like to downgrade your plan please contact %{support_link_start}Customer Support%{support_link_end}.').html_safe % { support_link_start: support_link_start, support_link_end: support_link_end }
%p= seats_data_last_update_info
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SubscriptionApp component on creation matches the snapshot 1`] = `
<subscription-table-stub
customerportalurl="https://customers.gitlab.com/subscriptions"
namespacename="bronze"
planupgradehref="/url"
/>
`;
......@@ -2,6 +2,9 @@ import { shallowMount } from '@vue/test-utils';
import createStore from 'ee/billings/stores';
import SubscriptionApp from 'ee/billings/components/app.vue';
import SubscriptionTable from 'ee/billings/components/subscription_table.vue';
import SubscriptionSeats from 'ee/billings/components/subscription_seats.vue';
import * as types from 'ee/billings/stores/modules/subscription/mutation_types';
import { mockDataSeats } from '../mock_data';
describe('SubscriptionApp component', () => {
let store;
......@@ -14,13 +17,16 @@ describe('SubscriptionApp component', () => {
customerPortalUrl: 'https://customers.gitlab.com/subscriptions',
};
const factory = (props = appProps) => {
const factory = (props = appProps, isFeatureEnabledApiBillableMemberList = true) => {
store = createStore();
jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = shallowMount(SubscriptionApp, {
store,
propsData: { ...props },
provide: {
glFeatures: { apiBillableMemberList: isFeatureEnabledApiBillableMemberList },
},
});
};
......@@ -31,6 +37,8 @@ describe('SubscriptionApp component', () => {
expect(componentWrapper.props()).toEqual(expect.objectContaining(props));
};
const findSubscriptionSeatsTable = () => wrapper.find(SubscriptionSeats);
afterEach(() => {
wrapper.destroy();
});
......@@ -38,18 +46,16 @@ describe('SubscriptionApp component', () => {
describe('on creation', () => {
beforeEach(() => {
factory();
store.commit(`subscription/${types.RECEIVE_HAS_BILLABLE_MEMBERS_SUCCESS}`, mockDataSeats);
});
it('dispatches the setNamespaceId on mounted', () => {
it('dispatches expected actions on created', () => {
expect(store.dispatch.mock.calls).toEqual([
['subscription/setNamespaceId', appProps.namespaceId],
['subscription/setNamespaceId', '42'],
['subscription/fetchHasBillableGroupMembers', undefined],
]);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('passes the correct props to the subscriptions table', () => {
expectComponentWithProps(SubscriptionTable, {
namespaceName: appProps.namespaceName,
......@@ -57,5 +63,43 @@ describe('SubscriptionApp component', () => {
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: {},
});
});
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 { GlPagination } from '@gitlab/ui';
import Vuex from 'vuex';
import SubscriptionSeats from 'ee/billings/components/subscription_seats.vue';
import { mockDataSeats, seatsTableItems } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const actionSpies = {
setNamespaceId: jest.fn(),
fetchBillableMembersList: jest.fn(),
};
const tableProps = {
namespaceName: 'Test Group Name',
namespaceId: '1000',
};
const fakeStore = ({ initialState }) =>
new Vuex.Store({
modules: {
seats: {
namespaced: true,
actions: actionSpies,
state: {
isLoading: false,
hasError: false,
...initialState,
},
},
},
});
const createComponent = ({ props = {}, options = {}, initialState = {} } = {}) => {
return shallowMount(SubscriptionSeats, {
propsData: { ...tableProps, ...props },
store: fakeStore({ initialState }),
localVue,
...options,
stubs: {
GlTable: { template: '<div></div>', props: { items: Array, fields: Array, busy: Boolean } },
},
});
};
describe('Subscription Seats', () => {
let wrapper;
const findTable = () => wrapper.find('[data-testid="seats-table"]');
const findHeading = () => wrapper.find('[data-testid="heading"]');
const findPagination = () => wrapper.find(GlPagination);
beforeEach(() => {
wrapper = createComponent({
initialState: {
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('300');
});
});
describe('table', () => {
it('is rendered and passed correct values', () => {
expect(findTable().props('fields')).toEqual(['user']);
expect(findTable().props('busy')).toBe(false);
expect(findTable().props('items')).toEqual(seatsTableItems);
});
});
describe('pagination', () => {
it('is rendered and passed correct values', () => {
expect(findPagination().vm.value).toBe(1);
expect(findPagination().props('perPage')).toBe(5);
expect(findPagination().props('totalItems')).toBe(300);
});
it.each([null, NaN, undefined, 'a string', false])(
'will not render given %s for currentPage',
value => {
wrapper = createComponent({
initialState: {
namespaceId: null,
members: [...mockDataSeats.data],
total: 300,
page: value,
perPage: 5,
},
});
expect(findPagination().exists()).toBe(false);
},
);
});
});
......@@ -41,7 +41,7 @@ describe('SubscriptionTable component', () => {
},
});
Object.assign(store.state.subscription, { isLoading: true });
Object.assign(store.state.subscription, { isLoadingSubscription: true });
return wrapper.vm.$nextTick();
});
......@@ -63,7 +63,7 @@ describe('SubscriptionTable component', () => {
beforeEach(() => {
factory({ propsData: { namespaceName: TEST_NAMESPACE_NAME } });
store.state.subscription.isLoading = false;
store.state.subscription.isLoadingSubscription = false;
store.commit(`subscription/${types.RECEIVE_SUBSCRIPTION_SUCCESS}`, mockDataSubscription.gold);
return wrapper.vm.$nextTick();
......@@ -104,7 +104,7 @@ describe('SubscriptionTable component', () => {
});
Object.assign(store.state.subscription, {
isLoading: false,
isLoadingSubscription: false,
isFreePlan,
plan: {
code: planName,
......
......@@ -68,31 +68,22 @@ export const mockDataSubscription = {
export const mockDataSeats = {
data: [
{
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://192.168.1.209:3001/root',
avatar_url: 'path/to/img',
web_url: 'path/to/user',
},
{
id: 3,
name: 'Agustin Walker',
username: 'lester.orn',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/772352aed294c4b3e6f236b0624764b6?s=80\u0026d=identicon',
web_url: 'http://192.168.1.209:3001/lester.orn',
avatar_url: 'path/to/img',
web_url: 'path/to/user',
},
{
id: 5,
name: 'Joella Miller',
username: 'era',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/8b306a0c173657865f6a5a6c7120b408?s=80\u0026d=identicon',
web_url: 'http://192.168.1.209:3001/era',
avatar_url: 'path/to/img',
web_url: 'path/to/user',
},
],
headers: {
......@@ -101,3 +92,30 @@ export const mockDataSeats = {
[HEADER_ITEMS_PER_PAGE]: '1',
},
};
export const seatsTableItems = [
{
user: {
name: 'Administrator',
username: '@root',
avatar_url: 'path/to/img',
web_url: 'path/to/user',
},
},
{
user: {
name: 'Agustin Walker',
username: '@lester.orn',
avatar_url: 'path/to/img',
web_url: 'path/to/user',
},
},
{
user: {
name: 'Joella Miller',
username: '@era',
avatar_url: 'path/to/img',
web_url: 'path/to/user',
},
},
];
......@@ -29,12 +29,12 @@ describe('EE billings subscription module mutations', () => {
mutations[types.REQUEST_SUBSCRIPTION](state);
});
it('sets isLoading to true', () => {
expect(state.isLoading).toBeTruthy();
it('sets isLoadingSubscription to true', () => {
expect(state.isLoadingSubscription).toBeTruthy();
});
it('sets hasError to false', () => {
expect(state.hasError).toBeFalsy();
it('sets hasErrorSubscription to false', () => {
expect(state.hasErrorSubscription).toBeFalsy();
});
});
......@@ -57,12 +57,12 @@ describe('EE billings subscription module mutations', () => {
${'with Gold trial'} | ${mockDataSubscription.trial} | ${TABLE_TYPE_TRIAL}
`('$desc', ({ subscription, tableKey }) => {
beforeEach(() => {
state.isLoading = true;
state.isLoadingSubscription = true;
mutations[types.RECEIVE_SUBSCRIPTION_SUCCESS](state, subscription);
});
it('sets isLoading to false', () => {
expect(state.isLoading).toBeFalsy();
it('sets isLoadingSubscription to false', () => {
expect(state.isLoadingSubscription).toBeFalsy();
});
it('sets plan', () => {
......@@ -82,12 +82,12 @@ describe('EE billings subscription module mutations', () => {
mutations[types.RECEIVE_SUBSCRIPTION_ERROR](state);
});
it('sets isLoading to false', () => {
expect(state.isLoading).toBeFalsy();
it('sets isLoadingSubscription to false', () => {
expect(state.isLoadingSubscription).toBeFalsy();
});
it('sets hasError to true', () => {
expect(state.hasError).toBeTruthy();
it('sets hasErrorSubscription to true', () => {
expect(state.hasErrorSubscription).toBeTruthy();
});
});
});
......@@ -3024,9 +3024,6 @@ msgstr ""
msgid "An error occurred while loading the pipelines jobs."
msgstr ""
msgid "An error occurred while loading the subscription details."
msgstr ""
msgid "An error occurred while making the request."
msgstr ""
......@@ -4202,6 +4199,15 @@ msgstr ""
msgid "Billing|An error occurred while loading billable members list"
msgstr ""
msgid "Billing|No users to display."
msgstr ""
msgid "Billing|Updated live"
msgstr ""
msgid "Billing|Users occupying seats in %{namespaceName} Group (%{total})"
msgstr ""
msgid "Bitbucket Server Import"
msgstr ""
......@@ -23182,7 +23188,7 @@ msgstr ""
msgid "Seat Link is disabled, and cannot be configured through this form."
msgstr ""
msgid "Seats usage data as of %{last_enqueue_time}"
msgid "Seats usage data as of %{last_enqueue_time} (Updated daily)"
msgstr ""
msgid "Seats usage data is updated every day at 12:00pm UTC"
......@@ -25366,6 +25372,12 @@ msgstr ""
msgid "Subscription successfully deleted."
msgstr ""
msgid "SubscriptionTable|An error occurred while loading billable members list"
msgstr ""
msgid "SubscriptionTable|An error occurred while loading the subscription details."
msgstr ""
msgid "SubscriptionTable|Billing"
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