Merge pull request #22658 from mattermost/MM-50966-in-product-expansion

MM-50365 - In Product Expansion Front-End
Этот коммит содержится в:
Conor Macpherson
2023-04-20 10:35:41 -04:00
коммит произвёл GitHub
родитель 87555aa242 4a77773774
Коммит 3795f5a470
45 изменённых файлов: 2411 добавлений и 53 удалений

Просмотреть файл

@@ -6,7 +6,7 @@ import {Stripe} from '@stripe/stripe-js';
import {getCode} from 'country-list';
import {CreateSubscriptionRequest} from '@mattermost/types/cloud';
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
import {SelfHostedExpansionRequest, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
import {ValueOf} from '@mattermost/types/utilities';
import {Client4} from 'mattermost-redux/client';
@@ -198,3 +198,89 @@ export function getTrueUpReviewStatus(): ActionFunc {
onRequest: HostedCustomerTypes.TRUE_UP_REVIEW_STATUS_REQUEST,
});
}
export function confirmSelfHostedExpansion(
stripe: Stripe,
stripeSetupIntent: StripeSetupIntent,
isDevMode: boolean,
billingDetails: BillingDetails,
initialProgress: ValueOf<typeof SelfHostedSignupProgress>,
expansionRequest: SelfHostedExpansionRequest,
): ActionFunc {
return async (dispatch: DispatchFunc) => {
const cardSetupFunction = getConfirmCardSetup(isDevMode);
const confirmCardSetup = cardSetupFunction(stripe.confirmCardSetup);
const shouldConfirmCard = selfHostedNeedsConfirmation(initialProgress);
if (shouldConfirmCard) {
const result = await confirmCardSetup(
stripeSetupIntent.client_secret,
{
payment_method: {
card: billingDetails.card,
billing_details: {
name: billingDetails.name,
address: {
line1: billingDetails.address,
line2: billingDetails.address2,
city: billingDetails.city,
state: billingDetails.state,
country: getCode(billingDetails.country),
postal_code: billingDetails.postalCode,
},
},
},
},
);
if (!result) {
return {data: false, error: 'failed to confirm card with Stripe'};
}
const {setupIntent, error: stripeError} = result;
if (stripeError) {
if (stripeError.code === STRIPE_UNEXPECTED_STATE && stripeError.message === STRIPE_ALREADY_SUCCEEDED && stripeError.setup_intent?.status === 'succeeded') {
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: SelfHostedSignupProgress.CONFIRMED_INTENT,
});
} else {
return {data: false, error: stripeError.message || 'Stripe failed to confirm payment method'};
}
} else {
if (setupIntent === null || setupIntent === undefined) {
return {data: false, error: 'Stripe did not return successful setup intent'};
}
if (setupIntent.status !== 'succeeded') {
return {data: false, error: `Stripe setup intent status was: ${setupIntent.status}`};
}
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: SelfHostedSignupProgress.CONFIRMED_INTENT,
});
}
}
let confirmResult;
try {
confirmResult = await Client4.confirmSelfHostedExpansion(stripeSetupIntent.id, expansionRequest);
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: confirmResult.progress,
});
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
// unprocessable entity, e.g. failed export compliance
if (error.status_code === 422) {
return {data: false, error: error.status_code};
}
return {data: false, error};
}
return {data: confirmResult.license};
};
}

Просмотреть файл

@@ -104,7 +104,7 @@
.license-details-top {
display: flex;
justify-content: flex-start;
justify-content: space-between;
font-size: 14px;
font-weight: 700;
line-height: 24px;
@@ -114,10 +114,11 @@
color: #3f4350;
}
span.expiration-days {
margin-left: auto;
color: var(--denim-status-online);
.add-seats-button {
border-radius: 4px;
font-family: 'Open Sans', sans-serif;
font-size: 12px;
font-weight: 600;
}
}
@@ -157,6 +158,20 @@
font-weight: 600;
}
}
span.expiration-days {
margin-left: 8px;
font-size: 14px;
font-weight: 600;
&-warning {
color: var(--sys-away-indicator);
}
&-danger {
color: var(--dnd-indicator);
}
}
}
.add-new-licence-btn {

Просмотреть файл

@@ -6,17 +6,31 @@ import {screen} from '@testing-library/react';
import {Provider} from 'react-redux';
import moment from 'moment-timezone';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import {renderWithIntl} from 'tests/react_testing_utils';
import {OverActiveUserLimits} from 'utils/constants';
import {OverActiveUserLimits, SelfHostedProducts} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
import {General} from 'mattermost-redux/constants';
import {DeepPartial} from '@mattermost/types/utilities';
import {GlobalState} from '@mattermost/types/store';
import mockStore from 'tests/test_store';
import * as useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
import EnterpriseEditionLeftPanel, {EnterpriseEditionProps} from './enterprise_edition_left_panel';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom') as typeof import('react-router-dom'),
useLocation: () => {
return {
pathname: '',
};
},
}));
describe('components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel', () => {
const license = {
IsLicensed: 'true',
@@ -26,7 +40,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
SkuShortName: 'Enterprise',
Name: 'LicenseName',
Company: 'Mattermost Inc.',
Users: '1000000',
Users: '1000',
};
const initialState: DeepPartial<GlobalState> = {
@@ -45,10 +59,36 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
},
general: {
license,
config: {
BuildEnterpriseReady: 'true',
},
},
preferences: {
myPreferences: {},
},
admin: {
config: {
ServiceSettings: {
SelfHostedPurchase: true,
},
},
},
cloud: {
subscription: undefined,
},
hostedCustomer: {
products: {
products: {
prod_professional: TestHelper.getProductMock({
id: 'prod_professional',
name: 'Professional',
sku: SelfHostedProducts.PROFESSIONAL,
price_per_seat: 7.5,
}),
},
productsLoaded: true,
},
},
},
};
@@ -80,12 +120,12 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
const item = wrapper.find('.item-element').filterWhere((n) => {
return n.children().length === 2 &&
n.childAt(0).type() === 'span' &&
!n.childAt(0).text().includes('ACTIVE') &&
n.childAt(0).text().includes('LICENSED SEATS');
n.childAt(0).type() === 'span' &&
!n.childAt(0).text().includes('ACTIVE') &&
n.childAt(0).text().includes('LICENSED SEATS');
});
expect(item.text()).toContain('1,000,000');
expect(item.text()).toContain('1,000');
});
test('should not add any class if active users is lower than the minimal', async () => {
@@ -146,4 +186,47 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
expect(screen.getByText('ACTIVE USERS:')).not.toHaveClass('legend--warning-over-seats-purchased');
expect(screen.getByText('ACTIVE USERS:')).toHaveClass('legend--over-seats-purchased');
});
test('should add warning class to days expired indicator when there are more than 5 days until expiry', async () => {
license.ExpiresAt = moment().add(6, 'days').valueOf().toString();
const store = await mockStore(initialState);
renderWithIntl(
<Provider store={store}>
<EnterpriseEditionLeftPanel
{...props}
/>
</Provider>,
);
expect(screen.getByText('Expires in 6 days')).toHaveClass('expiration-days-warning');
});
test('should add danger class to days expired indicator when there are at least 5 days until expiry', async () => {
license.ExpiresAt = moment().add(5, 'days').valueOf().toString();
const store = await mockStore(initialState);
renderWithIntl(
<Provider store={store}>
<EnterpriseEditionLeftPanel
{...props}
/>
</Provider>,
);
expect(screen.getByText('Expires in 5 days')).toHaveClass('expiration-days-danger');
});
test('should display add seats button when there are more than 60 days until expiry and self hosted expansion is available', async () => {
license.ExpiresAt = moment().add(61, 'days').valueOf().toString();
const store = await mockStore(initialState);
jest.spyOn(useCanSelfHostedExpand, 'default').mockImplementation(() => true);
renderWithIntl(
<Provider store={store}>
<EnterpriseEditionLeftPanel
{...props}
/>
</Provider>,
);
expect(screen.getByText('+ Add seats')).toBeVisible();
});
});

Просмотреть файл

