Merge pull request #22658 from mattermost/MM-50966-in-product-expansion
MM-50365 - In Product Expansion Front-End
Этот коммит содержится в:
@@ -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;
|
||||
|
||||
26
webapp/channels/src/components/outlined_input/index.tsx
Обычный файл
26
webapp/channels/src/components/outlined_input/index.tsx
Обычный файл
@@ -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": "We’re 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;
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user