Commit aa32508a authored by Michael Lunøe's avatar Michael Lunøe Committed by Etienne Baqué

Feat(Licensing): add Offline cloud type

With the introduction of Zuora field:
TurnOnCloudLicensing__c = "Offline"
we are introducing a type to be displayed in the
frontend, so the user can see the license types of
current, past, and future licenses.

https://gitlab.com/gitlab-com/business-technology/enterprise-apps/financeops/finance-systems/-/issues/537

Changelog: added
EE: true
parent 951bfb8a
......@@ -19,7 +19,7 @@ import {
SUBSCRIPTION_ACTIVATION_FINALIZED_EVENT,
subscriptionActivationForm,
} from '../constants';
import { getErrorsAsData, getLicenseFromData } from '../graphql/utils';
import { getErrorsAsData, getLicenseFromData } from '../utils';
import activateSubscriptionMutation from '../graphql/mutations/activate_subscription.mutation.graphql';
const feedbackMap = {
......
......@@ -20,7 +20,14 @@ import SubscriptionDetailsCard from './subscription_details_card.vue';
import SubscriptionDetailsHistory from './subscription_details_history.vue';
import SubscriptionDetailsUserInfo from './subscription_details_user_info.vue';
export const subscriptionDetailsFields = ['id', 'plan', 'expiresAt', 'lastSync', 'startsAt'];
export const subscriptionDetailsFields = [
'id',
'plan',
'type',
'expiresAt',
'lastSync',
'startsAt',
];
export const licensedToFields = ['name', 'email', 'company'];
export const modalId = 'subscription-activation-modal';
......@@ -86,7 +93,7 @@ export default {
return this.customersPortalUrl && this.hasSubscription;
},
canSyncSubscription() {
return this.subscriptionSyncPath && this.isCloudType;
return this.subscriptionSyncPath && this.isOnlineCloudType;
},
canRemoveLicense() {
return this.licenseRemovePath;
......@@ -97,8 +104,8 @@ export default {
hasSubscriptionHistory() {
return Boolean(this.subscriptionList.length);
},
isCloudType() {
return this.subscription.type === subscriptionTypes.CLOUD;
isOnlineCloudType() {
return this.subscription.type === subscriptionTypes.ONLINE_CLOUD;
},
isLicenseFileType() {
return this.subscription.type === subscriptionTypes.LICENSE_FILE;
......
......@@ -4,12 +4,14 @@ import { identity } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { getLicenseTypeLabel } from '../utils';
import SubscriptionDetailsTable from './subscription_details_table.vue';
const subscriptionDetailsFormatRules = {
id: getIdFromGraphQLId,
expiresAt: getTimeago().format,
lastSync: getTimeago().format,
type: getLicenseTypeLabel,
plan: capitalizeFirstCharacter,
};
......
......@@ -2,13 +2,8 @@
import { GlTooltip, GlTooltipDirective, GlIcon, GlBadge, GlTableLite } from '@gitlab/ui';
import { kebabCase } from 'lodash';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import {
cloudLicenseText,
detailsLabels,
licenseFileText,
subscriptionTable,
subscriptionTypes,
} from '../constants';
import { detailsLabels, subscriptionTable } from '../constants';
import { getLicenseTypeLabel } from '../utils';
const DEFAULT_BORDER_CLASSES = 'gl-border-b-1! gl-border-b-gray-100! gl-border-b-solid!';
const DEFAULT_TH_CLASSES = 'gl-bg-white! gl-border-t-0! gl-pb-5! gl-px-5! gl-text-gray-700!';
......@@ -99,8 +94,7 @@ export default {
},
{
key: 'type',
formatter: (v, k, item) =>
item.type === subscriptionTypes.LICENSE_FILE ? licenseFileText : cloudLicenseText,
formatter: (v, k, item) => getLicenseTypeLabel(item.type),
label: subscriptionTable.type,
tdAttr,
tdClass: this.cellClass,
......
<script>
import { GlSkeletonLoader, GlTableLite } from '@gitlab/ui';
import { GlSkeletonLoader, GlTableLite, GlBadge } from '@gitlab/ui';
import { slugifyWithUnderscore } from '~/lib/utils/text_utility';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { copySubscriptionIdButtonText, detailsLabels } from '../constants';
......@@ -23,7 +23,7 @@ export default {
},
{
key: 'value',
formatter: (v, k, item) => item.value.toString(),
formatter: (v, k, item) => item.value?.toString() || '-',
label: '',
thClass: DEFAULT_TH_CLASSES,
tdClass: DEFAULT_TD_CLASSES,
......@@ -34,6 +34,7 @@ export default {
ClipboardButton,
GlSkeletonLoader,
GlTableLite,
GlBadge,
},
props: {
details: {
......@@ -61,9 +62,6 @@ export default {
},
},
methods: {
isLastRow(index) {
return index === this.details.length - 1;
},
placeHolderPosition(index) {
return (index - 1) * placeholderHeightFactor;
},
......@@ -106,7 +104,12 @@ export default {
data-testid="details-content"
:data-qa-selector="qaSelectorValue(item)"
>
{{ value || '-' }}
<gl-badge v-if="item.detail === 'type'" size="md" variant="info">
{{ value }}
</gl-badge>
<span v-else>
{{ value }}
</span>
<clipboard-button
v-if="item.detail === 'id'"
:text="value"
......
......@@ -36,7 +36,8 @@ export const manageSubscriptionButtonText = s__('SuperSonics|Manage');
export const syncSubscriptionButtonText = s__('SuperSonics|Sync subscription details');
export const copySubscriptionIdButtonText = __('Copy');
export const licenseFileText = __('License file');
export const cloudLicenseText = s__('SuperSonics|Cloud license');
export const onlineCloudLicenseText = s__('SuperSonics|Cloud license');
export const offlineCloudLicenseText = s__('SuperSonics|Offline cloud');
export const usersInSubscriptionUnlimited = __('Unlimited');
export const detailsLabels = {
address: __('Address'),
......@@ -46,6 +47,7 @@ export const detailsLabels = {
lastSync: __('Last Sync'),
name: licensedToHeaderText,
plan: __('Plan'),
type: __('Type'),
expiresAt: __('Renews'),
startsAt: __('Started'),
};
......@@ -106,7 +108,8 @@ export const subscriptionSyncStatus = {
};
export const subscriptionTypes = {
CLOUD: 'cloud',
ONLINE_CLOUD: 'cloud',
OFFLINE_CLOUD: 'offline_cloud',
LICENSE_FILE: 'license_file',
};
......
import {
subscriptionTypes,
offlineCloudLicenseText,
onlineCloudLicenseText,
licenseFileText,
} from './constants';
export const getLicenseFromData = ({ data } = {}) => data?.gitlabSubscriptionActivate?.license;
export const getErrorsAsData = ({ data } = {}) => data?.gitlabSubscriptionActivate?.errors || [];
export function getLicenseTypeLabel(type) {
switch (type) {
case subscriptionTypes.OFFLINE_CLOUD:
return offlineCloudLicenseText;
case subscriptionTypes.ONLINE_CLOUD:
return onlineCloudLicenseText;
default:
return licenseFileText;
}
}
......@@ -12,7 +12,13 @@ module Resolvers
authorize!
::Gitlab::CurrentSettings.future_subscriptions.each do |subscription|
subscription['type'] = subscription['cloud_license_enabled'] ? License::CLOUD_LICENSE_TYPE : License::LICENSE_FILE_TYPE
subscription['type'] = if subscription['offline_cloud_licensing'] && subscription['cloud_license_enabled']
License::OFFLINE_CLOUD_TYPE
elsif subscription['cloud_license_enabled'] && !subscription['offline_cloud_licensing']
License::CLOUD_LICENSE_TYPE
else
License::LICENSE_FILE_TYPE
end
end
end
......
......@@ -8,6 +8,7 @@ class License < ApplicationRecord
PREMIUM_PLAN = 'premium'
ULTIMATE_PLAN = 'ultimate'
CLOUD_LICENSE_TYPE = 'cloud'
OFFLINE_CLOUD_TYPE = 'offline_cloud'
LICENSE_FILE_TYPE = 'license_file'
ALLOWED_PERCENTAGE_OF_USERS_OVERAGE = (10 / 100.0)
......@@ -587,11 +588,11 @@ class License < ApplicationRecord
end
def offline_cloud_license?
!!license&.offline_cloud_licensing?
cloud_license? && !!license&.offline_cloud_licensing?
end
def online_cloud_license?
cloud_license? && !offline_cloud_license?
cloud_license? && !license&.offline_cloud_licensing?
end
def customer_service_enabled?
......@@ -603,7 +604,10 @@ class License < ApplicationRecord
end
def license_type
cloud_license? ? CLOUD_LICENSE_TYPE : LICENSE_FILE_TYPE
return OFFLINE_CLOUD_TYPE if offline_cloud_license?
return CLOUD_LICENSE_TYPE if online_cloud_license?
LICENSE_FILE_TYPE
end
def auto_renew
......
......@@ -180,13 +180,16 @@ describe('Subscription Breakdown', () => {
describe('footer buttons', () => {
it.each`
url | type | shouldShow
${subscriptionSyncPath} | ${subscriptionTypes.CLOUD} | ${true}
${subscriptionSyncPath} | ${subscriptionTypes.LICENSE_FILE} | ${false}
${''} | ${subscriptionTypes.CLOUD} | ${false}
${''} | ${subscriptionTypes.LICENSE_FILE} | ${false}
${undefined} | ${subscriptionTypes.CLOUD} | ${false}
${undefined} | ${subscriptionTypes.LICENSE_FILE} | ${false}
url | type | shouldShow
${subscriptionSyncPath} | ${subscriptionTypes.ONLINE_CLOUD} | ${true}
${subscriptionSyncPath} | ${subscriptionTypes.OFFLINE_CLOUD} | ${false}
${subscriptionSyncPath} | ${subscriptionTypes.LICENSE_FILE} | ${false}
${''} | ${subscriptionTypes.ONLINE_CLOUD} | ${false}
${''} | ${subscriptionTypes.OFFLINE_CLOUD} | ${false}
${''} | ${subscriptionTypes.LICENSE_FILE} | ${false}
${undefined} | ${subscriptionTypes.ONLINE_CLOUD} | ${false}
${undefined} | ${subscriptionTypes.OFFLINE_CLOUD} | ${false}
${undefined} | ${subscriptionTypes.LICENSE_FILE} | ${false}
`(
'with url is $url and type is $type the sync button is shown: $shouldShow',
({ url, type, shouldShow }) => {
......@@ -229,13 +232,16 @@ describe('Subscription Breakdown', () => {
});
it.each`
url | type | shouldShow
${licenseRemovePath} | ${subscriptionTypes.LICENSE_FILE} | ${true}
${licenseRemovePath} | ${subscriptionTypes.CLOUD} | ${true}
${''} | ${subscriptionTypes.LICENSE_FILE} | ${false}
${''} | ${subscriptionTypes.CLOUD} | ${false}
${undefined} | ${subscriptionTypes.LICENSE_FILE} | ${false}
${undefined} | ${subscriptionTypes.CLOUD} | ${false}
url | type | shouldShow
${licenseRemovePath} | ${subscriptionTypes.LICENSE_FILE} | ${true}
${licenseRemovePath} | ${subscriptionTypes.ONLINE_CLOUD} | ${true}
${licenseRemovePath} | ${subscriptionTypes.OFFLINE_CLOUD} | ${true}
${''} | ${subscriptionTypes.LICENSE_FILE} | ${false}
${''} | ${subscriptionTypes.ONLINE_CLOUD} | ${false}
${''} | ${subscriptionTypes.OFFLINE_CLOUD} | ${false}
${undefined} | ${subscriptionTypes.LICENSE_FILE} | ${false}
${undefined} | ${subscriptionTypes.ONLINE_CLOUD} | ${false}
${undefined} | ${subscriptionTypes.OFFLINE_CLOUD} | ${false}
`(
'with url is $url and type is $type the remove button is shown: $shouldShow',
({ url, type, shouldShow }) => {
......@@ -254,9 +260,10 @@ describe('Subscription Breakdown', () => {
);
it.each`
type | shouldShow
${subscriptionTypes.LICENSE_FILE} | ${true}
${subscriptionTypes.CLOUD} | ${false}
type | shouldShow
${subscriptionTypes.LICENSE_FILE} | ${true}
${subscriptionTypes.ONLINE_CLOUD} | ${false}
${subscriptionTypes.OFFLINE_CLOUD} | ${false}
`(
'with url is $url and type is $type the activate cloud license button is shown: $shouldShow',
({ type, shouldShow }) => {
......
......@@ -63,6 +63,10 @@ describe('Subscription Details Card', () => {
detail: 'plan',
value: 'Ultimate',
},
{
detail: 'type',
value: 'Cloud license',
},
{
detail: 'expiresAt',
value: 'in 1 year',
......
......@@ -2,12 +2,8 @@ import { GlBadge, GlIcon, GlTooltip } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import SubscriptionDetailsHistory from 'ee/admin/subscriptions/show/components/subscription_details_history.vue';
import {
detailsLabels,
cloudLicenseText,
licenseFileText,
subscriptionTypes,
} from 'ee/admin/subscriptions/show/constants';
import { detailsLabels, onlineCloudLicenseText } from 'ee/admin/subscriptions/show/constants';
import { getLicenseTypeLabel } from 'ee/admin/subscriptions/show/utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { license, subscriptionFutureHistory, subscriptionPastHistory } from '../mock_data';
......@@ -54,7 +50,7 @@ describe('Subscription Details History', () => {
});
it('has the correct license type', () => {
expect(findCurrentRow().text()).toContain(cloudLicenseText);
expect(findCurrentRow().text()).toContain(onlineCloudLicenseText);
expect(findTableRows().at(-1).text()).toContain('License file');
});
......@@ -119,9 +115,7 @@ describe('Subscription Details History', () => {
it('displays the correct value for the type cell', () => {
const cellTestId = `subscription-cell-type`;
const type =
subscription.type === subscriptionTypes.LICENSE_FILE ? licenseFileText : cloudLicenseText;
expect(findCellByTestid(cellTestId).text()).toBe(type);
expect(findCellByTestid(cellTestId).text()).toBe(getLicenseTypeLabel(subscription.type));
});
it('displays the correct value for the plan cell', () => {
......
import { GlSkeletonLoader } from '@gitlab/ui';
import { GlSkeletonLoader, GlBadge } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import SubscriptionDetailsTable from 'ee/admin/subscriptions/show/components/subscription_details_table.vue';
import { detailsLabels } from 'ee/admin/subscriptions/show/constants';
......@@ -14,6 +14,9 @@ const licenseDetails = [
detail: 'lastSync',
value: 'just now',
},
{
detail: 'email',
},
];
const hasFontWeightBold = (wrapper) => wrapper.classes('gl-font-weight-bold');
......@@ -26,6 +29,7 @@ describe('Subscription Details Table', () => {
const findLabelCells = () => wrapper.findAllByTestId('details-label');
const findLastSyncRow = () => wrapper.findByTestId('row-lastsync');
const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
const findTypeBadge = () => wrapper.findComponent(GlBadge);
const hasClass = (className) => (w) => w.classes(className);
const isNotLastSyncRow = (w) => w.attributes('data-testid') !== 'row-lastsync';
......@@ -45,8 +49,8 @@ describe('Subscription Details Table', () => {
});
it('displays the correct number of rows', () => {
expect(findLabelCells()).toHaveLength(2);
expect(findContentCells()).toHaveLength(2);
expect(findLabelCells()).toHaveLength(licenseDetails.length);
expect(findContentCells()).toHaveLength(licenseDetails.length);
});
it('displays the correct content for rows', () => {
......@@ -62,9 +66,39 @@ describe('Subscription Details Table', () => {
expect(findClipboardButton().exists()).toBe(false);
});
it('does not show a badge', () => {
expect(findTypeBadge().exists()).toBe(false);
});
it('shows the default row color', () => {
expect(findLastSyncRow().classes('gl-text-gray-800')).toBe(true);
});
it('displays a dash for empty values', () => {
expect(findLabelCells().at(2).text()).toBe(`${detailsLabels.email}:`);
expect(findContentCells().at(2).text()).toBe('-');
});
});
describe('with type detail', () => {
beforeEach(() => {
createComponent({
details: [
{
detail: 'type',
value: 'My type',
},
],
});
});
it('shows a badge', () => {
expect(findTypeBadge().exists()).toBe(true);
});
it('displays the correct text', () => {
expect(findContentCells().at(0).text()).toBe('My type');
});
});
describe('with copy-able detail', () => {
......
......@@ -13,7 +13,7 @@ export const license = {
name: 'Jane Doe',
plan: 'ultimate',
startsAt: '2021-03-11',
type: subscriptionTypes.CLOUD,
type: subscriptionTypes.ONLINE_CLOUD,
usersInLicenseCount: '10',
usersOverLicenseCount: '0',
},
......@@ -29,7 +29,7 @@ export const license = {
name: 'Jane Doe',
plan: 'ultimate',
startsAt: '2022-03-16',
type: subscriptionTypes.CLOUD,
type: subscriptionTypes.ONLINE_CLOUD,
usersInLicenseCount: '10',
usersOverLicenseCount: '0',
},
......@@ -45,7 +45,7 @@ export const subscriptionPastHistory = [
name: 'Jane Doe',
plan: 'ultimate',
startsAt: '2021-03-11',
type: subscriptionTypes.CLOUD,
type: subscriptionTypes.ONLINE_CLOUD,
usersInLicenseCount: '10',
},
{
......@@ -70,7 +70,7 @@ export const subscriptionFutureHistory = [
name: 'Jane Doe',
plan: 'ultimate',
startsAt: '2022-03-11',
type: subscriptionTypes.CLOUD,
type: subscriptionTypes.OFFLINE_CLOUD,
usersInLicenseCount: '15',
},
{
......@@ -80,7 +80,7 @@ export const subscriptionFutureHistory = [
name: 'Jane Doe',
plan: 'ultimate',
startsAt: '2021-03-16',
type: subscriptionTypes.CLOUD,
type: subscriptionTypes.ONLINE_CLOUD,
usersInLicenseCount: '10',
},
];
......
import { getErrorsAsData, getLicenseFromData } from 'ee/admin/subscriptions/show/graphql/utils';
describe('graphQl utils', () => {
import {
subscriptionTypes,
offlineCloudLicenseText,
onlineCloudLicenseText,
licenseFileText,
} from 'ee/admin/subscriptions/show/constants';
import {
getErrorsAsData,
getLicenseFromData,
getLicenseTypeLabel,
} from 'ee/admin/subscriptions/show/utils';
describe('utils', () => {
describe('getLicenseFromData', () => {
const license = { id: 'license-id' };
const gitlabSubscriptionActivate = { license };
......@@ -58,4 +68,16 @@ describe('graphQl utils', () => {
expect(result).toEqual([]);
});
});
describe('getLicenseTypeLabel', () => {
const typeLabels = {
OFFLINE_CLOUD: offlineCloudLicenseText,
ONLINE_CLOUD: onlineCloudLicenseText,
LICENSE_FILE: licenseFileText,
};
it.each(Object.keys(subscriptionTypes))('should return correct label for type', (key) => {
expect(getLicenseTypeLabel(subscriptionTypes[key])).toBe(typeLabels[key]);
});
});
});
......@@ -24,19 +24,21 @@ RSpec.describe Resolvers::Admin::CloudLicenses::SubscriptionFutureEntriesResolve
end
end
context 'when no subscriptions exist' do
it 'returns an empty array', :enable_admin_mode do
context 'when no subscriptions exist', :enable_admin_mode do
it 'returns an empty array' do
allow(::Gitlab::CurrentSettings).to receive(:future_subscriptions).and_return([])
expect(result).to eq([])
end
end
context 'when future subscriptions exist' do
context 'when future subscriptions exist', :enable_admin_mode do
let(:cloud_license_enabled) { true }
let(:offline_cloud_licensing) { false }
let(:subscription) do
{
'cloud_license_enabled' => cloud_license_enabled,
'offline_cloud_licensing' => offline_cloud_licensing,
'plan' => 'ultimate',
'name' => 'User Example',
'email' => 'user@example.com',
......@@ -51,11 +53,11 @@ RSpec.describe Resolvers::Admin::CloudLicenses::SubscriptionFutureEntriesResolve
allow(::Gitlab::CurrentSettings).to receive(:future_subscriptions).and_return([subscription])
end
it 'returns the subscription future entries', :enable_admin_mode do
it 'returns the subscription future entries' do
expect(result).to match(
[
hash_including(
'type' => 'cloud',
'type' => License::CLOUD_LICENSE_TYPE,
'plan' => 'ultimate',
'name' => 'User Example',
'email' => 'user@example.com',
......@@ -71,8 +73,16 @@ RSpec.describe Resolvers::Admin::CloudLicenses::SubscriptionFutureEntriesResolve
context 'cloud_license_enabled is false' do
let(:cloud_license_enabled) { false }
it 'returns type as license_file', :enable_admin_mode do
expect(result.first).to include('type' => 'license_file')
it 'returns type as license_file' do
expect(result.first).to include('type' => License::LICENSE_FILE_TYPE)
end
end
context 'cloud_license_enabled is true and offline_cloud_licensing is true' do
let(:offline_cloud_licensing) { true }
it 'returns type as offline_cloud' do
expect(result.first).to include('type' => License::OFFLINE_CLOUD_TYPE)
end
end
end
......
......@@ -1640,11 +1640,17 @@ RSpec.describe License do
it { is_expected.to eq(described_class::LICENSE_FILE_TYPE) }
end
context 'when the license is a cloud license' do
context 'when the license is an online cloud license' do
let(:gl_license) { build(:gitlab_license, cloud_licensing_enabled: true) }
it { is_expected.to eq(described_class::CLOUD_LICENSE_TYPE) }
end
context 'when the license is an offline cloud license' do
let(:gl_license) { build(:gitlab_license, cloud_licensing_enabled: true, offline_cloud_licensing_enabled: true) }
it { is_expected.to eq(described_class::OFFLINE_CLOUD_TYPE) }
end
end
describe '#auto_renew' do
......
......@@ -35504,6 +35504,9 @@ msgstr ""
msgid "SuperSonics|Maximum users"
msgstr ""
msgid "SuperSonics|Offline cloud"
msgstr ""
msgid "SuperSonics|Paste your activation code"
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