@@ -4,6 +4,7 @@
import React, {RefObject, useEffect, useState} from 'react';
import classNames from 'classnames';
import {FormattedDate, FormattedMessage, FormattedNumber, FormattedTime, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import Tag from 'components/widgets/tag/tag';
@@ -15,9 +16,17 @@ import {getRemainingDaysFromFutureTimestamp, toTitleCase} from 'utils/utils';
import {FileTypes} from 'utils/constants';
import {getSkuDisplayName} from 'utils/subscription';
import {calculateOverageUserActivated} from 'utils/overage_team';
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
import './enterprise_edition.scss';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
import {getExpandSeatsLink} from 'selectors/cloud';
import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal';
import {useQuery} from 'utils/http_utils';
const DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD = 30;
const DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD = 5;
export interface EnterpriseEditionProps {
openEELicenseModal: () => void;
@@ -47,10 +56,23 @@ const EnterpriseEditionLeftPanel = ({
const {formatMessage} = useIntl();
const [unsanitizedLicense, setUnsanitizedLicense] = useState(license);
const openPricingModal = useOpenPricingModal();
const canExpand = useCanSelfHostedExpand();
const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'});
const expandableLink = useSelector(getExpandSeatsLink);
const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
const query = useQuery();
const actionQueryParam = query.get('action');
useEffect(() => {
if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedPurchaseEnabled) {
selfHostedExpansionModal.open();
query.set('action', '');
}
}, []);
useEffect(() => {
async function fetchUnSanitizedLicense() {
// This solves this the issue reported here: https://mattermost.atlassian.net/browse/MM-42906
try {
const unsanitizedL = await Client4.getClientLicenseOld();
setUnsanitizedLicense(unsanitizedL);
@@ -77,6 +99,14 @@ const EnterpriseEditionLeftPanel = ({
</button>
);
const handleClickAddSeats = () => {
if (!isSelfHostedPurchaseEnabled || !canExpand) {
window.open(expandableLink(unsanitizedLicense.Id), '_blank');
} else {
selfHostedExpansionModal.open();
}
};
return (
<div
className='EnterpriseEditionLeftPanel'
@@ -117,10 +147,16 @@ const EnterpriseEditionLeftPanel = ({
<div className='licenseInformation'>
<div className='license-details-top'>
<span className='title'>{'License details'}</span>
{(expirationDays <= 30) &&
<span className='expiration-days'>
{`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`}
</span>
{canExpand &&
<button
className='add-seats-button btn btn-primary'
onClick={handleClickAddSeats}
>
<FormattedMessage
id={'admin.license.enterpriseEdition.add.seats'}
defaultMessage='+ Add seats'
/>
</button>
}
</div>
{
@@ -134,6 +170,7 @@ const EnterpriseEditionLeftPanel = ({
fileInputRef,
handleChange,
statsActiveUsers,
expirationDays,
)
}
</div>
@@ -162,7 +199,7 @@ const EnterpriseEditionLeftPanel = ({
type LegendValues = 'START DATE:' | 'EXPIRES:' | 'LICENSED SEATS:' | 'ACTIVE USERS:' | 'EDITION:' | 'LICENSE ISSUED:' | 'NAME:' | 'COMPANY / ORG:'
const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({legend, value}: {legend: LegendValues; value: string | JSX.Element | null}, index: number): React.ReactNode => {
const renderLicenseValues = (activeUsers: number, seatsPurchased: number, expirationDays: number) => ({legend, value}: {legend: LegendValues; value: string | JSX.Element | null}, index: number): React.ReactNode => {
if (legend === 'ACTIVE USERS:') {
const {isBetween5PercerntAnd10PercentPurchasedSeats, isOver10PercerntPurchasedSeats} = calculateOverageUserActivated({activeUsers, seatsPurchased});
return (
@@ -186,6 +223,26 @@ const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({l
>{value}</span>
</div>
);
} else if (legend === 'EXPIRES:') {
return (
<div
className='item-element'
key={value + index.toString()}
>
<span className='legend'>{legend}</span>
<span className='value'>{value}</span>
{(expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD) &&
<span
className={classNames('expiration-days', {
'expiration-days-warning': expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD,
'expiration-days-danger': expirationDays <= DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD,
})}
>
{`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`}
</span>
}
</div>
);
}
return (
@@ -209,6 +266,7 @@ const renderLicenseContent = (
fileInputRef: RefObject<HTMLInputElement>,
handleChange: () => void,
statsActiveUsers: number,
expirationDays: number,
) => {
// Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English.
@@ -246,7 +304,7 @@ const renderLicenseContent = (
return (
<div className='licenseElements'>
{licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10)))}
{licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10), expirationDays))}
<hr/>
{renderAddNewLicenseButton(fileInputRef, handleChange)}
{renderRemoveButton(handleRemove, isDisabled, removing)}

Просмотреть файл

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {useSelector} from 'react-redux';
import {Client4} from 'mattermost-redux/client';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
import {BillingSchemes, SelfHostedProducts} from 'utils/constants';
import {isCloudLicense} from 'utils/license_utils';
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
import useGetSelfHostedProducts from './useGetSelfHostedProducts';
export default function useCanSelfHostedExpand() {
// NOTE: This is a basic implementation to get things up and running, more details to come later.
const [expansionAvailable, setExpansionAvailable] = useState(false);
const config = useSelector(getConfig);
const isEnterpriseReady = config.BuildEnterpriseReady === 'true';
const isSalesServeOnly = useSelector(getSubscriptionProduct)?.billing_scheme === BillingSchemes.SALES_SERVE;
const license = useSelector(getLicense);
const isCloud = isCloudLicense(license);
const [products] = useGetSelfHostedProducts();
const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName);
// Self Hosted Products never contains a product for starter, additional check is done out of caution.
const isSelfHostedStarter = currentProduct === null || currentProduct?.sku === SelfHostedProducts.STARTER;
useEffect(() => {
if (!isEnterpriseReady) {
return;
}
Client4.getLicenseSelfServeStatus().
then((res) => {
setExpansionAvailable(res.is_expandable ?? false);
}).
catch(() => {
setExpansionAvailable(false);
});
}, [isEnterpriseReady]);
return !isCloud && !isSelfHostedStarter && !isSalesServeOnly && expansionAvailable;
}

Просмотреть файл

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useMemo} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {trackEvent} from 'actions/telemetry_actions';
import {openModal} from 'actions/views/modals';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
import PurchaseInProgressModal from 'components/purchase_in_progress_modal';
import {Client4} from 'mattermost-redux/client';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_purchases/constants';
import SelfHostedExpansionModal from 'components/self_hosted_purchases/self_hosted_expansion_modal';
import {useControlModal, ControlModal} from './useControlModal';
import useCanSelfHostedExpand from './useCanSelfHostedExpand';
interface HookOptions{
trackingLocation?: string;
}
export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal {
const dispatch = useDispatch();
const currentUser = useSelector(getCurrentUser);
const canExpand = useCanSelfHostedExpand();
const controlModal = useControlModal({
modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION,
dialogType: SelfHostedExpansionModal,
});
return useMemo(() => {
return {
...controlModal,
open: async () => {
if (!canExpand) {
return;
}
const purchaseInProgress = localStorage.getItem(STORAGE_KEY_EXPANSION_IN_PROGRESS) === 'true';
// check if user already has an open purchase modal in current browser.
if (purchaseInProgress) {
// User within the same browser session
// is already trying to purchase. Notify them of this
// and request the exit that purchase flow before attempting again.
dispatch(openModal({
modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS,
dialogType: PurchaseInProgressModal,
dialogProps: {
purchaserEmail: currentUser.email,
storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS,
},
}));
return;
}
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'click_open_expansion_modal', {
callerInfo: options.trackingLocation,
});
try {
const result = await Client4.bootstrapSelfHostedSignup();
if (result.email !== currentUser.email) {
// Token already exists and was created by another admin.
// Notify user of this and do not allow them to try to expand concurrently.
dispatch(openModal({
modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS,
dialogType: PurchaseInProgressModal,
dialogProps: {
purchaserEmail: result.email,
storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS,
},
}));
return;
}
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: result.progress,
});
controlModal.open();
} catch (e) {
// eslint-disable-next-line no-console
console.error('error bootstrapping self hosted purchase modal', e);
}
},
};
}, [controlModal, options.trackingLocation]);
}

Просмотреть файл

@@ -7,8 +7,8 @@ import {useDispatch, useSelector} from 'react-redux';
import {trackEvent} from 'actions/telemetry_actions';
import {closeModal, openModal} from 'actions/views/modals';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
import SelfHostedPurchaseModal from 'components/self_hosted_purchase_modal';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchase_modal/constants';
import SelfHostedPurchaseModal from 'components/self_hosted_purchases/self_hosted_purchase_modal';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchases/constants';
import PurchaseInProgressModal from 'components/purchase_in_progress_modal';
import {Client4} from 'mattermost-redux/client';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
@@ -63,6 +63,7 @@ export default function useControlSelfHostedPurchaseModal(options: HookOptions):
dialogType: PurchaseInProgressModal,
dialogProps: {
purchaserEmail: currentUser.email,
storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS,
},
}));
return;
@@ -86,6 +87,7 @@ export default function useControlSelfHostedPurchaseModal(options: HookOptions):
dialogType: PurchaseInProgressModal,
dialogProps: {
purchaserEmail: result.email,
storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS,
},
}));
return;

Просмотреть файл

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {OutlinedInput as MUIOutlineInput, OutlinedInputProps} from '@mui/material';
/**
* A horizontal separator for use in menus.
* @example
* <OutlineInput
* data-testid='my-input'
* size='small|medium
* value=10
* onChange={myChangeHandler}
* error=true
* disabled=false
* />
*/
export function OutlinedInput(props: OutlinedInputProps) {
return (
<MUIOutlineInput
{...props}
/>
);
}

Просмотреть файл

@@ -11,6 +11,8 @@ import {GlobalState} from 'types/store';
import {TestHelper as TH} from 'utils/test_helper';
import {Client4} from 'mattermost-redux/client';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchases/constants';
import PurchaseInProgressModal from './';
jest.mock('mattermost-redux/client', () => {
@@ -56,13 +58,27 @@ describe('PurchaseInProgressModal', () => {
it('when purchaser and user emails are different, user is instructed to wait', () => {
const stateOverride: DeepPartial<GlobalState> = JSON.parse(JSON.stringify(initialState));
stateOverride.entities!.users!.currentUserId = 'otherUserId';
renderWithIntlAndStore(<div id='root-portal'><PurchaseInProgressModal purchaserEmail={'admin@example.com'}/></div>, stateOverride);
renderWithIntlAndStore(
<div id='root-portal'>
<PurchaseInProgressModal
purchaserEmail={'admin@example.com'}
storageKey={STORAGE_KEY_PURCHASE_IN_PROGRESS}
/>
</div>, stateOverride,
);
screen.getByText('@UserAdmin is currently attempting to purchase a paid license.');
});
it('when purchaser and user emails are same, allows user to reset purchase flow', () => {
renderWithIntlAndStore(<div id='root-portal'><PurchaseInProgressModal purchaserEmail={'admin@example.com'}/></div>, initialState);
renderWithIntlAndStore(
<div id='root-portal'>
<PurchaseInProgressModal
purchaserEmail={'admin@example.com'}
storageKey={STORAGE_KEY_PURCHASE_IN_PROGRESS}
/>
</div>, initialState,
);
expect(Client4.bootstrapSelfHostedSignup).not.toHaveBeenCalled();
screen.getByText('Reset purchase flow').click();

Просмотреть файл

@@ -13,13 +13,13 @@ import {Client4} from 'mattermost-redux/client';
import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg';
import {useControlPurchaseInProgressModal} from 'components/common/hooks/useControlModal';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchase_modal/constants';
import './index.scss';
import {GlobalState} from '@mattermost/types/store';
interface Props {
purchaserEmail: string;
storageKey: string;
}
export default function PurchaseInProgressModal(props: Props) {
@@ -64,7 +64,7 @@ export default function PurchaseInProgressModal(props: Props) {
);
genericModalProps.handleConfirm = () => {
localStorage.removeItem(STORAGE_KEY_PURCHASE_IN_PROGRESS);
localStorage.removeItem(props.storageKey);
Client4.bootstrapSelfHostedSignup(true);
close();
};

Просмотреть файл

@@ -2,3 +2,4 @@
// See LICENSE.txt for license information.
export const STORAGE_KEY_PURCHASE_IN_PROGRESS = 'PURCHASE_IN_PROGRESS';
export const STORAGE_KEY_EXPANSION_IN_PROGRESS = 'EXPANSION_IN_PROGRESS';

Просмотреть файл

@@ -0,0 +1,3 @@
.self_hosted_expansion_failed {
margin-top: 163px;
}

Просмотреть файл

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm';
import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg';
import IconMessage from 'components/purchase_modal/icon_message';
import './error_page.scss';
interface Props {
canRetry: boolean;
tryAgain: () => void;
}
export default function SelfHostedExpansionErrorPage(props: Props) {
const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error');
const formattedTitle = (
<FormattedMessage
id='admin.billing.subscription.paymentVerificationFailed'
defaultMessage='Sorry, the payment verification failed'
/>
);
let formattedButtonText = (
<FormattedMessage
id='self_hosted_expansion.try_again'
defaultMessage='Try again'
/>
);
if (!props.canRetry) {
formattedButtonText = (
<FormattedMessage
id='self_hosted_expansion.close'
defaultMessage='Close'
/>
);
}
const formattedSubtitle = (
<FormattedMessage
id='self_hosted_expansion.paymentFailed'
defaultMessage='Payment failed. Please try again or contact support.'
/>
);
const tertiaryButtonText = (
<FormattedMessage
id='self_hosted_expansion.contact_support'
defaultMessage={'Contact Support'}
/>
);
const icon = (
<PaymentFailedSvg
width={444}
height={313}
/>
);
return (
<div className='self_hosted_expansion_failed'>
<IconMessage
formattedTitle={formattedTitle}
formattedSubtitle={formattedSubtitle}
icon={icon}
error={true}
formattedButtonText={formattedButtonText}
buttonHandler={props.tryAgain}
formattedTertiaryButonText={tertiaryButtonText}
tertiaryButtonHandler={() => window.open(contactSupportLink, '_blank', 'noreferrer')}
/>
</div>
);
}

Просмотреть файл

@@ -0,0 +1,131 @@
.SelfHostedExpansionRHSCard {
display: flex;
width: 280px;
flex-direction: column;
&__Content {
padding: 24px;
border: 1px solid;
border-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16);
border-radius: 4px;
}
&__RHSCardTitle {
display: block;
margin-bottom: 12px;
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
font-size: 14px;
font-weight: 600;
text-align: center;
text-transform: capitalize;
}
.seatsInput {
width: 73px;
margin-left: auto;
font-size: 14px;
font-weight: 400;
input[type="number"] {
text-align: right;
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
margin: 0;
-webkit-appearance: none;
}
}
&__PlanDetails {
display: flex;
flex-direction: column;
text-align: center;
.planName {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
font-size: 20px;
font-weight: 400;
text-transform: capitalize;
}
.usage {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.56);
font-size: 12px;
font-weight: 600;
:first-child {
text-transform: uppercase;
}
}
}
hr {
width: 90%;
height: 2px;
background-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16);
}
&__seatInput,
&__cost_breakdown {
display: grid;
font-weight: 400;
gap: 10px;
grid-template-columns: repeat(2, 1fr);
.costPerUser > span:first-child {
font-size: 14px;
}
.costPerUser > span:last-child {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
font-size: 12px;
}
.totalCostWarning {
width: 141px;
}
.totalCostWarning > span:first-child {
color: var(--sys-denim-center-channel-text);
font-size: 14px;
font-weight: 700;
}
.totalCostWarning > span:last-child {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
font-size: 12px;
}
.costAmount {
width: 100%;
margin-right: 0;
margin-left: auto;
font-weight: 700;
}
}
&__AddSeatsWarning {
display: block;
width: 100%;
height: 35px;
margin-bottom: 15px;
color: var(--dnd-indicator);
font-size: 12px;
font-weight: 600;
text-align: right;
}
&__CompletePurchaseButton {
width: 100%;
margin-top: 10px;
margin-bottom: 10px;
border-radius: 4px;
}
&__ChargedTodayDisclaimer {
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
font-size: 12px;
font-weight: 400;
}
}

Просмотреть файл

@@ -0,0 +1,244 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import moment from 'moment-timezone';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import WarningIcon from 'components/widgets/icons/fa_warning_icon';
import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts';
import ExternalLink from 'components/external_link';
import {OutlinedInput} from 'components/outlined_input';
import {DocLinks} from 'utils/constants';
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
import './expansion_card.scss';
const MONTHS_IN_YEAR = 12;
const MAX_TRANSACTION_VALUE = 1_000_000 - 1;
interface Props {
canSubmit: boolean;
licensedSeats: number;
minimumSeats: number;
submit: () => void;
updateSeats: (seats: number) => void;
}
export default function SelfHostedExpansionCard(props: Props) {
const intl = useIntl();
const license = useSelector(getLicense);
const startsAt = moment(parseInt(license.StartsAt, 10)).format('MMM. D, YYYY');
const endsAt = moment(parseInt(license.ExpiresAt, 10)).format('MMM. D, YYYY');
const [additionalSeats, setAdditionalSeats] = useState(props.minimumSeats);
const [overMaxSeats, setOverMaxSeats] = useState(false);
const licenseExpiry = new Date(parseInt(license.ExpiresAt, 10));
const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats) || additionalSeats < props.minimumSeats;
const [products] = useGetSelfHostedProducts();
const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName);
const costPerMonth = currentProduct?.price_per_seat || 0;
const getMonthsUntilExpiry = () => {
const now = new Date();
return (licenseExpiry.getMonth() - now.getMonth()) + (MONTHS_IN_YEAR * (licenseExpiry.getFullYear() - now.getFullYear()));
};
const getCostPerUser = () => {
const monthsUntilExpiry = getMonthsUntilExpiry();
return costPerMonth * monthsUntilExpiry;
};
const getPaymentTotal = () => {
if (isNaN(additionalSeats)) {
return 0;
}
const monthsUntilExpiry = getMonthsUntilExpiry();
return additionalSeats * costPerMonth * monthsUntilExpiry;
};
// Finds the maximum number of additional seats that is possible, taking into account
// the stripe transaction limit. The maximum number of seats will follow the formula:
// (StripeTransaction Limit - (current_seats * yearly_price_per_seat)) / yearly_price_per_seat
const getMaximumAdditionalSeats = () => {
if (currentProduct === null) {
return 0;
}
const currentPaymentPrice = costPerMonth * props.licensedSeats * 12;
const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice;
const remainingSeats = Math.floor(remainingTransactionLimit / (costPerMonth * 12));
return Math.max(0, remainingSeats);
};
const maxAdditionalSeats = getMaximumAdditionalSeats();
const handleNewSeatsInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const requestedSeats = parseInt(e.target.value, 10);
if (!isNaN(requestedSeats) && requestedSeats <= 0) {
e.preventDefault();
return;
}
setOverMaxSeats(false);
const overMaxAdditionalSeats = requestedSeats > maxAdditionalSeats;
setOverMaxSeats(overMaxAdditionalSeats);
const finalSeatCount = overMaxAdditionalSeats ? maxAdditionalSeats : requestedSeats;
setAdditionalSeats(finalSeatCount);
props.updateSeats(finalSeatCount);
};
const formatCurrency = (value: number) => {
return intl.formatNumber(value, {style: 'currency', currency: 'USD'});
};
return (
<div className='SelfHostedExpansionRHSCard'>
<div className='SelfHostedExpansionRHSCard__RHSCardTitle'>
<FormattedMessage
id='self_hosted_expansion_rhs_license_summary_title'
defaultMessage='License Summary'
/>
</div>
<div className='SelfHostedExpansionRHSCard__Content'>
<div className='SelfHostedExpansionRHSCard__PlanDetails'>
<span className='planName'>{license.SkuShortName}</span>
<div className='usage'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_license_date'
defaultMessage='{startsAt} - {endsAt}'
values={{
startsAt,
endsAt,
}}
/>
<br/>
<FormattedMessage
id='self_hosted_expansion_rhs_card_licensed_seats'
defaultMessage='{licensedSeats} LICENSES SEATS'
values={{
licensedSeats: props.licensedSeats,
}}
/>
</div>
</div>
<hr/>
<div className='SelfHostedExpansionRHSCard__seatInput'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_add_new_seats'
defaultMessage='Add new seats'
/>
<OutlinedInput
data-testid='seatsInput'
className='seatsInput'
size='small'
type='number'
value={additionalSeats}
onChange={handleNewSeatsInputChange}
disabled={maxAdditionalSeats === 0}
/>
</div>
<div className='SelfHostedExpansionRHSCard__AddSeatsWarning'>
{invalidAdditionalSeats && !overMaxSeats && isNaN(additionalSeats) &&
<FormattedMessage
id='self_hosted_expansion_rhs_card_must_add_seats_warning'
defaultMessage='{warningIcon} You must add a seat to continue'
values={{
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
}}
/>
}
{invalidAdditionalSeats && additionalSeats < props.minimumSeats &&
<FormattedMessage
id='self_hosted_expansion_rhs_card_must_purchase_enough_seats'
defaultMessage='{warningIcon} You must purchase at least {minimumSeats} seats to be compliant with your license'
values={{
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
minimumSeats: props.minimumSeats,
}}
/>
}
{overMaxSeats && maxAdditionalSeats > 0 &&
<FormattedMessage
id='self_hosted_expansion_rhs_card_maximum_seats_warning'
defaultMessage='{warningIcon} You may only expand by an additional {maxAdditionalSeats} seats'
values={{
maxAdditionalSeats,
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
}}
/>
}
</div>
<div className='SelfHostedExpansionRHSCard__cost_breakdown'>
<div className='costPerUser'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_cost_per_user_title'
defaultMessage='Cost per user'
/>
<br/>
<FormattedMessage
id='self_hosted_expansion_rhs_card_cost_per_user_breakdown'
/* eslint-disable no-template-curly-in-string*/
defaultMessage='{costPerUser} x {monthsUntilExpiry} months'
values={{
costPerUser: formatCurrency(costPerMonth),
monthsUntilExpiry: getMonthsUntilExpiry(),
}}
/>
</div>
<div className='costAmount'>
<span>{formatCurrency(getCostPerUser())}</span>
</div>
<div className='totalCostWarning'>
<FormattedMessage
id='self_hosted_expansion_rhs_card_total_title'
defaultMessage='Total'
/>
<br/>
<FormattedMessage
id='self_hosted_expansion_rhs_card_total_prorated_warning'
defaultMessage='The total will be prorated'
/>
</div>
<span className='totalCostAmount'>
<span>{formatCurrency(getPaymentTotal()) }</span>
</span>
</div>
<button
className='btn btn-primary SelfHostedExpansionRHSCard__CompletePurchaseButton'
disabled={!props.canSubmit || maxAdditionalSeats === 0 || invalidAdditionalSeats}
onClick={props.submit}
>
<FormattedMessage
id='self_hosted_expansion_rhs_complete_button'
defaultMessage='Complete purchase'
/>
</button>
<div className='SelfHostedExpansionRHSCard__ChargedTodayDisclaimer'>
<FormattedMessage
id='self_hosted_expansion_rhs_credit_card_charge_today_warning'
defaultMessage='Your credit card will be charged today.<see_how_billing_works>See how billing works.</see_how_billing_works>'
values={{
see_how_billing_works: (text: string) => (
<>
<br/>
<ExternalLink
href={DocLinks.SELF_HOSTED_BILLING}
>
{text}
</ExternalLink>
</>
),
}}
/>
</div>
</div>
</div>
);
}

Просмотреть файл

@@ -0,0 +1,494 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {screen, fireEvent, waitFor} from '@testing-library/react';
import {GlobalState} from 'types/store';
import {SelfHostedSignupForm, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
import {TestHelper as TH} from 'utils/test_helper';
import {SelfHostedProducts, ModalIdentifiers, RecurringIntervals} from 'utils/constants';
import {DeepPartial} from '@mattermost/types/utilities';
import SelfHostedExpansionModal, {makeInitialState, canSubmit, FormState} from './';
import moment from 'moment-timezone';
interface MockCardInputProps {
onCardInputChange: (event: {complete: boolean}) => void;
forwardedRef: React.MutableRefObject<any>;
}
// number borrowed from stripe
const successCardNumber = '4242424242424242';
function MockCardInput(props: MockCardInputProps) {
props.forwardedRef.current = {
getCard: () => ({}),
};
return (
<input
placeholder='Card number'
type='text'
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === successCardNumber) {
props.onCardInputChange({complete: true});
}
}}
/>
);
}
jest.mock('components/payment_form/card_input', () => {
const original = jest.requireActual('components/payment_form/card_input');
return {
...original,
__esModule: true,
default: MockCardInput,
};
});
jest.mock('components/self_hosted_purchases/stripe_provider', () => {
return function(props: {children: React.ReactNode | React.ReactNodeArray}) {
return props.children;
};
});
jest.mock('components/common/hooks/useLoadStripe', () => {
return function() {
return {current: {
stripe: {},
}};
};
});
const mockCreatedIntent = SelfHostedSignupProgress.CREATED_INTENT;
const mockCreatedLicense = SelfHostedSignupProgress.CREATED_LICENSE;
const failOrg = 'failorg';
const existingUsers = 10;
const mockProfessionalProduct = TH.getProductMock({
id: 'prod_professional',
name: 'Professional',
sku: SelfHostedProducts.PROFESSIONAL,
price_per_seat: 7.5,
recurring_interval: RecurringIntervals.MONTH,
});
jest.mock('mattermost-redux/client', () => {
const original = jest.requireActual('mattermost-redux/client');
return {
__esModule: true,
...original,
Client4: {
...original.Client4,
pageVisited: jest.fn(),
setAcceptLanguage: jest.fn(),
trackEvent: jest.fn(),
createCustomerSelfHostedSignup: (form: SelfHostedSignupForm) => {
if (form.organization === failOrg) {
throw new Error('error creating customer');
}
return Promise.resolve({
progress: mockCreatedIntent,
});
},
confirmSelfHostedExpansion: () => Promise.resolve({
progress: mockCreatedLicense,
license: {Users: existingUsers * 2},
}),
},
};
});
jest.mock('components/payment_form/stripe', () => {
const original = jest.requireActual('components/payment_form/stripe');
return {
__esModule: true,
...original,
getConfirmCardSetup: () => () => () => ({setupIntent: {status: 'succeeded'}, error: null}),
};
});
jest.mock('utils/hosted_customer', () => {
const original = jest.requireActual('utils/hosted_customer');
return {
__esModule: true,
...original,
findSelfHostedProductBySku: () => {
return mockProfessionalProduct;
},
};
});
const productName = SelfHostedProducts.PROFESSIONAL;
// Licensed expiry set as 3 months from the current date (rolls over to new years).
let licenseExpiry = moment();
const monthsUntilLicenseExpiry = 3;
licenseExpiry = licenseExpiry.add(monthsUntilLicenseExpiry, 'months');
const initialState: DeepPartial<GlobalState> = {
views: {
modals: {
modalState: {
[ModalIdentifiers.SELF_HOSTED_EXPANSION]: {
open: true,
},
},
},
},
storage: {
storage: {},
},
entities: {
teams: {
currentTeamId: '',
},
preferences: {
myPreferences: {
theme: {},
},
},
general: {
config: {
EnableDeveloper: 'false',
},
license: {
SkuName: productName,
Sku: productName,
Users: '50',
ExpiresAt: licenseExpiry.valueOf().toString(),
},
},
cloud: {
subscription: {},
},
users: {
currentUserId: 'adminUserId',
profiles: {
adminUserId: TH.getUserMock({
id: 'adminUserId',
roles: 'admin',
first_name: 'first',
last_name: 'admin',
}),
otherUserId: TH.getUserMock({
id: 'otherUserId',
roles: '',
first_name: '',
last_name: '',
}),
},
filteredStats: {
total_users_count: 100,
},
},
hostedCustomer: {
products: {
productsLoaded: true,
products: {
prod_professional: mockProfessionalProduct,
},
},
signupProgress: SelfHostedSignupProgress.START,
},
},
};
const valueEvent = (value: any) => ({target: {value}});
function changeByPlaceholder(sel: string, val: any) {
fireEvent.change(screen.getByPlaceholderText(sel), valueEvent(val));
}
function selectDropdownValue(testId: string, value: string) {
fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value));
fireEvent.click(screen.getByTestId(testId).querySelector('.DropDown__option--is-focused') as any);
}
function changeByTestId(testId: string, value: string) {
fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value));
}
interface PurchaseForm {
card: string;
org: string;
name: string;
country: string;
address: string;
city: string;
state: string;
zip: string;
seats: string;
agree: boolean;
}
const defaultSuccessForm: PurchaseForm = {
card: successCardNumber,
org: 'My org',
name: 'The Cardholder',
country: 'United States of America',
address: '123 Main Street',
city: 'Minneapolis',
state: 'MN',
zip: '55423',
seats: '50',
agree: true,
};
function fillForm(form: PurchaseForm) {
changeByPlaceholder('Card number', form.card);
changeByPlaceholder('Organization Name', form.org);
changeByPlaceholder('Name on Card', form.name);
selectDropdownValue('selfHostedExpansionCountrySelector', form.country);
changeByPlaceholder('Address', form.address);
changeByPlaceholder('City', form.city);
selectDropdownValue('selfHostedExpansionStateSelector', form.state);
changeByPlaceholder('Zip/Postal Code', form.zip);
if (form.agree) {
fireEvent.click(screen.getByText('I have read and agree', {exact: false}));
}
const completeButton = screen.getByText('Complete purchase');
if (form === defaultSuccessForm) {
expect(completeButton).toBeEnabled();
}
return completeButton;
}
describe('SelfHostedExpansionModal Open', () => {
it('renders the form', () => {
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
screen.getByText('Provide your payment details');
screen.getByText('Add new seats');
screen.getByText('Contact Sales');
screen.getByText('Cost per user', {exact: false});
// screen.getByText(productName, {normalizer: (val) => {return val.charAt(0).toUpperCase() + val.slice(1)}});
screen.getByText('Your credit card will be charged today.');
screen.getByText('See how billing works', {exact: false});
});
it('filling the form enables expansion', () => {
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
expect(screen.getByText('Complete purchase')).toBeDisabled();
fillForm(defaultSuccessForm);
});
it('happy path submit shows success screen when confirmation succeeds', async () => {
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
expect(screen.getByText('Complete purchase')).toBeDisabled();
const upgradeButton = fillForm(defaultSuccessForm);
upgradeButton.click();
expect(screen.findByText('The license has been automatically applied')).toBeTruthy();
});
it('happy path submit shows submitting screen while requesting confirmation', async () => {
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
expect(screen.getByText('Complete purchase')).toBeDisabled();
const upgradeButton = fillForm(defaultSuccessForm);
upgradeButton.click();
await waitFor(() => expect(document.getElementsByClassName('submitting')[0]).toBeTruthy(), {timeout: 1234});
});
it('sad path submit shows error screen', async () => {
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
expect(screen.getByText('Complete purchase')).toBeDisabled();
fillForm(defaultSuccessForm);
changeByPlaceholder('Organization Name', failOrg);
const upgradeButton = screen.getByText('Complete purchase');
expect(upgradeButton).toBeEnabled();
upgradeButton.click();
await waitFor(() => expect(screen.getByText('Sorry, the payment verification failed')).toBeTruthy(), {timeout: 1234});
});
});
describe('SelfHostedExpansionModal RHS Card', () => {
it('New seats input should be pre-populated with the difference from the active users and licensed seats', () => {
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
const expectedPrePopulatedSeats = (initialState.entities?.users?.filteredStats?.total_users_count || 1) - parseInt(initialState.entities?.general?.license?.Users || '1', 10);
const seatsField = screen.getByTestId('seatsInput').querySelector('input');
expect(seatsField).toBeInTheDocument();
expect(seatsField?.value).toBe(expectedPrePopulatedSeats.toString());
});
it('Seat input only allows users to fill input with the licensed seats and active users difference if it is not 0', () => {
const expectedUserOverage = '50';
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
fillForm(defaultSuccessForm);
// The seat input should already have the expected value.
expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedUserOverage);
// Try to set an undefined value.
fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, undefined);
// Expecting the seats input to now contain the difference between active users and licensed seats.
expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedUserOverage);
expect(screen.getByText('Complete purchase')).toBeEnabled();
});
it('New seats input cannot be less than 1', () => {
if (initialState.entities?.users?.filteredStats?.total_users_count) {
initialState.entities.users.filteredStats.total_users_count = 50;
}
const expectedAddNewSeats = '1';
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
fillForm(defaultSuccessForm);
// Try to set a negative value.
fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, -10);
expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedAddNewSeats);
// Try to set a 0 value.
fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, 0);
expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedAddNewSeats);
});
it('Cost per User should be represented as the current subscription price multiplied by the remaining months', () => {
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
const expectedCostPerUser = monthsUntilLicenseExpiry * mockProfessionalProduct.price_per_seat;
const costPerUser = document.getElementsByClassName('costPerUser')[0];
expect(costPerUser).toBeInTheDocument();
expect(costPerUser.innerHTML).toContain('Cost per user<br>$' + mockProfessionalProduct.price_per_seat.toFixed(2) + ' x ' + monthsUntilLicenseExpiry + ' months');
const costAmount = document.getElementsByClassName('costAmount')[0];
expect(costAmount).toBeInTheDocument();
expect(costAmount.innerHTML).toContain('$' + expectedCostPerUser);
});
it('Total cost User should be represented as the current subscription price multiplied by the remaining months multiplied by the number of users', () => {
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
const seatsInputValue = 100;
changeByTestId('seatsInput', seatsInputValue.toString());
const expectedTotalCost = monthsUntilLicenseExpiry * mockProfessionalProduct.price_per_seat * seatsInputValue;
const costAmount = document.getElementsByClassName('totalCostAmount')[0];
expect(costAmount).toBeInTheDocument();
expect(costAmount).toHaveTextContent(Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(expectedTotalCost));
});
});
describe('SelfHostedExpansionModal Submit', () => {
function makeHappyPathState(): FormState {
return {
address: 'string',
address2: 'string',
city: 'string',
state: 'string',
country: 'string',
postalCode: '12345',
shippingAddress: 'string',
shippingAddress2: 'string',
shippingCity: 'string',
shippingState: 'string',
shippingCountry: 'string',
shippingPostalCode: '12345',
shippingSame: false,
agreedTerms: true,
cardName: 'string',
organization: 'string',
cardFilled: true,
seats: 1,
submitting: false,
succeeded: false,
progressBar: 0,
error: '',
};
}
it('if submitting, can not submit again', () => {
const state = makeHappyPathState();
state.submitting = true;
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(false);
});
it('if created license, can submit', () => {
const state = makeInitialState(1);
state.submitting = false;
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(true);
});
it('if paid, can submit', () => {
const state = makeInitialState(1);
state.submitting = false;
expect(canSubmit(state, SelfHostedSignupProgress.PAID)).toBe(true);
});
it('if created subscription, can submit', () => {
const state = makeInitialState(1);
state.submitting = false;
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_SUBSCRIPTION)).toBe(true);
});
it('if all details filled and card has not been confirmed, can submit', () => {
const state = makeHappyPathState();
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(true);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(true);
});
it('if card name missing and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.cardName = '';
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if card number missing and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.cardFilled = false;
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if address not filled and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.address = '';
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if seats not valid and card has not been confirmed, can not submit', () => {
const state = makeHappyPathState();
state.seats = 0;
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
});
it('if card confirmed, card not required for submission', () => {
const state = makeHappyPathState();
state.cardFilled = false;
state.cardName = '';
expect(canSubmit(state, SelfHostedSignupProgress.CONFIRMED_INTENT)).toBe(true);
});
it('if passed unknown progress status, can not submit', () => {
const state = makeHappyPathState();
expect(canSubmit(state, 'unknown status' as any)).toBe(false);
});
});

