Commit 771d0e85 authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch 'ag-331597-failed-sync' into 'master'

Highlight line on failed subscription sync

See merge request gitlab-org/gitlab!62687
parents bbeb4f48 4b3195c5
......@@ -5,7 +5,7 @@ import { activateSubscription, howToActivateSubscription, uploadLegacyLicense }
import SubscriptionActivationErrors from './subscription_activation_errors.vue';
import SubscriptionActivationForm from './subscription_activation_form.vue';
export const activateSubscriptionUrl = helpPagePath('/user/admin_area/license', {
export const activateSubscriptionUrl = helpPagePath('user/admin_area/license.html', {
anchor: 'activate-gitlab-ee-with-an-activation-code',
});
......
......@@ -5,7 +5,7 @@ import {
enterActivationCode,
licensedToHeaderText,
manageSubscriptionButtonText,
notificationType,
subscriptionSyncStatus,
removeLicense,
removeLicenseConfirm,
subscriptionDetailsHeaderText,
......@@ -63,7 +63,8 @@ export default {
return {
hasAsyncActivity: false,
licensedToFields,
notification: null,
shouldShowNotifications: false,
subscriptionSyncStatus: null,
subscriptionDetailsFields,
};
},
......@@ -103,23 +104,27 @@ export default {
subscriptionHistory() {
return this.hasSubscriptionHistory ? this.subscriptionList : [this.subscription];
},
syncDidFail() {
return this.subscriptionSyncStatus === subscriptionSyncStatus.SYNC_FAILURE;
},
},
methods: {
didDismissSuccessAlert() {
this.notification = null;
this.shouldShowNotifications = false;
},
syncSubscription() {
this.hasAsyncActivity = true;
this.notification = null;
this.shouldShowNotifications = false;
axios
.post(this.subscriptionSyncPath)
.then(() => {
this.notification = notificationType.SYNC_SUCCESS;
this.subscriptionSyncStatus = subscriptionSyncStatus.SYNC_SUCCESS;
})
.catch(() => {
this.notification = notificationType.SYNC_FAILURE;
this.subscriptionSyncStatus = subscriptionSyncStatus.SYNC_FAILURE;
})
.finally(() => {
this.shouldShowNotifications = true;
this.hasAsyncActivity = false;
});
},
......@@ -131,9 +136,9 @@ export default {
<div>
<subscription-activation-modal v-if="hasSubscription" :modal-id="$options.modal.id" />
<subscription-sync-notifications
v-if="notification"
v-if="shouldShowNotifications"
class="mb-4"
:notification="notification"
:sync-status="subscriptionSyncStatus"
@success-alert-dismissed="didDismissSuccessAlert"
/>
<section class="row gl-mb-5">
......@@ -142,6 +147,7 @@ export default {
:details-fields="subscriptionDetailsFields"
:header-text="$options.i18n.subscriptionDetailsHeaderText"
:subscription="subscription"
:sync-did-fail="syncDidFail"
>
<template v-if="shouldShowFooter" #footer>
<gl-button
......
......@@ -4,7 +4,6 @@ import { identity } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { detailsLabels } from '../constants';
import SubscriptionDetailsTable from './subscription_details_table.vue';
const humanReadableDate = (value) => (value ? formatDate(value, 'd mmmm yyyy') : '');
......@@ -20,8 +19,8 @@ const subscriptionDetailsFormatRules = {
export default {
name: 'SubscriptionDetailsCard',
components: {
SubscriptionDetailsTable,
GlCard,
SubscriptionDetailsTable,
},
props: {
detailsFields: {
......@@ -37,15 +36,19 @@ export default {
type: Object,
required: true,
},
syncDidFail: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
details() {
return this.detailsFields.map((detail) => {
const label = detailsLabels[detail];
const formatter = subscriptionDetailsFormatRules[detail] || identity;
const valueToFormat = this.subscription[detail];
const value = valueToFormat ? formatter(valueToFormat) : '';
return { canCopy: detail === 'id', label, value };
return { detail, value };
});
},
},
......@@ -57,9 +60,7 @@ export default {
<template v-if="headerText" #header>
<h6 class="gl-m-0">{{ headerText }}</h6>
</template>
<subscription-details-table :details="details" />
<subscription-details-table :details="details" :sync-did-fail="syncDidFail" />
<template #footer>
<slot name="footer"></slot>
</template>
......
......@@ -2,7 +2,7 @@
import { GlSkeletonLoader, GlTable } from '@gitlab/ui';
import { slugifyWithUnderscore } from '~/lib/utils/text_utility';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { copySubscriptionIdButtonText } from '../constants';
import { copySubscriptionIdButtonText, detailsLabels } from '../constants';
const placeholderHeightFactor = 32;
const placeholderWidth = 180;
......@@ -10,6 +10,7 @@ const DEFAULT_TH_CLASSES = 'gl-display-none';
const DEFAULT_TD_CLASSES = 'gl-border-none! gl-h-7 gl-line-height-normal! gl-p-0!';
export default {
detailsLabels,
i18n: {
copySubscriptionIdButtonText,
},
......@@ -39,6 +40,11 @@ export default {
type: Array,
required: true,
},
syncDidFail: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
hasContent() {
......@@ -61,30 +67,48 @@ export default {
placeHolderPosition(index) {
return (index - 1) * placeholderHeightFactor;
},
qaSelectorValue(label) {
return slugifyWithUnderscore(label.toLowerCase());
qaSelectorValue({ detail }) {
return slugifyWithUnderscore(detail);
},
rowAttr({ detail }, type) {
return {
'data-testid': `${type}-${slugifyWithUnderscore(detail)}`,
};
},
rowClass(item) {
return item.detail === 'lastSync' && this.syncDidFail
? `gl-text-red-500`
: 'gl-text-gray-800';
},
rowLabel({ detail }) {
return this.$options.detailsLabels[detail];
},
},
};
</script>
<template>
<gl-table v-if="hasContent" :fields="$options.fields" :items="details" class="gl-m-0!">
<gl-table
v-if="hasContent"
:fields="$options.fields"
:items="details"
class="gl-m-0!"
:tbody-tr-attr="rowAttr"
:tbody-tr-class="rowClass"
>
<template #cell(label)="{ item }">
<p class="gl-font-weight-bold gl-text-gray-800" data-testid="details-label">
{{ item.label }}:
</p>
<p class="gl-font-weight-bold" data-testid="details-label">{{ rowLabel(item) }}:</p>
</template>
<template #cell(value)="{ item, value }">
<p
class="gl-relative"
data-testid="details-content"
:data-qa-selector="qaSelectorValue(item.label)"
:data-qa-selector="qaSelectorValue(item)"
>
{{ value || '-' }}
<clipboard-button
v-if="item.canCopy"
v-if="item.detail === 'id'"
:text="value"
:title="$options.i18n.copySubscriptionIdButtonText"
category="tertiary"
......
......@@ -4,13 +4,13 @@ import {
manualSyncFailureText,
connectivityIssue,
manualSyncSuccessfulTitle,
notificationType,
subscriptionSyncStatus,
} from '../constants';
export const SUCCESS_ALERT_DISMISSED_EVENT = 'success-alert-dismissed';
const notificationTypeValidator = (value) =>
!value || Object.values(notificationType).includes(value);
const subscriptionSyncStatusValidator = (value) =>
!value || Object.values(subscriptionSyncStatus).includes(value);
export default {
name: 'SubscriptionSyncNotifications',
......@@ -26,19 +26,18 @@ export default {
},
inject: ['connectivityHelpURL'],
props: {
notification: {
syncStatus: {
type: String,
required: false,
default: '',
validator: notificationTypeValidator,
required: true,
validator: subscriptionSyncStatusValidator,
},
},
computed: {
syncDidSuccess() {
return this.notification === notificationType.SYNC_SUCCESS;
return this.syncStatus === subscriptionSyncStatus.SYNC_SUCCESS;
},
syncDidFail() {
return this.notification === notificationType.SYNC_FAILURE;
return this.syncStatus === subscriptionSyncStatus.SYNC_FAILURE;
},
},
methods: {
......
......@@ -81,7 +81,7 @@ export const subscriptionActivationForm = {
),
};
export const notificationType = {
export const subscriptionSyncStatus = {
SYNC_FAILURE: 'SYNC_FAILURE',
SYNC_SUCCESS: 'SYNC_SUCCESS',
};
......
......@@ -16,7 +16,7 @@ import SubscriptionSyncNotifications, {
} from 'ee/pages/admin/cloud_licenses/components/subscription_sync_notifications.vue';
import {
licensedToHeaderText,
notificationType,
subscriptionSyncStatus,
subscriptionDetailsHeaderText,
subscriptionType,
} from 'ee/pages/admin/cloud_licenses/constants';
......@@ -101,18 +101,22 @@ describe('Subscription Breakdown', () => {
it('provides the correct props to the cards', () => {
const props = findDetailsCards().wrappers.map((w) => w.props());
expect(props).toEqual([
{
detailsFields: subscriptionDetailsFields,
headerText: subscriptionDetailsHeaderText,
subscription: license.ULTIMATE,
},
{
detailsFields: licensedToFields,
headerText: licensedToHeaderText,
subscription: license.ULTIMATE,
},
]);
expect(props).toEqual(
expect.arrayContaining([
{
detailsFields: subscriptionDetailsFields,
headerText: subscriptionDetailsHeaderText,
subscription: license.ULTIMATE,
syncDidFail: false,
},
{
detailsFields: licensedToFields,
headerText: licensedToHeaderText,
subscription: license.ULTIMATE,
syncDidFail: false,
},
]),
);
});
it('shows the user info', () => {
......@@ -276,11 +280,15 @@ describe('Subscription Breakdown', () => {
});
it('shows a success notification', () => {
expect(findSubscriptionSyncNotifications().props('notification')).toBe(
notificationType.SYNC_SUCCESS,
expect(findSubscriptionSyncNotifications().props('syncStatus')).toBe(
subscriptionSyncStatus.SYNC_SUCCESS,
);
});
it('provides the sync status to the details card', () => {
expect(findDetailsCards().at(0).props('syncDidFail')).toBe(false);
});
it('dismisses the success notification', async () => {
findSubscriptionSyncNotifications().vm.$emit(SUCCESS_ALERT_DISMISSED_EVENT);
await nextTick();
......@@ -298,11 +306,15 @@ describe('Subscription Breakdown', () => {
});
it('shows a failure notification', () => {
expect(findSubscriptionSyncNotifications().props('notification')).toBe(
notificationType.SYNC_FAILURE,
expect(findSubscriptionSyncNotifications().props('syncStatus')).toBe(
subscriptionSyncStatus.SYNC_FAILURE,
);
});
it('provides the sync status to the details card', () => {
expect(findDetailsCards().at(0).props('syncDidFail')).toBe(true);
});
it('dismisses the failure notification when retrying to sync', async () => {
await findSubscriptionSyncAction().vm.$emit('click');
......
......@@ -18,16 +18,13 @@ describe('Subscription Details Card', () => {
const findCardFooter = () => findCard().find('.gl-card-footer');
const findSubscriptionDetailsTable = () => wrapper.findComponent(SubscriptionDetailsTable);
const createComponent = (
{ detailsFields = subscriptionDetailsFields, headerText, subscription = license.ULTIMATE } = {},
slots,
) => {
const createComponent = (props = {}, slots) => {
wrapper = extendedWrapper(
shallowMount(SubscriptionDetailsCard, {
propsData: {
detailsFields,
headerText,
subscription,
detailsFields: subscriptionDetailsFields,
subscription: license.ULTIMATE,
...props,
},
stubs: {
GlCard,
......@@ -59,34 +56,43 @@ describe('Subscription Details Card', () => {
it('passes the details to the table component', () => {
expect(findSubscriptionDetailsTable().props('details')).toEqual([
{
canCopy: true,
label: 'ID',
detail: 'id',
value: 13,
},
{
canCopy: false,
label: 'Plan',
detail: 'plan',
value: 'Ultimate',
},
{
canCopy: false,
label: 'Renews',
detail: 'expiresAt',
value: 'in 1 year',
},
{
canCopy: false,
label: 'Last Sync',
detail: 'lastSync',
value: 'just now',
},
{
canCopy: false,
label: 'Started',
detail: 'startsAt',
value: '11 March 2021',
},
]);
});
});
describe('subscription sync state', () => {
it('passes true when sync succeeded', () => {
createComponent({ syncDidFail: false });
expect(findSubscriptionDetailsTable().props('syncDidFail')).toBe(false);
});
it('passes true when sync failed', () => {
createComponent({ syncDidFail: true });
expect(findSubscriptionDetailsTable().props('syncDidFail')).toBe(true);
});
});
describe('with no title', () => {
it('does not display a title', () => {
createComponent();
......
import { GlSkeletonLoader } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import SubscriptionDetailsTable from 'ee/pages/admin/cloud_licenses/components/subscription_details_table.vue';
import { detailsLabels } from 'ee/pages/admin/cloud_licenses/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
const licenseDetails = [
{
label: 'Row label 1',
value: 'row content 1',
detail: 'expiresAt',
value: 'in 1 year',
},
{
label: 'Row label 2',
value: 'row content 2',
detail: 'lastSync',
value: 'just now',
},
];
......@@ -20,12 +21,18 @@ const hasFontWeightBold = (wrapper) => wrapper.classes('gl-font-weight-bold');
describe('Subscription Details Table', () => {
let wrapper;
const findAllRows = () => wrapper.findAll('tbody > tr');
const findContentCells = () => wrapper.findAllByTestId('details-content');
const findLabelCells = () => wrapper.findAllByTestId('details-label');
const findLastSyncRow = () => wrapper.findByTestId('row-lastsync');
const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
const hasClass = (className) => (w) => w.classes(className);
const isNotLastSyncRow = (w) => w.attributes('data-testid') !== 'row-lastsync';
const createComponent = (details = licenseDetails) => {
wrapper = extendedWrapper(mount(SubscriptionDetailsTable, { propsData: { details } }));
const createComponent = (props) => {
wrapper = extendedWrapper(
mount(SubscriptionDetailsTable, { propsData: { details: licenseDetails, ...props } }),
);
};
afterEach(() => {
......@@ -43,8 +50,8 @@ describe('Subscription Details Table', () => {
});
it('displays the correct content for rows', () => {
expect(findLabelCells().at(0).text()).toBe('Row label 1:');
expect(findContentCells().at(0).text()).toBe('row content 1');
expect(findLabelCells().at(0).text()).toBe(`${detailsLabels.expiresAt}:`);
expect(findContentCells().at(0).text()).toBe(licenseDetails[0].value);
});
it('displays the labels in bold', () => {
......@@ -54,17 +61,22 @@ describe('Subscription Details Table', () => {
it('does not show a clipboard button', () => {
expect(findClipboardButton().exists()).toBe(false);
});
it('shows the default row color', () => {
expect(findLastSyncRow().classes('gl-text-gray-800')).toBe(true);
});
});
describe('with copy-able detail', () => {
beforeEach(() => {
createComponent([
{
label: 'label',
value: 'Something to copy',
canCopy: true,
},
]);
createComponent({
details: [
{
detail: 'id',
value: 13,
},
],
});
});
it('shows a clipboard button', () => {
......@@ -72,13 +84,37 @@ describe('Subscription Details Table', () => {
});
it('passes the text to the clipboard', () => {
expect(findClipboardButton().props('text')).toBe('Something to copy');
expect(findClipboardButton().props('text')).toBe('13');
});
});
describe('subscription sync state', () => {
it('when the sync succeeded', () => {
createComponent({ syncDidFail: false });
expect(findLastSyncRow().classes('gl-text-gray-800')).toBe(true);
});
describe('when the sync failed', () => {
beforeEach(() => {
createComponent({ syncDidFail: true });
});
it('shows the highlighted color for the last sync row', () => {
expect(findLastSyncRow().classes('gl-text-red-500')).toBe(true);
});
it('shows the default row color for all other rows', () => {
const allButLastSync = findAllRows().wrappers.filter(isNotLastSyncRow);
expect(allButLastSync.every(hasClass('gl-text-gray-800'))).toBe(true);
});
});
});
describe('with no content', () => {
it('displays a loader', () => {
createComponent([]);
createComponent({ details: [] });
expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
});
......
......@@ -6,7 +6,7 @@ import SubscriptionSyncNotifications, {
import {
connectivityIssue,
manualSyncSuccessfulTitle,
notificationType,
subscriptionSyncStatus,
} from 'ee/pages/admin/cloud_licenses/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
......@@ -23,7 +23,10 @@ describe('Subscription Sync Notifications', () => {
const createComponent = ({ props, stubs } = {}) => {
wrapper = extendedWrapper(
shallowMount(SubscriptionSyncNotifications, {
propsData: props,
propsData: {
syncStatus: '',
...props,
},
provide: { connectivityHelpURL },
stubs,
}),
......@@ -45,7 +48,7 @@ describe('Subscription Sync Notifications', () => {
describe('sync success notification', () => {
beforeEach(() => {
createComponent({
props: { notification: notificationType.SYNC_SUCCESS },
props: { syncStatus: subscriptionSyncStatus.SYNC_SUCCESS },
});
});
......@@ -63,7 +66,7 @@ describe('Subscription Sync Notifications', () => {
describe('sync failure notification', () => {
beforeEach(() => {
createComponent({
props: { notification: notificationType.SYNC_FAILURE },
props: { syncStatus: subscriptionSyncStatus.SYNC_FAILURE },
stubs: { GlSprintf },
});
});
......
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