Просмотреть файл

@@ -0,0 +1,534 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useRef, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import classNames from 'classnames';
import {StripeCardElementChangeEvent} from '@stripe/stripe-js';
import {getLicenseConfig} from 'mattermost-redux/actions/general';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUser, getFilteredUsersStats} from 'mattermost-redux/selectors/entities/users';
import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer';
import {DispatchFunc} from 'mattermost-redux/types/actions';
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import {isDevModeEnabled} from 'selectors/general';
import {closeModal} from 'actions/views/modals';
import {pageVisited} from 'actions/telemetry_actions';
import {confirmSelfHostedExpansion} from 'actions/hosted_customer';
import {ValueOf} from '@mattermost/types/utilities';
import {SelfHostedSignupCustomerResponse, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
import RootPortal from 'components/root_portal';
import ContactSalesLink from 'components/self_hosted_purchases/contact_sales_link';
import ErrorPage from 'components/self_hosted_purchases/self_hosted_expansion_modal/error_page';
import SuccessPage from 'components/self_hosted_purchases/self_hosted_expansion_modal/success_page';
import useLoadStripe from 'components/common/hooks/useLoadStripe';
import CardInput, {CardInputType} from 'components/payment_form/card_input';
import FullScreenModal from 'components/widgets/modals/full_screen_modal';
import Input from 'components/widgets/inputs/input/input';
import BackgroundSvg from 'components/common/svg_images_components/background_svg';
import Terms from 'components/self_hosted_purchases/self_hosted_purchase_modal/terms';
import Address from 'components/self_hosted_purchases/address';
import ChooseDifferentShipping from 'components/choose_different_shipping';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
import {inferNames} from 'utils/hosted_customer';
import Submitting from './submitting';
import StripeProvider from '../stripe_provider';
import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from '../constants';
import SelfHostedExpansionCard from './expansion_card';
import './self_hosted_expansion_modal.scss';
export interface FormState {
cardName: string;
cardFilled: boolean;
address: string;
address2: string;
city: string;
state: string;
country: string;
postalCode: string;
organization: string;
seats: number;
shippingSame: boolean;
shippingAddress: string;
shippingAddress2: string;
shippingCity: string;
shippingState: string;
shippingCountry: string;
shippingPostalCode: string;
agreedTerms: boolean;
submitting: boolean;
succeeded: boolean;
progressBar: number;
error: string;
}
export function makeInitialState(seats: number): FormState {
return {
cardName: '',
cardFilled: false,
address: '',
address2: '',
city: '',
state: '',
country: '',
postalCode: '',
organization: '',
shippingSame: true,
shippingAddress: '',
shippingAddress2: '',
shippingCity: '',
shippingState: '',
shippingCountry: '',
shippingPostalCode: '',
seats,
agreedTerms: false,
submitting: false,
succeeded: false,
progressBar: 0,
error: '',
};
}
export function canSubmit(formState: FormState, progress: ValueOf<typeof SelfHostedSignupProgress>) {
if (formState.submitting) {
return false;
}
const validAddress = Boolean(
formState.organization &&
formState.address &&
formState.city &&
formState.state &&
formState.postalCode &&
formState.country,
);
const validShippingAddress = Boolean(
formState.shippingSame ||
(formState.shippingAddress &&
formState.shippingCity &&
formState.shippingState &&
formState.shippingPostalCode &&
formState.shippingCountry),
);
const agreedToTerms = formState.agreedTerms;
const validCard = Boolean(
formState.cardName &&
formState.cardFilled,
);
const validSeats = formState.seats > 0;
switch (progress) {
case SelfHostedSignupProgress.PAID:
case SelfHostedSignupProgress.CREATED_LICENSE:
case SelfHostedSignupProgress.CREATED_SUBSCRIPTION:
return true;
case SelfHostedSignupProgress.CONFIRMED_INTENT: {
return Boolean(
validAddress && validShippingAddress && validSeats && agreedToTerms,
);
}
case SelfHostedSignupProgress.START:
case SelfHostedSignupProgress.CREATED_CUSTOMER:
case SelfHostedSignupProgress.CREATED_INTENT:
return Boolean(
validCard &&
validAddress &&
validShippingAddress &&
validSeats &&
agreedToTerms,
);
default: {
return false;
}
}
}
export default function SelfHostedExpansionModal() {
const dispatch = useDispatch<DispatchFunc>();
const intl = useIntl();
const cardRef = useRef<CardInputType | null>(null);
const theme = useSelector(getTheme);
const progress = useSelector(getSelfHostedSignupProgress);
const user = useSelector(getCurrentUser);
const isDevMode = useSelector(isDevModeEnabled);
const license = useSelector(getLicense);
const licensedSeats = parseInt(license.Users, 10);
const currentPlan = license.SkuName;
const activeUsers = useSelector(getFilteredUsersStats)?.total_users_count || 0;
const [minimumSeats] = useState(activeUsers <= licensedSeats ? 1 : activeUsers - licensedSeats);
const [requestedSeats, setRequestedSeats] = useState(minimumSeats);
const [stripeLoadHint, setStripeLoadHint] = useState(Math.random());
const stripeRef = useLoadStripe(stripeLoadHint);
const initialState = makeInitialState(requestedSeats);
const [formState, setFormState] = useState<FormState>(initialState);
const [show] = useState(true);
const canRetry = formState.error !== '422';
const showForm = progress !== SelfHostedSignupProgress.PAID && progress !== SelfHostedSignupProgress.CREATED_LICENSE && !formState.submitting && !formState.error && !formState.succeeded;
const title = intl.formatMessage({
id: 'self_hosted_expansion.expansion_modal.title',
defaultMessage: 'Provide your payment details',
});
const canSubmitForm = canSubmit(formState, progress);
const submit = async () => {
let submitProgress = progress;
let signupCustomerResult: SelfHostedSignupCustomerResponse | null = null;
setFormState({...formState, submitting: true});
try {
const [firstName, lastName] = inferNames(user, formState.cardName);
signupCustomerResult = await Client4.createCustomerSelfHostedSignup({
first_name: firstName,
last_name: lastName,
billing_address: {
city: formState.city,
country: formState.country,
line1: formState.address,
line2: formState.address2,
postal_code: formState.postalCode,
state: formState.state,
},
shipping_address: {
city: formState.city,
country: formState.country,
line1: formState.address,
line2: formState.address2,
postal_code: formState.postalCode,
state: formState.state,
},
organization: formState.organization,
});
} catch {
setFormState({...formState, error: 'Failed to submit payment information'});
return;
}
if (signupCustomerResult === null) {
setStripeLoadHint(Math.random());
setFormState({...formState, submitting: false});
return;
}
if (progress === SelfHostedSignupProgress.START || progress === SelfHostedSignupProgress.CREATED_CUSTOMER) {
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: signupCustomerResult.progress,
});
submitProgress = signupCustomerResult.progress;
}
if (stripeRef.current === null) {
setStripeLoadHint(Math.random());
setFormState({...formState, submitting: false});
return;
}
try {
const card = cardRef.current?.getCard();
if (!card) {
const message = 'Failed to get card when it was expected';
setFormState({...formState, error: message});
return;
}
const finished = await dispatch(confirmSelfHostedExpansion(
stripeRef.current,
{
id: signupCustomerResult.setup_intent_id,
client_secret: signupCustomerResult.setup_intent_secret,
},
isDevMode,
{
address: formState.address,
address2: formState.address2,
city: formState.city,
state: formState.state,
country: formState.country,
postalCode: formState.postalCode,
name: formState.cardName,
card,
},
submitProgress,
{
seats: formState.seats,
license_id: license.Id,
},
));
if (finished.data) {
setFormState({...formState, succeeded: true});
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: SelfHostedSignupProgress.CREATED_LICENSE,
});
// Reload license in background.
// Needed if this was completed while on the Edition and License page.
dispatch(getLicenseConfig());
} else if (finished.error) {
let errorData = finished.error;
if (finished.error === 422) {
errorData = finished.error.toString();
}
setFormState({...formState, error: errorData});
return;
}
setFormState({...formState, submitting: false});
} catch (e) {
setFormState({...formState, error: 'unable to complete signup'});
}
};
useEffect(() => {
pageVisited(
TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION,
'pageview_self_hosted_expansion',
);
localStorage.setItem(STORAGE_KEY_EXPANSION_IN_PROGRESS, 'true');
return () => {
localStorage.removeItem(STORAGE_KEY_EXPANSION_IN_PROGRESS);
};
}, []);
const resetToken = () => {
try {
Client4.bootstrapSelfHostedSignup(true).
then((data) => {
dispatch({
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
data: data.progress,
});
});
} catch {
// swallow error ok here
}
};
return (
<StripeProvider
stripeRef={stripeRef}
>
<RootPortal>
<FullScreenModal
show={show}
ariaLabelledBy='self_hosted_expansion_modal_title'
onClose={() => {
dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION));
resetToken();
}}
>
<div className='SelfHostedExpansionModal'>
<div className={classNames('form-view', {'form-view--hide': !showForm})}>
<div className='lhs'>
<h2 className='title'>{title}</h2>
<UpgradeSvg
width={267}
height={227}
/>
<div className='footer-text'>{'Questions?'}</div>
<ContactSalesLink/>
</div>
<div className='center'>
<div
className='form'
data-testid='expansion-modal'
>
<span className='section-title'>
{intl.formatMessage({
id: 'payment_form.credit_card',
defaultMessage: 'Credit Card',
})}
</span>
<div className='form-row'>
<CardInput
forwardedRef={cardRef}
required={true}
onCardInputChange={(event: StripeCardElementChangeEvent) => {
setFormState({...formState, cardFilled: event.complete});
}}
theme={theme}
/>
</div>
<div className='form-row'>
<Input
name='organization'
type='text'
value={formState.organization}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFormState({...formState, organization: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'self_hosted_signup.organization',
defaultMessage: 'Organization Name',
})}
required={true}
/>
</div>
<div className='form-row'>
<Input
name='name'
type='text'
value={formState.cardName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFormState({...formState, cardName: e.target.value});
}}
placeholder={intl.formatMessage({
id: 'payment_form.name_on_card',
defaultMessage: 'Name on Card',
})}
required={true}
/>
</div>
<span className='section-title'>
<FormattedMessage
id='payment_form.billing_address'
defaultMessage='Billing address'
/>
</span>
<Address
testPrefix='selfHostedExpansion'
type='billing'
country={formState.country}
changeCountry={(option) => {
setFormState({...formState, country: option.value});
}}
address={formState.address}
changeAddress={(e) => {
setFormState({...formState, address: e.target.value});
}}
address2={formState.address2}
changeAddress2={(e) => {
setFormState({...formState, address2: e.target.value});
}}
city={formState.city}
changeCity={(e) => {
setFormState({...formState, city: e.target.value});
}}
state={formState.state}
changeState={(state: string) => {
setFormState({...formState, state});
}}
postalCode={formState.postalCode}
changePostalCode={(e) => {
setFormState({...formState, postalCode: e.target.value});
}}
/>
<ChooseDifferentShipping
shippingIsSame={formState.shippingSame}
setShippingIsSame={(val: boolean) => {
setFormState({...formState, shippingSame: val});
}}
/>
{!formState.shippingSame && (
<>
<div className='section-title'>
<FormattedMessage
id='payment_form.shipping_address'
defaultMessage='Shipping Address'
/>
</div>
<Address
testPrefix='shippingSelfHostedExpansion'
type='shipping'
country={formState.shippingCountry}
changeCountry={(option) => {
setFormState({...formState, shippingCountry: option.value});
}}
address={formState.shippingAddress}
changeAddress={(e) => {
setFormState({...formState, shippingAddress: e.target.value});
}}
address2={formState.shippingAddress2}
changeAddress2={(e) => {
setFormState({...formState, shippingAddress2: e.target.value});
}}
city={formState.shippingCity}
changeCity={(e) => {
setFormState({...formState, shippingCity: e.target.value});
}}
state={formState.shippingState}
changeState={(state: string) => {
setFormState({...formState, shippingState: state});
}}
postalCode={formState.shippingPostalCode}
changePostalCode={(e) => {
setFormState({...formState, shippingPostalCode: e.target.value});
}}
/>
</>
)}
<Terms
agreed={formState.agreedTerms}
setAgreed={(data: boolean) => {
setFormState({...formState, agreedTerms: data});
}}
/>
</div>
</div>
<div className='rhs'>
<SelfHostedExpansionCard
updateSeats={(seats: number) => {
setFormState({...formState, seats});
setRequestedSeats(seats);
}}
canSubmit={canSubmitForm}
submit={submit}
licensedSeats={licensedSeats}
minimumSeats={minimumSeats}
/>
</div>
</div>
{((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && (
<SuccessPage
onClose={() => {
setFormState({...formState, submitting: false, error: '', succeeded: false});
dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION));
}}
/>
)}
{formState.submitting && (
<Submitting
currentPlan={currentPlan}
/>
)}
{formState.error && (
<ErrorPage
canRetry={canRetry}
tryAgain={() => {
setFormState({...formState, submitting: false, error: ''});
}}
/>
)}
<div className='background-svg'>
<BackgroundSvg/>
</div>
</div>
</FullScreenModal>
</RootPortal>
</StripeProvider>
);
}

Просмотреть файл

@@ -0,0 +1,183 @@
.SelfHostedExpansionModal {
height: 100%;
.form-view {
display: flex;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-content: top;
justify-content: center;
padding: 77px 107px;
color: var(--center-channel-color);
font-family: "Open Sans";
font-size: 16px;
font-weight: 600;
overflow-x: hidden;
.title {
font-size: 22px;
font-weight: 600;
}
.form {
padding: 0 96px;
margin: 0 auto;
.form-row {
display: flex;
width: 100%;
margin-bottom: 24px;
}
.form-row-third-1 {
width: 66%;
max-width: 288px;
margin-right: 16px;
.DropdownInput {
margin-top: 0;
}
}
.DropdownInput {
position: relative;
height: 36px;
margin-bottom: 24px;
.Input_fieldset {
height: 43px;
}
}
.form-row-third-2 {
width: 34%;
max-width: 144px;
}
.section-title {
display: block;
margin-bottom: 10px;
color: rgba(var(--center-channel-color-rgb), 0.72);
font-size: 16px;
font-weight: 600;
text-align: left;
}
.Input_fieldset {
height: 40px;
padding: 2px 1px;
background: var(--center-channel-bg);
.Input {
background: inherit;
}
.Input_wrapper {
margin: 0;
}
}
}
&--hide {
display: none;
}
>.lhs {
width: 25%;
}
>.center {
width: 50%;
}
>.rhs {
position: sticky;
display: flex;
width: 25%;
flex-direction: column;
align-items: center;
}
.submitting,
.success,
.failed {
display: flex;
overflow: hidden;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-content: center;
justify-content: center;
padding: 77px 107px;
color: var(--center-channel-color);
font-family: "Open Sans";
font-size: 16px;
font-weight: 600;
.IconMessage .content .IconMessage-link {
margin-left: 0;
}
}
.background-svg {
position: absolute;
z-index: -1;
top: 0;
width: 100%;
height: 100%;
>div {
position: absolute;
top: 0;
left: 0;
}
}
.self-hosted-agreed-terms {
label {
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
input[type=checkbox] {
width: 17px;
height: 17px;
flex-shrink: 0;
margin-right: 12px;
}
font-size: 16px;
}
}
@media (max-width: 1020px) {
.SelfHostedExpansionModal {
.form-view {
>.lhs {
display: none;
}
>.center {
width: 66%;
}
>.rhs {
width: 33%;
}
}
}
}
.FullScreenModal {
.close-x {
top: 12px;
right: 12px;
}
}
}

Просмотреть файл

@@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer';
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
import {ValueOf} from '@mattermost/types/utilities';
import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg';
import IconMessage from 'components/purchase_modal/icon_message';
import './submitting_page.scss';
function useConvertProgressToWaitingExplanation(progress: ValueOf<typeof SelfHostedSignupProgress>, planName: string): React.ReactNode {
const intl = useIntl();
switch (progress) {
case SelfHostedSignupProgress.START:
case SelfHostedSignupProgress.CREATED_CUSTOMER:
case SelfHostedSignupProgress.CREATED_INTENT:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.submitting_payment',
defaultMessage: 'Submitting payment information',
});
case SelfHostedSignupProgress.CONFIRMED_INTENT:
case SelfHostedSignupProgress.CREATED_SUBSCRIPTION:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.verifying_payment',
defaultMessage: 'Verifying payment details',
});
case SelfHostedSignupProgress.PAID:
case SelfHostedSignupProgress.CREATED_LICENSE:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.applying_license',
defaultMessage: 'Applying your {planName} license to your Mattermost instance',
}, {planName});
default:
return intl.formatMessage({
id: 'self_hosted_signup.progress_step.submitting_payment',
defaultMessage: 'Submitting payment information',
});
}
}
export function convertProgressToBar(progress: ValueOf<typeof SelfHostedSignupProgress>): number {
switch (progress) {
case SelfHostedSignupProgress.START:
return 15;
case SelfHostedSignupProgress.CREATED_CUSTOMER:
return 30;
case SelfHostedSignupProgress.CREATED_INTENT:
return 45;
case SelfHostedSignupProgress.CONFIRMED_INTENT:
return 60;
case SelfHostedSignupProgress.CREATED_SUBSCRIPTION:
return 75;
case SelfHostedSignupProgress.PAID:
return 85;
case SelfHostedSignupProgress.CREATED_LICENSE:
return 100;
default:
return 0;
}
}
interface Props {
currentPlan: string;
}
const maxProgressBar = 100;
const maxFakeProgressIncrement = 5;
const fakeProgressInterval = 600;
export default function Submitting(props: Props) {
const [barProgress, setBarProgress] = useState(0);
const signupProgress = useSelector(getSelfHostedSignupProgress);
const waitingExplanation = useConvertProgressToWaitingExplanation(signupProgress, props.currentPlan);
const footer = (
<div className='ProcessPayment-progress'>
<div
className='ProcessPayment-progress-fill'
style={{width: `${barProgress}%`}}
/>
</div>
);
useEffect(() => {
const maxProgressForCurrentSignupProgress = convertProgressToBar(signupProgress);
const interval = setInterval(() => {
if (barProgress < maxProgressBar) {
setBarProgress(Math.min(maxProgressForCurrentSignupProgress, barProgress + maxFakeProgressIncrement));
}
}, fakeProgressInterval);
return () => clearInterval(interval);
}, [barProgress]);
return (
<div className='submitting'>
<IconMessage
formattedTitle={(
<FormattedMessage
id='admin.billing.subscription.verifyPaymentInformation'
defaultMessage='Verifying your payment information'
/>
)}
formattedSubtitle={waitingExplanation}
icon={
<CreditCardSvg
width={444}
height={313}
/>
}
footer={footer}
className={'processing'}
/>
</div>
);
}

Просмотреть файл

@@ -0,0 +1,7 @@
.submitting {
overflow: hidden;
.processing {
margin-top: 163px;
}
}

Просмотреть файл

@@ -0,0 +1,23 @@
.SelfHostedPurchaseModal__success {
display: flex;
overflow: hidden;
width: 100%;
height: 100%;
flex-direction: row;
flex-grow: 1;
flex-wrap: wrap;
align-content: center;
justify-content: center;
padding: 77px 107px;
color: var(--center-channel-color);
font-size: 16px;
font-weight: 600;
}
.self_hosted_expansion_success {
overflow: hidden;
.selfHostedExpansionModal__success {
margin-top: 163px;
}
}

Просмотреть файл

@@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useHistory} from 'react-router-dom';
import IconMessage from 'components/purchase_modal/icon_message';
import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg';
import {ConsolePages} from 'utils/constants';
import './success_page.scss';
interface Props {
onClose: () => void;
}
export default function SelfHostedExpansionSuccessPage(props: Props) {
const history = useHistory();
const titleText = (
<FormattedMessage
id={'self_hosted_expansion.expand_success'}
defaultMessage={"You've successfully updated your license seat count"}
/>
);
const formattedSubtitleText = (
<FormattedMessage
id={'self_hosted_expansion.license_applied'}
defaultMessage={'The license has been automatically applied to your Mattermost instance. Your updated invoice will be visible in the <billing>Billing section</billing> of the system console.'}
values={{
billing: (billingText: React.ReactNode) => (
<a
href='#'
onClick={() => {
history.push(ConsolePages.BILLING_HISTORY);
props.onClose();
}}
>
{billingText}
</a>
),
}}
/>
);
const formattedButtonText = (
<FormattedMessage
id={'self_hosted_expansion.close'}
defaultMessage={'Close'}
/>
);
const icon = (
<PaymentSuccessStandardSvg
width={444}
height={313}
/>
);
return (
<div className='self_hosted_expansion_success'>
<IconMessage
className={'selfHostedExpansionModal__success'}
formattedTitle={titleText}
formattedSubtitle={formattedSubtitleText}
testId='selfHostedExpansionSuccess'
icon={icon}
formattedButtonText={formattedButtonText}
buttonHandler={props.onClose}
/>
</div>
);
}

Просмотреть файл

@@ -15,7 +15,7 @@ import {SelfHostedProducts, ModalIdentifiers} from 'utils/constants';
import {DeepPartial} from '@mattermost/types/utilities';
import SelfHostedPurchaseModal, {makeInitialState, canSubmit, State} from './';
import SelfHostedPurchaseModal, {makeInitialState, canSubmit, State} from '.';
interface MockCardInputProps {
onCardInputChange: (event: {complete: boolean}) => void;
@@ -50,7 +50,7 @@ jest.mock('components/payment_form/card_input', () => {
};
});
jest.mock('components/self_hosted_purchase_modal/stripe_provider', () => {
jest.mock('components/self_hosted_purchases/stripe_provider', () => {
return function(props: {children: React.ReactNode | React.ReactNodeArray}) {
return props.children;
};

Просмотреть файл

@@ -26,6 +26,8 @@ import {GlobalState} from 'types/store';
import {isModalOpen} from 'selectors/views/modals';
import {isDevModeEnabled} from 'selectors/general';
import {inferNames} from 'utils/hosted_customer';
import {
ModalIdentifiers,
StatTypes,
@@ -46,29 +48,28 @@ import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardA
import ChooseDifferentShipping from 'components/choose_different_shipping';
import {ValueOf} from '@mattermost/types/utilities';
import {UserProfile} from '@mattermost/types/users';
import {
SelfHostedSignupProgress,
SelfHostedSignupCustomerResponse,
} from '@mattermost/types/hosted_customer';
import {Seats, errorInvalidNumber} from '../seats_calculator';
import {Seats, errorInvalidNumber} from '../../seats_calculator';
import ContactSalesLink from './contact_sales_link';
import ContactSalesLink from '../contact_sales_link';
import Submitting, {convertProgressToBar} from './submitting';
import ErrorPage from './error';
import SuccessPage from './success_page';
import SelfHostedCard from './self_hosted_card';
import StripeProvider from './stripe_provider';
import StripeProvider from '../stripe_provider';
import Terms from './terms';
import Address from './address';
import Address from '../address';
import useNoEscape from './useNoEscape';
import {SetPrefix, UnionSetActions} from './types';
import './self_hosted_purchase_modal.scss';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from './constants';
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from '../constants';
export interface State {
@@ -309,17 +310,6 @@ interface FakeProgress {
intervalId?: NodeJS.Timeout;
}
function inferNames(user: UserProfile, cardName: string): [string, string] {
if (user.first_name) {
return [user.first_name, user.last_name];
}
const names = cardName.split(' ');
if (cardName.length === 2) {
return [names[0], names[1]];
}
return [names[0], names.slice(1).join(' ')];
}
export default function SelfHostedPurchaseModal(props: Props) {
useFetchStandardAnalytics();
useNoEscape();

Просмотреть файл

@@ -17,8 +17,8 @@ import {
SelfHostedProducts,
} from 'utils/constants';
import Consequences from '../seats_calculator/consequences';
import SeatsCalculator, {Seats} from '../seats_calculator';
import Consequences from '../../seats_calculator/consequences';
import SeatsCalculator, {Seats} from '../../seats_calculator';
// Card has a bunch of props needed for monthly/yearly payments that
// do not apply to self-hosted.

Просмотреть файл

@@ -1323,6 +1323,7 @@
"admin.license.enterprise.upgrade.eeLicenseLink": "Enterprise Edition License",
"admin.license.enterprise.upgrading": "Upgrading {percentage}%",
"admin.license.enterpriseEdition": "Enterprise Edition",
"admin.license.enterpriseEdition.add.seats": "+ Add seats",
"admin.license.enterpriseEdition.subtitle": "This is an Enterprise Edition for the Mattermost {skuName} plan",
"admin.license.enterprisePlanSubtitle": "Were here to work with you and your needs. Contact us today to get more seats on your plan.",
"admin.license.enterprisePlanTitle": "Need to increase your headcount?",
@@ -4769,6 +4770,26 @@
"select_team.icon": "Select Team Icon",
"select_team.join.icon": "Join Team Icon",
"select_team.private.icon": "Private Team",
"self_hosted_expansion_rhs_card_add_new_seats": "Add new seats",
"self_hosted_expansion_rhs_card_cost_per_user_breakdown": "{costPerUser} x {monthsUntilExpiry} months",
"self_hosted_expansion_rhs_card_cost_per_user_title": "Cost per user",
"self_hosted_expansion_rhs_card_license_date": "{startsAt} - {endsAt}",
"self_hosted_expansion_rhs_card_licensed_seats": "{licensedSeats} LICENSES SEATS",
"self_hosted_expansion_rhs_card_maximum_seats_warning": "{warningIcon} You may only expand by an additional {maxAdditionalSeats} seats",
"self_hosted_expansion_rhs_card_must_add_seats_warning": "{warningIcon} You must add a seat to continue",
"self_hosted_expansion_rhs_card_must_purchase_enough_seats": "{warningIcon} You must purchase at least {minimumSeats} seats to be compliant with your license",
"self_hosted_expansion_rhs_card_total_prorated_warning": "The total will be prorated",
"self_hosted_expansion_rhs_card_total_title": "Total",
"self_hosted_expansion_rhs_complete_button": "Complete purchase",
"self_hosted_expansion_rhs_credit_card_charge_today_warning": "Your credit card will be charged today.<see_how_billing_works>See how billing works.</see_how_billing_works>",
"self_hosted_expansion_rhs_license_summary_title": "License Summary",
"self_hosted_expansion.close": "Close",
"self_hosted_expansion.contact_support": "Contact Support",
"self_hosted_expansion.expand_success": "You've successfully updated your license seat count",
"self_hosted_expansion.expansion_modal.title": "Provide your payment details",
"self_hosted_expansion.license_applied": "The license has been automatically applied to your Mattermost instance. Your updated invoice will be visible in the <billing>Billing section</billing> of the system console.",
"self_hosted_expansion.paymentFailed": "Payment failed. Please try again or contact support.",
"self_hosted_expansion.try_again": "Try again",
"self_hosted_signup.air_gapped_content": "It appears that your instance is air-gapped, or it may not be connected to the internet. To purchase a license, please visit",
"self_hosted_signup.air_gapped_title": "Purchase through the customer portal",
"self_hosted_signup.close": "Close",

Просмотреть файл

@@ -464,6 +464,8 @@ export const ModalIdentifiers = {
DELETE_WORKSPACE_RESULT: 'delete_workspace_result',
SCREENING_IN_PROGRESS: 'screening_in_progress',
CONFIRM_SWITCH_TO_YEARLY: 'confirm_switch_to_yearly',
EXPANSION_IN_PROGRESS: 'expansion_in_progress',
SELF_HOSTED_EXPANSION: 'self_hosted_expansion',
START_TRIAL_FORM_MODAL: 'start_trial_form_modal',
START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result',
};
@@ -751,6 +753,7 @@ export const TELEMETRY_CATEGORIES = {
CLOUD_PURCHASING: 'cloud_purchasing',
CLOUD_PRICING: 'cloud_pricing',
SELF_HOSTED_PURCHASING: 'self_hosted_purchasing',
SELF_HOSTED_EXPANSION: 'self_hosted_expansion',
CLOUD_ADMIN: 'cloud_admin',
CLOUD_DELINQUENCY: 'cloud_delinquency',
SELF_HOSTED_ADMIN: 'self_hosted_admin',
@@ -1104,6 +1107,7 @@ export const DocLinks = {
ONBOARD_LDAP: 'https://docs.mattermost.com/onboard/ad-ldap.html',
ONBOARD_SSO: 'https://docs.mattermost.com/onboard/sso-saml.html',
TRUE_UP_REVIEW: 'https://mattermost.com/pl/true-up-documentation',
SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html',
ABOUT_TEAMS: 'https://docs.mattermost.com/welcome/about-teams.html#team-url',
};
@@ -2017,6 +2021,7 @@ export const ConsolePages = {
WEB_SERVER: '/admin_console/environment/web_server',
PUSH_NOTIFICATION_CENTER: '/admin_console/environment/push_notification_server',
SMTP: '/admin_console/environment/smtp',
BILLING_HISTORY: '/admin_console/billing/billing_history',
};
export const WindowSizes = {

Просмотреть файл

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {Product} from '@mattermost/types/cloud';
import {UserProfile} from '@mattermost/types/users';
// find a self-hosted product based on its SKU
// This function should not be used for cloud products, because there are
@@ -17,3 +18,13 @@ export const findSelfHostedProductBySku = (products: Record<string, Product>, sk
return matches[0];
};
export const inferNames = (user: UserProfile, cardName: string): [string, string] => {
if (user.first_name) {
return [user.first_name, user.last_name];
}
const names = cardName.split(' ');
if (cardName.length === 2) {
return [names[0], names[1]];
}
return [names[0], names.slice(1).join(' ')];
};

Просмотреть файл

@@ -33,6 +33,7 @@ import {
SelfHostedSignupCustomerResponse,
SelfHostedSignupSuccessResponse,
SelfHostedSignupBootstrapResponse,
SelfHostedExpansionRequest,
} from '@mattermost/types/hosted_customer';
import {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories';
@@ -3895,6 +3896,14 @@ export default class Client4 {
);
};
confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => {
return this.doFetch<SelfHostedSignupSuccessResponse>(
`${this.getHostedCustomerRoute()}/confirm-expand`,
{method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, expand_request: expandRequest})},
);
}
subscribeToNewsletter = (newletterRequestBody: NewsletterRequestBody) => {
return this.doFetch<StatusOK>(
`${this.getHostedCustomerRoute()}/subscribe-newsletter`,

Просмотреть файл

@@ -370,7 +370,6 @@ export type ServiceSettings = {
EnableCustomGroups: boolean;
SelfHostedPurchase: boolean;
AllowSyncedDrafts: boolean;
SelfHostedExpansion: boolean;
};
export type TeamSettings = {

Просмотреть файл

@@ -75,3 +75,8 @@ export interface TrueUpReviewProfileReducer extends TrueUpReviewProfile {
export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus {
getRequestState: RequestState;
}
export type SelfHostedExpansionRequest = {
seats: number;
license_id: string;
}