From b40cd113fa4f0d8efd57ebb7e2bac017fb79e882 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 14:48:47 -0400 Subject: [PATCH 01/56] Migrate to mono-repo --- .../channels/src/actions/hosted_customer.tsx | 88 ++- .../enterprise_edition.scss | 25 +- .../enterprise_edition_left_panel.test.tsx | 86 ++- .../enterprise_edition_left_panel.tsx | 61 ++- .../common/hooks/useCanSelfHostedExpand.ts | 46 ++ .../useControlSelfHostedExpansionModal.ts | 93 ++++ .../useControlSelfHostedPurchaseModal.ts | 2 + .../purchase_in_progress_modal/index.test.tsx | 20 +- .../purchase_in_progress_modal/index.tsx | 4 +- .../self_hosted_expansion_modal/constants.tsx | 4 + .../error_page.scss | 3 + .../error_page.tsx | 70 +++ .../expansion_card.scss | 140 +++++ .../expansion_card.tsx | 268 ++++++++++ .../index.test.tsx | 418 +++++++++++++++ .../self_hosted_expansion_modal/index.tsx | 503 ++++++++++++++++++ .../self_hosted_expansion_modal.scss | 178 +++++++ .../success_page.scss | 20 + .../success_page.tsx | 77 +++ .../self_hosted_purchase_modal/index.tsx | 13 +- webapp/channels/src/utils/constants.tsx | 4 + webapp/channels/src/utils/hosted_customer.ts | 11 + webapp/platform/client/src/client4.ts | 8 + webapp/platform/types/src/hosted_customer.ts | 4 + 24 files changed, 2111 insertions(+), 35 deletions(-) create mode 100644 webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts create mode 100644 webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/index.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx diff --git a/webapp/channels/src/actions/hosted_customer.tsx b/webapp/channels/src/actions/hosted_customer.tsx index 81d02c63fd..9ae2d350ba 100644 --- a/webapp/channels/src/actions/hosted_customer.tsx +++ b/webapp/channels/src/actions/hosted_customer.tsx @@ -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, + 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}; + }; +} diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss index 881616d1cf..69ab2d6e1b 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss @@ -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 { @@ -194,4 +209,4 @@ } } } -} +} \ No newline at end of file diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx index ef6a3d387f..551af99212 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx @@ -6,15 +6,20 @@ 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'; describe('components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel', () => { @@ -26,7 +31,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris SkuShortName: 'Enterprise', Name: 'LicenseName', Company: 'Mattermost Inc.', - Users: '1000000', + Users: '1000', }; const initialState: DeepPartial = { @@ -45,10 +50,36 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris }, general: { license, + config: { + BuildEnterpriseReady: 'true', + }, }, preferences: { myPreferences: {}, }, + admin: { + config: { + ServiceSettings: { + SelfHostedExpansion: 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 +111,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('USERS'); + n.childAt(0).type() === 'span' && + !n.childAt(0).text().includes('ACTIVE') && + n.childAt(0).text().includes('USERS'); }); - 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 +177,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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + expect(screen.getByText('+ Add seats')).toBeVisible(); + }); }); diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx index c3f37bfd93..0d4a422a27 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx @@ -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,16 @@ 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'; + +const DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD = 30; +const DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD = 5; export interface EnterpriseEditionProps { openEELicenseModal: () => void; @@ -47,10 +55,12 @@ 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); 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); @@ -63,6 +73,7 @@ const EnterpriseEditionLeftPanel = ({ const skuName = getSkuDisplayName(unsanitizedLicense.SkuShortName, unsanitizedLicense.IsGovSku === 'true'); const expirationDays = getRemainingDaysFromFutureTimestamp(parseInt(unsanitizedLicense.ExpiresAt, 10)); + const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion; const viewPlansButton = ( } { @@ -134,6 +159,7 @@ const EnterpriseEditionLeftPanel = ({ fileInputRef, handleChange, statsActiveUsers, + expirationDays, ) } @@ -162,7 +188,7 @@ const EnterpriseEditionLeftPanel = ({ type LegendValues = 'START DATE:' | 'EXPIRES:' | 'USERS:' | '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 +212,26 @@ const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({l >{value} ); + } else if (legend === 'EXPIRES:') { + return ( +
+ {legend} + {value} + {(expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD) && + + {`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`} + + } +
+ ); } return ( @@ -209,6 +255,7 @@ const renderLicenseContent = ( fileInputRef: RefObject, handleChange: () => void, statsActiveUsers: number, + expirationDays: number, ) => { // Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English. @@ -246,7 +293,7 @@ const renderLicenseContent = ( return (
- {licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10)))} + {licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10), expirationDays))}
{renderAddNewLicenseButton(fileInputRef, handleChange)} {renderRemoveButton(handleRemove, isDisabled, removing)} diff --git a/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts b/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts new file mode 100644 index 0000000000..93f2d2092e --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts @@ -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; +} diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts new file mode 100644 index 0000000000..acca8853e0 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -0,0 +1,93 @@ +// 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_expansion_modal/constants'; +import SelfHostedExpansionModal from 'components/self_hosted_expansion_modal'; + +import {useControlModal, ControlModal} from './useControlModal'; + +interface HookOptions{ + onClick?: () => void; + trackingLocation: string; +} + +export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal { + const dispatch = useDispatch(); + const currentUser = useSelector(getCurrentUser); + const controlModal = useControlModal({ + modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, + dialogType: SelfHostedExpansionModal, + }); + + return useMemo(() => { + return { + ...controlModal, + open: async () => { + 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.PURCHASE_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, + }); + + if (options.onClick) { + options.onClick(); + } + + 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.onClick, options.trackingLocation]); +} diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts index d6e3d1cdec..1cbd91372b 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts @@ -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; diff --git a/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx b/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx index 268d8dbb90..fc1b87159e 100644 --- a/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx +++ b/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx @@ -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_purchase_modal/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 = JSON.parse(JSON.stringify(initialState)); stateOverride.entities!.users!.currentUserId = 'otherUserId'; - renderWithIntlAndStore(
, stateOverride); + renderWithIntlAndStore( +
+ +
, 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(
, initialState); + renderWithIntlAndStore( +
+ +
, initialState, + ); expect(Client4.bootstrapSelfHostedSignup).not.toHaveBeenCalled(); screen.getByText('Reset purchase flow').click(); diff --git a/webapp/channels/src/components/purchase_in_progress_modal/index.tsx b/webapp/channels/src/components/purchase_in_progress_modal/index.tsx index 1a7cf3be80..2e0483a401 100644 --- a/webapp/channels/src/components/purchase_in_progress_modal/index.tsx +++ b/webapp/channels/src/components/purchase_in_progress_modal/index.tsx @@ -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(); }; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx new file mode 100644 index 0000000000..83c13f3567 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const STORAGE_KEY_EXPANSION_IN_PROGRESS = 'EXPANSION_IN_PROGRESS'; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss new file mode 100644 index 0000000000..9a25362e9d --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss @@ -0,0 +1,3 @@ +.self_hosted_expansion_failed { + margin-top: 163px; +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx new file mode 100644 index 0000000000..76b9af34d4 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -0,0 +1,70 @@ +// 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 {useSelector} from 'react-redux'; + +import {getCloudContactUsLink, InquiryType} from 'selectors/cloud'; + +import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg'; +import IconMessage from 'components/purchase_modal/icon_message'; + +import './error_page.scss'; + +export default function SelfHostedExpansionErrorPage() { + const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical); + + const formattedTitle = ( + + ); + + const formattedButtonText = ( + + ); + + const formattedSubtitle = ( + + ); + + const tertiaryButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ { + //TODO: Open self hosted expansion modal + }} + formattedTertiaryButonText={tertiaryButtonText} + tertiaryButtonHandler={() => window.open(contactSupportLink, '_blank', 'noreferrer')} + /> +
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss new file mode 100644 index 0000000000..79efc3e325 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss @@ -0,0 +1,140 @@ +.SelfHostedExpansionRHSCard { + display: flex; + max-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-family: 'Open Sans'; + font-size: 14px; + font-weight: 600; + text-align: center; + text-transform: capitalize; + } + + .seatsInput { + width: 73px; + margin-left: auto; + font-family: 'Open Sans'; + 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-family: 'Metropolis'; + font-size: 20px; + font-weight: 400; + text-transform: capitalize; + } + + .usage { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.56); + font-family: 'Open Sans'; + 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-family: 'Open Sans'; + font-size: 14px; + } + + .costPerUser > span:last-child { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-family: 'Open Sans'; + font-size: 12px; + } + + .totalCost { + width: 141px; + } + + .totalCost > span:first-child { + color: var(--sys-denim-center-channel-text); + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 700; + } + + .totalCost > span:last-child { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-family: 'Open Sans'; + font-size: 12px; + } + + .costAmount { + margin-right: 0; + margin-left: auto; + font-weight: 700; + } + } + + &__AddSeatsWarning { + display: block; + width: 100%; + height: 35px; + margin-bottom: 15px; + color: var(--dnd-indicator); + font-family: 'Open Sans'; + 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-family: 'Open Sans'; + font-size: 12px; + font-weight: 400; + } +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx new file mode 100644 index 0000000000..d79d6b66fc --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx @@ -0,0 +1,268 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {OutlinedInput} from '@mui/material'; + +import moment from 'moment-timezone'; +import React, {Fragment, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {DocLinks, RecurringIntervals} from 'utils/constants'; +import WarningIcon from 'components/widgets/icons/fa_warning_icon'; + +import './expansion_card.scss'; +import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts'; +import {findSelfHostedProductBySku} from 'utils/hosted_customer'; +import ExternalLink from 'components/external_link'; + +const MONTHS_IN_YEAR = 12; +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; +const MAX_TRANSACTION_VALUE = 1_000_000 - 1; + +interface Props { + canSubmit: boolean; + licensedSeats: number; + initialSeats: number; + submit: () => void; + updateSeats: (seats: number) => void; +} + +export default function SelfHostedExpansionCard(props: Props) { + 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.initialSeats); + const [overMaxSeats, setOverMaxSeats] = useState(false); + const licenseExpiry = parseInt(license.ExpiresAt, 10); + const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats); + const [products] = useGetSelfHostedProducts(); + const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName); + + const getMonthsUntilExpiry = () => { + const now = new Date(); + return Math.ceil((licenseExpiry - now.getTime()) / MILLISECONDS_PER_DAY / 30); + }; + + const getMonthlyPrice = () => { + if (currentProduct === null) { + return 0; + } + + if (currentProduct?.recurring_interval === RecurringIntervals.MONTH) { + return currentProduct.price_per_seat; + } + + const costPerMonth = (currentProduct.price_per_seat / MONTHS_IN_YEAR); + + // Only display 2 decimal places if the cost per month is not evenly divisible over 12 months. + if (!Number.isInteger(costPerMonth)) { + // Keep the return value as a number. + return costPerMonth; + } + + return costPerMonth; + }; + + const getCostPerUser = () => { + if (isNaN(additionalSeats)) { + return 0; + } + const monthlyPrice = getMonthlyPrice(); + const monthsUntilExpiry = getMonthsUntilExpiry(); + return monthlyPrice * monthsUntilExpiry; + }; + + const getTotal = () => { + if (isNaN(additionalSeats)) { + return 0; + } + const monthlyPrice = getMonthlyPrice(); + const monthsUntilExpiry = getMonthsUntilExpiry(); + return additionalSeats * monthlyPrice * 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 * Price Per Seat)) / price_per_seat + const getMaximumAdditionalSeats = () => { + if (currentProduct === null) { + return 0; + } + + let recurringCost = 0; + + // if monthly + if (currentProduct.recurring_interval === RecurringIntervals.MONTH) { + recurringCost = getMonthlyPrice(); + } else { // if yearly + recurringCost = currentProduct.price_per_seat; + } + + const currentPaymentPrice = recurringCost * props.licensedSeats; + const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice; + const remainingSeats = Math.floor(remainingTransactionLimit / recurringCost); + return Math.max(0, remainingSeats); + }; + + const maxAdditionalSeats = getMaximumAdditionalSeats(); + + const handleNewSeatsInputChange = (e: React.ChangeEvent) => { + setOverMaxSeats(false); + + const requestedSeats = parseInt(e.target.value, 10); + + const overMaxAdditionalSeats = requestedSeats > maxAdditionalSeats; + setOverMaxSeats(overMaxAdditionalSeats); + + const finalSeatCount = overMaxAdditionalSeats ? maxAdditionalSeats : requestedSeats; + setAdditionalSeats(finalSeatCount); + + props.updateSeats(finalSeatCount); + }; + + return ( +
+
+ +
+
+
+ {license.SkuShortName} +
+ +
+ +
+
+
+
+ + +
+
+ {invalidAdditionalSeats && !overMaxSeats && + , + }} + /> + } + {overMaxSeats && maxAdditionalSeats > 0 && + , + }} + /> + } + {maxAdditionalSeats === 0 && + , + warningIcon: , + }} + /> + } +
+
+
+ +
+ +
+
+ {'$' + getCostPerUser().toFixed(2)} +
+
+ +
+ +
+ + {'$' + getTotal().toFixed(2)} + +
+ +
+ ( + +
+ + {text} + +
+ ), + }} + /> +
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx new file mode 100644 index 0000000000..05f6386302 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -0,0 +1,418 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {screen, fireEvent} 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} from 'utils/constants'; + +import {DeepPartial} from '@mattermost/types/utilities'; + +import SelfHostedExpansionModal, {makeInitialState, canSubmit, FormState} from './'; + +interface MockCardInputProps { + onCardInputChange: (event: {complete: boolean}) => void; + forwardedRef: React.MutableRefObject; +} + +// number borrowed from stripe +const successCardNumber = '4242424242424242'; +function MockCardInput(props: MockCardInputProps) { + props.forwardedRef.current = { + getCard: () => ({}), + }; + return ( + ) => { + 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_purchase_modal/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, +}); + +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, + }); + }, + confirmSelfHostedSignup: () => Promise.resolve({ + progress: mockCreatedLicense, + license: {Users: existingUsers * 2}, + }), + getClientLicenseOld: () => Promise.resolve({ + data: {Sku: 'Enterprise'}, + }), + }, + }; +}); + +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; + +const initialState: DeepPartial = { + views: { + modals: { + modalState: { + [ModalIdentifiers.SELF_HOSTED_EXPANSION]: { + open: true, + }, + }, + }, + }, + storage: { + storage: {}, + }, + entities: { + admin: { + analytics: { + TOTAL_USERS: existingUsers, + }, + }, + teams: { + currentTeamId: '', + }, + preferences: { + myPreferences: { + theme: {}, + }, + }, + general: { + config: { + EnableDeveloper: 'false', + }, + license: { + Sku: productName, + Users: '50', + }, + }, + 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; +} + +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: '10', +}; + +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); + changeByTestId('seatsInput', form.seats); + + expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled(); + + // not changing the license seats number, + // because it is expected to be pre-filled with the correct number of seats. + + const completeButton = screen.getByText('Complete purchase'); + + if (form === defaultSuccessForm) { + expect(completeButton).toBeEnabled(); + } + + return completeButton; +} + +describe('SelfHostedExpansionModal', () => { + it('renders the form', () => { + renderWithIntlAndStore(
, 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(
, initialState); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + fillForm(defaultSuccessForm); + }); + + it('disables expansion if too few seats or no seats entered', () => { + renderWithIntlAndStore(
, initialState); + fillForm(defaultSuccessForm); + + // 0 seats entered. + const tooFewSeats = 0; + fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, valueEvent(tooFewSeats.toString())); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + expect(screen.getByText('You must add a seat to continue')).toBeVisible(); + + // No seats value entered. + fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, undefined); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + expect(screen.getByText('You must add a seat to continue')).toBeVisible(); + }); + + // it('happy path submit shows success screen', async () => { + // renderWithIntlAndStore(
, initialState); + // expect(screen.getByText('Complete purchase')).toBeDisabled(); + // const upgradeButton = fillForm(defaultSuccessForm); + + // upgradeButton.click(); + // await waitFor(() => expect(screen.getByText(`You're now subscribed to ${productName}`)).toBeTruthy(), {timeout: 1234}); + // }); + + // it('sad path submit shows error screen', async () => { + // renderWithIntlAndStore(
, 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 :: canSubmit', () => { + function makeHappyPathState(): FormState { + return { + address: 'string', + address2: 'string', + city: 'string', + state: 'string', + country: 'string', + postalCode: '12345', + 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); + }); + + // TODO: Needed? + 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); + }); +}); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx new file mode 100644 index 0000000000..914d464877 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -0,0 +1,503 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useRef, useState} from 'react'; + +import {useIntl} from 'react-intl'; + +import {useDispatch, useSelector} from 'react-redux'; + +import {StripeCardElementChangeEvent} from '@stripe/stripe-js'; + +import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg'; +import RootPortal from 'components/root_portal'; +import ContactSalesLink from 'components/self_hosted_purchase_modal/contact_sales_link'; + +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 {COUNTRIES} from 'utils/countries'; +import StateSelector from 'components/payment_form/state_selector'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import DropdownInput from 'components/dropdown_input'; +import StripeProvider from '../self_hosted_purchase_modal/stripe_provider'; + +import {closeModal} from 'actions/views/modals'; +import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getCurrentUser, getFilteredUsersStats} from 'mattermost-redux/selectors/entities/users'; +import {pageVisited} from 'actions/telemetry_actions'; + +import {Client4} from 'mattermost-redux/client'; +import {HostedCustomerTypes} from 'mattermost-redux/action_types'; +import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer'; +import {inferNames} from 'utils/hosted_customer'; +import {SelfHostedSignupCustomerResponse, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; +import {isDevModeEnabled} from 'selectors/general'; +import {getLicenseConfig} from 'mattermost-redux/actions/general'; +import {confirmSelfHostedExpansion} from 'actions/hosted_customer'; +import {DispatchFunc} from 'mattermost-redux/types/actions'; +import {ValueOf} from '@mattermost/types/utilities'; + +import SelfHostedExpansionCard from './expansion_card'; + +import './self_hosted_expansion_modal.scss'; + +import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from './constants'; + +export interface FormState { + address: string; + address2: string; + city: string; + state: string; + country: string; + postalCode: string; + cardName: string; + organization: string; + cardFilled: boolean; + seats: number; + submitting: boolean; + succeeded: boolean; + progressBar: number; + error: string; +} + +export function makeInitialState(seats: number): FormState { + return { + address: '', + address2: '', + city: '', + state: '', + country: '', + postalCode: '', + cardName: '', + organization: '', + cardFilled: false, + seats, + submitting: false, + succeeded: false, + progressBar: 0, + error: '', + }; +} + +export function canSubmit(formState: FormState, progress: ValueOf) { + if (formState.submitting) { + return false; + } + + const validAddress = Boolean( + formState.organization && + formState.address && + formState.city && + formState.state && + formState.postalCode && + formState.country, + ); + 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 && + validSeats, + ); + } + case SelfHostedSignupProgress.START: + case SelfHostedSignupProgress.CREATED_CUSTOMER: + case SelfHostedSignupProgress.CREATED_INTENT: + return Boolean( + validCard && + validAddress && + validSeats, + ); + default: { + return false; + } + } +} + +export default function SelfHostedExpansionModal() { + const dispatch = useDispatch(); + const intl = useIntl(); + const cardRef = useRef(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 activeUsers = useSelector(getFilteredUsersStats)?.total_users_count || 0; + const [additionalSeats, setAdditionalSeats] = useState(activeUsers <= licensedSeats ? 1 : activeUsers - licensedSeats); + + const [stripeLoadHint, setStripeLoadHint] = useState(Math.random()); + const stripeRef = useLoadStripe(stripeLoadHint); + + const initialState = makeInitialState(additionalSeats); + const [formState, setFormState] = useState(initialState); + const [show] = useState(true); + + 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; + 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, + }, + 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'; + // eslint-disable-next-line no-console + console.error(message); + 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, + }, + )); + + 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) { + // eslint-disable-next-line no-console + console.error('could not complete setup', 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 ( + + + { + dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); + resetToken(); + }} + > +
+
+
+

{title}

+ +
{'Questions?'}
+ +
+
+
+ + {intl.formatMessage({ + id: 'payment_form.credit_card', + defaultMessage: 'Credit Card', + })} + +
+ { + setFormState({...formState, cardFilled: event.complete}); + }} + theme={theme} + /> +
+
+ ) => { + setFormState({...formState, organization: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'self_hosted_signup.organization', + defaultMessage: 'Organization Name', + })} + required={true} + /> +
+
+ ) => { + setFormState({...formState, cardName: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.name_on_card', + defaultMessage: 'Name on Card', + })} + required={true} + /> +
+ + {intl.formatMessage({ + id: 'payment_form.billing_address', + defaultMessage: 'Billing address', + })} + + { + setFormState({...formState, country: option.value}); + }} + value={ + formState.country ? {value: formState.country, label: formState.country} : undefined + } + options={COUNTRIES.map((country) => ({ + value: country.name, + label: country.name, + }))} + legend={intl.formatMessage({ + id: 'payment_form.country', + defaultMessage: 'Country', + })} + placeholder={intl.formatMessage({ + id: 'payment_form.country', + defaultMessage: 'Country', + })} + name={'billing_dropdown'} + /> +
+ ) => { + setFormState({...formState, address: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.address', + defaultMessage: 'Address', + })} + required={true} + /> +
+
+ ) => { + setFormState({...formState, address2: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.address_2', + defaultMessage: 'Address 2', + })} + /> +
+
+ ) => { + setFormState({...formState, city: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.city', + defaultMessage: 'City', + })} + required={true} + /> +
+
+
+ { + setFormState({...formState, state}); + }} + /> +
+
+ ) => { + setFormState({...formState, postalCode: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.zipcode', + defaultMessage: 'Zip/Postal Code', + })} + required={true} + /> +
+
+
+
+
+ { + setFormState({...formState, seats}); + setAdditionalSeats(seats); + }} + canSubmit={canSubmitForm} + submit={submit} + licensedSeats={licensedSeats} + initialSeats={additionalSeats} + /> +
+
+ {/* {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE) && hasLicense) && !formState.error && !formState.submitting && ( + + )} + {formState.submitting && ( + + )} + {formState.error && ( + + )} */} +
+ +
+
+
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss new file mode 100644 index 0000000000..beb6c32e08 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -0,0 +1,178 @@ +.SelfHostedExpansionModal { + height: 100%; + + .form-view { + display: flex; + overflow: hidden; + 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; + + .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 { + z-index: 99999; + margin-top: 0; + } + } + + .DropdownInput { + position: relative; + z-index: 999999; + height: 36px; + margin-bottom: 24px; + + .Input_fieldset { + height: 43px; + } + } + + .form-row-third-2 { + width: 34%; + max-width: 144px; + } + + .section-title { + margin-bottom: 24px; + 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 { + height: 32px; + background: inherit; + } + + .Input_wrapper { + margin: 0; + } + } + } + + >.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] { + 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; + } + } +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss new file mode 100644 index 0000000000..7b4bab61f8 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss @@ -0,0 +1,20 @@ +.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-family: "Open Sans"; + font-size: 16px; + font-weight: 600; +} + +.self_hosted_expansion_success { + margin-top: 163px; +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx new file mode 100644 index 0000000000..cda885fa0d --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx @@ -0,0 +1,77 @@ +// 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 {NavLink} from 'react-router-dom'; + +import {useDispatch} from 'react-redux'; + +import IconMessage from 'components/purchase_modal/icon_message'; +import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg'; +import {ConsolePages, ModalIdentifiers} from 'utils/constants'; +import BackgroundSvg from 'components/common/svg_images_components/background_svg'; +import {closeModal} from 'actions/views/modals'; + +import './success_page.scss'; + +export default function SelfHostedExpansionSuccessPage() { + const dispatch = useDispatch(); + const titleText = ( + + ); + + const formattedSubtitleText = ( + Billing section of the system console.'} + values={{ + billing: (billingText: React.ReactNode) => ( + + {billingText} + + ), + }} + /> + ); + + const formattedButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL))} + /> +
+ +
+
+ ); +} + diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx index 03bfbccddc..3f032a0dcc 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx @@ -27,6 +27,7 @@ import {isModalOpen} from 'selectors/views/modals'; import {isDevModeEnabled} from 'selectors/general'; import {COUNTRIES} from 'utils/countries'; +import {inferNames} from 'utils/hosted_customer'; import { ModalIdentifiers, @@ -49,7 +50,6 @@ import useControlSelfHostedPurchaseModal from 'components/common/hooks/useContro import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardAnalytics'; import {ValueOf} from '@mattermost/types/utilities'; -import {UserProfile} from '@mattermost/types/users'; import { SelfHostedSignupProgress, SelfHostedSignupCustomerResponse, @@ -270,17 +270,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(); diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index d3426a4395..0d1ddb8d70 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -459,6 +459,7 @@ export const ModalIdentifiers = { DELETE_WORKSPACE_RESULT: 'delete_workspace_result', SCREENING_IN_PROGRESS: 'screening_in_progress', CONFIRM_SWITCH_TO_YEARLY: 'confirm_switch_to_yearly', + SELF_HOSTED_EXPANSION: 'self_hosted_expansion', }; export const UserStatuses = { @@ -738,6 +739,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', @@ -1068,6 +1070,7 @@ export const CloudLinks = { SELF_HOSTED_SIGNUP: 'https://customers.mattermost.com/signup', DELINQUENCY_DOCS: 'https://docs.mattermost.com/about/cloud-subscriptions.html#failed-or-late-payments', SELF_HOSTED_PRICING: 'https://mattermost.com/pricing/#self-hosted', + SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html', }; export const HostedCustomerLinks = { @@ -1998,6 +2001,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 = { diff --git a/webapp/channels/src/utils/hosted_customer.ts b/webapp/channels/src/utils/hosted_customer.ts index 130bba706c..6ea29269f7 100644 --- a/webapp/channels/src/utils/hosted_customer.ts +++ b/webapp/channels/src/utils/hosted_customer.ts @@ -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, 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(' ')]; +}; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 56e2f552f1..8aca0c6326 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -32,6 +32,7 @@ import { SelfHostedSignupCustomerResponse, SelfHostedSignupSuccessResponse, SelfHostedSignupBootstrapResponse, + SelfHostedExpansionRequest, } from '@mattermost/types/hosted_customer'; import {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories'; @@ -3892,6 +3893,13 @@ export default class Client4 { ); }; + confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => { + return this.doFetch( + `${this.getHostedCustomerRoute()}/confirm?expand=true`, + {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, subscription: expandRequest})}, + ); + } + createPaymentMethod = async () => { return this.doFetch( `${this.getCloudRoute()}/payment`, diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index d81ef15227..28f04fd561 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -74,3 +74,7 @@ export interface TrueUpReviewProfileReducer extends TrueUpReviewProfile { export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { getRequestState: RequestState; } + +export interface SelfHostedExpansionRequest { + seats: number; +} From 9cea0fd266f15178749ce1bd6e6a76f490bfef28 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 16:30:07 -0400 Subject: [PATCH 02/56] fix cost per user movement when total is large. --- .../components/self_hosted_expansion_modal/expansion_card.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss index 79efc3e325..0ac7a31fd4 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss @@ -106,6 +106,7 @@ } .costAmount { + width: 100%; margin-right: 0; margin-left: auto; font-weight: 700; From 32ccd93ed8c8fb67c79c2ea20b0222c4396c139e Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 16:37:45 -0400 Subject: [PATCH 03/56] add license_id param. --- .../src/components/self_hosted_expansion_modal/index.tsx | 1 + webapp/platform/types/src/hosted_customer.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 914d464877..06eda5dafb 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -228,6 +228,7 @@ export default function SelfHostedExpansionModal() { submitProgress, { seats: formState.seats, + license_id: license.ID, }, )); diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index 28f04fd561..ddbfc3d9de 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -77,4 +77,5 @@ export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { export interface SelfHostedExpansionRequest { seats: number; + license_id: string; } From afb929b041713be7d8f15a2dc40a18fa1d28012d Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 10:01:55 -0400 Subject: [PATCH 04/56] update expansion request. --- webapp/platform/types/src/hosted_customer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index ddbfc3d9de..69e16f32a5 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -75,7 +75,7 @@ export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { getRequestState: RequestState; } -export interface SelfHostedExpansionRequest { +export type SelfHostedExpansionRequest = { seats: number; license_id: string; } From acb4c57ee10e34e28de54dbf218d37b8ac3d2c7c Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 11:49:23 -0400 Subject: [PATCH 05/56] Add shiping address and add back terms. --- .../self_hosted_expansion_modal/index.tsx | 262 ++++++++++-------- .../self_hosted_expansion_modal.scss | 5 +- 2 files changed, 149 insertions(+), 118 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 06eda5dafb..1a2320db7c 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -3,7 +3,7 @@ import React, {useEffect, useRef, useState} from 'react'; -import {useIntl} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; @@ -47,18 +47,34 @@ import SelfHostedExpansionCard from './expansion_card'; import './self_hosted_expansion_modal.scss'; import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from './constants'; +import Address from 'components/self_hosted_purchase_modal/address'; +import ChooseDifferentShipping from 'components/choose_different_shipping'; +import Terms from 'components/self_hosted_purchase_modal/terms'; export interface FormState { + cardName: string; + cardFilled: boolean; + address: string; address2: string; city: string; state: string; country: string; postalCode: string; - cardName: string; organization: string; - cardFilled: boolean; + seats: number; + + shippingSame: boolean; + shippingAddress: string; + shippingAddress2: string; + shippingCity: string; + shippingState: string; + shippingCountry: string; + shippingPostalCode: string; + + agreedTerms: boolean; + submitting: boolean; succeeded: boolean; progressBar: number; @@ -67,16 +83,24 @@ export interface FormState { export function makeInitialState(seats: number): FormState { return { + cardName: '', + cardFilled: false, address: '', address2: '', city: '', state: '', country: '', postalCode: '', - cardName: '', organization: '', - cardFilled: false, + shippingSame: true, + shippingAddress: '', + shippingAddress2: '', + shippingCity: '', + shippingState: '', + shippingCountry: '', + shippingPostalCode: '', seats, + agreedTerms: false, submitting: false, succeeded: false, progressBar: 0, @@ -97,6 +121,18 @@ export function canSubmit(formState: FormState, progress: ValueOf 0; switch (progress) { - case SelfHostedSignupProgress.PAID: - case SelfHostedSignupProgress.CREATED_LICENSE: - case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: - return true; - case SelfHostedSignupProgress.CONFIRMED_INTENT: { - return Boolean( - validAddress && - validSeats, - ); - } - case SelfHostedSignupProgress.START: - case SelfHostedSignupProgress.CREATED_CUSTOMER: - case SelfHostedSignupProgress.CREATED_INTENT: - return Boolean( - validCard && + 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 && - validSeats, - ); - default: { - return false; - } + validShippingAddress && + validSeats && + agreedToTerms + ); + default: { + return false; + } } } @@ -173,6 +210,14 @@ export default function SelfHostedExpansionModal() { 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 { @@ -361,104 +406,87 @@ export default function SelfHostedExpansionModal() { />
- {intl.formatMessage({ - id: 'payment_form.billing_address', - defaultMessage: 'Billing address', - })} + - { +
{ setFormState({...formState, country: option.value}); }} - value={ - formState.country ? {value: formState.country, label: formState.country} : undefined - } - options={COUNTRIES.map((country) => ({ - value: country.name, - label: country.name, - }))} - legend={intl.formatMessage({ - id: 'payment_form.country', - defaultMessage: 'Country', - })} - placeholder={intl.formatMessage({ - id: 'payment_form.country', - defaultMessage: 'Country', - })} - name={'billing_dropdown'} + 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}); + }} /> -
- ) => { - setFormState({...formState, address: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.address', - defaultMessage: 'Address', - })} - required={true} - /> -
-
- ) => { - setFormState({...formState, address2: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.address_2', - defaultMessage: 'Address 2', - })} - /> -
-
- ) => { - setFormState({...formState, city: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.city', - defaultMessage: 'City', - })} - required={true} - /> -
-
-
- { - setFormState({...formState, state}); + { + setFormState({...formState, shippingSame: val}); + }} + /> + {!formState.shippingSame && ( + <> +
+ +
+
{ + 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}); }} /> -
-
- ) => { - setFormState({...formState, postalCode: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.zipcode', - defaultMessage: 'Zip/Postal Code', - })} - required={true} - /> -
-
+ + )} + { + setFormState({...formState, agreedTerms: data}); + }} + />
diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss index beb6c32e08..888532b85e 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -3,7 +3,7 @@ .form-view { display: flex; - overflow: hidden; + overflow-x: hidden; width: 100%; height: 100%; flex-direction: row; @@ -144,6 +144,9 @@ } input[type=checkbox] { + width: 17px; + height: 17px; + flex-shrink: 0; margin-right: 12px; } From 759943d24ed9579ccf1e59771f85ff577b2328de Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 13:21:22 -0400 Subject: [PATCH 06/56] add layers, add missing model. --- model/hosted_customer.go | 10 ++++++++++ plugin/api_timer_layer_generated.go | 2 +- plugin/hooks_timer_layer_generated.go | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/model/hosted_customer.go b/model/hosted_customer.go index 4f1917bdaf..d42102face 100644 --- a/model/hosted_customer.go +++ b/model/hosted_customer.go @@ -58,3 +58,13 @@ type SelfHostedBillingAccessRequest struct { type SelfHostedBillingAccessResponse struct { Token string `json:"token"` } + +type SelfHostedExpansionRequest struct { + Seats int `json:"seats"` + LicenseId string `json:"license_id"` +} + +type SelfHostedExpansionConfirmPaymentMethodRequest struct { + StripeSetupIntentID string `json:"stripe_setup_intent_id"` + ExpandRequest SelfHostedExpansionRequest `json:"expand_request"` +} diff --git a/plugin/api_timer_layer_generated.go b/plugin/api_timer_layer_generated.go index a084188c62..c54c6ac7bb 100644 --- a/plugin/api_timer_layer_generated.go +++ b/plugin/api_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" ) type apiTimerLayer struct { diff --git a/plugin/hooks_timer_layer_generated.go b/plugin/hooks_timer_layer_generated.go index 6093048d54..87e79ca7e6 100644 --- a/plugin/hooks_timer_layer_generated.go +++ b/plugin/hooks_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" ) type hooksTimerLayer struct { From e4cf521add6de43b0f85574576f78111f69c0c02 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 13:53:27 -0400 Subject: [PATCH 07/56] lint. --- .../enterprise_edition.scss | 2 +- .../self_hosted_expansion_modal/index.tsx | 47 +++++++++---------- .../self_hosted_expansion_modal.scss | 2 +- webapp/platform/client/src/client4.ts | 2 +- 4 files changed, 25 insertions(+), 28 deletions(-) diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss index 69ab2d6e1b..acce9b4621 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss @@ -209,4 +209,4 @@ } } } -} \ No newline at end of file +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 1a2320db7c..da54d4ce65 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -19,10 +19,7 @@ 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 {COUNTRIES} from 'utils/countries'; -import StateSelector from 'components/payment_form/state_selector'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import DropdownInput from 'components/dropdown_input'; import StripeProvider from '../self_hosted_purchase_modal/stripe_provider'; import {closeModal} from 'actions/views/modals'; @@ -124,11 +121,11 @@ export function canSubmit(formState: FormState, progress: ValueOf 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 && + 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; - } + agreedToTerms, + ); + default: { + return false; + } } } @@ -273,7 +270,7 @@ export default function SelfHostedExpansionModal() { submitProgress, { seats: formState.seats, - license_id: license.ID, + license_id: license.Id, }, )); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss index 888532b85e..7166c369e7 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -3,7 +3,6 @@ .form-view { display: flex; - overflow-x: hidden; width: 100%; height: 100%; flex-direction: row; @@ -16,6 +15,7 @@ font-family: "Open Sans"; font-size: 16px; font-weight: 600; + overflow-x: hidden; .title { font-size: 22px; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 8aca0c6326..a71207f42f 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -3896,7 +3896,7 @@ export default class Client4 { confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => { return this.doFetch( `${this.getHostedCustomerRoute()}/confirm?expand=true`, - {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, subscription: expandRequest})}, + {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, expand_request: expandRequest})}, ); } From 9f01e983430b33d83567476133dff2c493c8cee8 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 10:47:47 -0400 Subject: [PATCH 08/56] i18n. --- webapp/channels/src/i18n/en.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index c4f6025f80..d9ddc1a442 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1314,6 +1314,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?", @@ -4707,6 +4708,25 @@ "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_additional_seats_limit_warning": "{warningIcon} Transaction amount limit reached.{break}Please contact sales", + "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_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.", + "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 section of the system console.", + "self_hosted_expansion.paymentFailed": "Payment failed. Please try again or contact support.", "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", From d4631a4add1a69dc37c6559a3b2cbba1c890e68f Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 11:30:15 -0400 Subject: [PATCH 09/56] fix links, types. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 2 +- .../components/self_hosted_expansion_modal/error_page.tsx | 5 ++--- .../components/self_hosted_expansion_modal/index.test.tsx | 8 ++++++++ .../src/components/self_hosted_expansion_modal/index.tsx | 2 +- .../src/components/self_hosted_purchase_modal/index.tsx | 1 + webapp/channels/src/utils/constants.tsx | 4 ++-- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index acca8853e0..ce77aff2a8 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -26,7 +26,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) const dispatch = useDispatch(); const currentUser = useSelector(getCurrentUser); const controlModal = useControlModal({ - modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, + modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, dialogType: SelfHostedExpansionModal, }); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index 76b9af34d4..c811fc4c5c 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -4,9 +4,8 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; -import {useSelector} from 'react-redux'; +import {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm'; -import {getCloudContactUsLink, InquiryType} from 'selectors/cloud'; import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg'; import IconMessage from 'components/purchase_modal/icon_message'; @@ -14,7 +13,7 @@ import IconMessage from 'components/purchase_modal/icon_message'; import './error_page.scss'; export default function SelfHostedExpansionErrorPage() { - const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical); + const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error'); const formattedTitle = ( { 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, diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index da54d4ce65..00672eddf2 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -336,7 +336,7 @@ export default function SelfHostedExpansionModal() { show={show} ariaLabelledBy='self_hosted_expansion_modal_title' onClose={() => { - dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); + dispatch(closeModal(ModalIdentifiers.EXPANSION_IN_PROGRESS)); resetToken(); }} > diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx index 3f032a0dcc..b2b9a356ca 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx @@ -71,6 +71,7 @@ import {SetPrefix, UnionSetActions} from './types'; import './self_hosted_purchase_modal.scss'; import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from './constants'; +import {inferNames} from 'utils/hosted_customer'; export interface State { address: string; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 0d1ddb8d70..34343b7aa9 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -459,7 +459,7 @@ export const ModalIdentifiers = { DELETE_WORKSPACE_RESULT: 'delete_workspace_result', SCREENING_IN_PROGRESS: 'screening_in_progress', CONFIRM_SWITCH_TO_YEARLY: 'confirm_switch_to_yearly', - SELF_HOSTED_EXPANSION: 'self_hosted_expansion', + EXPANSION_IN_PROGRESS: 'expansion_in_progress', }; export const UserStatuses = { @@ -1070,7 +1070,6 @@ export const CloudLinks = { SELF_HOSTED_SIGNUP: 'https://customers.mattermost.com/signup', DELINQUENCY_DOCS: 'https://docs.mattermost.com/about/cloud-subscriptions.html#failed-or-late-payments', SELF_HOSTED_PRICING: 'https://mattermost.com/pricing/#self-hosted', - SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html', }; export const HostedCustomerLinks = { @@ -1090,6 +1089,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', }; export const LicenseLinks = { From 378bdae7fe9d8da138fc84132892965953769ca2 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 11:52:21 -0400 Subject: [PATCH 10/56] lint. --- .../src/components/self_hosted_expansion_modal/error_page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index c811fc4c5c..80e852d584 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -6,7 +6,6 @@ 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'; From 5babdf3de747deb4dc6e285b397f0653b8074ddc Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 13:46:40 -0400 Subject: [PATCH 11/56] fix types. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 4 ++-- .../src/components/self_hosted_expansion_modal/index.tsx | 2 +- webapp/channels/src/utils/constants.tsx | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index ce77aff2a8..bda0099ca8 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -26,7 +26,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) const dispatch = useDispatch(); const currentUser = useSelector(getCurrentUser); const controlModal = useControlModal({ - modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, + modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, dialogType: SelfHostedExpansionModal, }); @@ -42,7 +42,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) // is already trying to purchase. Notify them of this // and request the exit that purchase flow before attempting again. dispatch(openModal({ - modalId: ModalIdentifiers.PURCHASE_IN_PROGRESS, + modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, dialogType: PurchaseInProgressModal, dialogProps: { purchaserEmail: currentUser.email, diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 00672eddf2..da54d4ce65 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -336,7 +336,7 @@ export default function SelfHostedExpansionModal() { show={show} ariaLabelledBy='self_hosted_expansion_modal_title' onClose={() => { - dispatch(closeModal(ModalIdentifiers.EXPANSION_IN_PROGRESS)); + dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); resetToken(); }} > diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 34343b7aa9..416f9f0b54 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -460,6 +460,7 @@ export const ModalIdentifiers = { 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', }; export const UserStatuses = { From 6afe8ce9b3fc4973320e1e354f78ef9edac2d695 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 14:48:47 -0400 Subject: [PATCH 12/56] Migrate to mono-repo --- .../channels/src/actions/hosted_customer.tsx | 88 ++- .../enterprise_edition.scss | 25 +- .../enterprise_edition_left_panel.test.tsx | 86 ++- .../enterprise_edition_left_panel.tsx | 61 ++- .../common/hooks/useCanSelfHostedExpand.ts | 46 ++ .../useControlSelfHostedExpansionModal.ts | 93 ++++ .../useControlSelfHostedPurchaseModal.ts | 2 + .../purchase_in_progress_modal/index.test.tsx | 20 +- .../purchase_in_progress_modal/index.tsx | 4 +- .../self_hosted_expansion_modal/constants.tsx | 4 + .../error_page.scss | 3 + .../error_page.tsx | 70 +++ .../expansion_card.scss | 140 +++++ .../expansion_card.tsx | 268 ++++++++++ .../index.test.tsx | 418 +++++++++++++++ .../self_hosted_expansion_modal/index.tsx | 503 ++++++++++++++++++ .../self_hosted_expansion_modal.scss | 178 +++++++ .../success_page.scss | 20 + .../success_page.tsx | 77 +++ .../self_hosted_purchase_modal/index.tsx | 15 +- webapp/channels/src/utils/constants.tsx | 4 + webapp/channels/src/utils/hosted_customer.ts | 11 + webapp/platform/client/src/client4.ts | 8 + webapp/platform/types/src/hosted_customer.ts | 4 + 24 files changed, 2113 insertions(+), 35 deletions(-) create mode 100644 webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts create mode 100644 webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/index.tsx create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss create mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx diff --git a/webapp/channels/src/actions/hosted_customer.tsx b/webapp/channels/src/actions/hosted_customer.tsx index 81d02c63fd..9ae2d350ba 100644 --- a/webapp/channels/src/actions/hosted_customer.tsx +++ b/webapp/channels/src/actions/hosted_customer.tsx @@ -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, + 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}; + }; +} diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss index 881616d1cf..69ab2d6e1b 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss @@ -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 { @@ -194,4 +209,4 @@ } } } -} +} \ No newline at end of file diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx index ef6a3d387f..551af99212 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx @@ -6,15 +6,20 @@ 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'; describe('components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel', () => { @@ -26,7 +31,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris SkuShortName: 'Enterprise', Name: 'LicenseName', Company: 'Mattermost Inc.', - Users: '1000000', + Users: '1000', }; const initialState: DeepPartial = { @@ -45,10 +50,36 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris }, general: { license, + config: { + BuildEnterpriseReady: 'true', + }, }, preferences: { myPreferences: {}, }, + admin: { + config: { + ServiceSettings: { + SelfHostedExpansion: 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 +111,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('USERS'); + n.childAt(0).type() === 'span' && + !n.childAt(0).text().includes('ACTIVE') && + n.childAt(0).text().includes('USERS'); }); - 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 +177,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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + expect(screen.getByText('+ Add seats')).toBeVisible(); + }); }); diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx index c3f37bfd93..0d4a422a27 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx @@ -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,16 @@ 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'; + +const DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD = 30; +const DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD = 5; export interface EnterpriseEditionProps { openEELicenseModal: () => void; @@ -47,10 +55,12 @@ 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); 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); @@ -63,6 +73,7 @@ const EnterpriseEditionLeftPanel = ({ const skuName = getSkuDisplayName(unsanitizedLicense.SkuShortName, unsanitizedLicense.IsGovSku === 'true'); const expirationDays = getRemainingDaysFromFutureTimestamp(parseInt(unsanitizedLicense.ExpiresAt, 10)); + const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion; const viewPlansButton = ( }
{ @@ -134,6 +159,7 @@ const EnterpriseEditionLeftPanel = ({ fileInputRef, handleChange, statsActiveUsers, + expirationDays, ) } @@ -162,7 +188,7 @@ const EnterpriseEditionLeftPanel = ({ type LegendValues = 'START DATE:' | 'EXPIRES:' | 'USERS:' | '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 +212,26 @@ const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({l >{value} ); + } else if (legend === 'EXPIRES:') { + return ( +
+ {legend} + {value} + {(expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD) && + + {`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`} + + } +
+ ); } return ( @@ -209,6 +255,7 @@ const renderLicenseContent = ( fileInputRef: RefObject, handleChange: () => void, statsActiveUsers: number, + expirationDays: number, ) => { // Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English. @@ -246,7 +293,7 @@ const renderLicenseContent = ( return (
- {licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10)))} + {licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10), expirationDays))}
{renderAddNewLicenseButton(fileInputRef, handleChange)} {renderRemoveButton(handleRemove, isDisabled, removing)} diff --git a/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts b/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts new file mode 100644 index 0000000000..93f2d2092e --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts @@ -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; +} diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts new file mode 100644 index 0000000000..acca8853e0 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -0,0 +1,93 @@ +// 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_expansion_modal/constants'; +import SelfHostedExpansionModal from 'components/self_hosted_expansion_modal'; + +import {useControlModal, ControlModal} from './useControlModal'; + +interface HookOptions{ + onClick?: () => void; + trackingLocation: string; +} + +export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal { + const dispatch = useDispatch(); + const currentUser = useSelector(getCurrentUser); + const controlModal = useControlModal({ + modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, + dialogType: SelfHostedExpansionModal, + }); + + return useMemo(() => { + return { + ...controlModal, + open: async () => { + 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.PURCHASE_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, + }); + + if (options.onClick) { + options.onClick(); + } + + 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.onClick, options.trackingLocation]); +} diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts index d6e3d1cdec..1cbd91372b 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts @@ -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; diff --git a/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx b/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx index 268d8dbb90..fc1b87159e 100644 --- a/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx +++ b/webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx @@ -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_purchase_modal/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 = JSON.parse(JSON.stringify(initialState)); stateOverride.entities!.users!.currentUserId = 'otherUserId'; - renderWithIntlAndStore(
, stateOverride); + renderWithIntlAndStore( +
+ +
, 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(
, initialState); + renderWithIntlAndStore( +
+ +
, initialState, + ); expect(Client4.bootstrapSelfHostedSignup).not.toHaveBeenCalled(); screen.getByText('Reset purchase flow').click(); diff --git a/webapp/channels/src/components/purchase_in_progress_modal/index.tsx b/webapp/channels/src/components/purchase_in_progress_modal/index.tsx index 1a7cf3be80..2e0483a401 100644 --- a/webapp/channels/src/components/purchase_in_progress_modal/index.tsx +++ b/webapp/channels/src/components/purchase_in_progress_modal/index.tsx @@ -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(); }; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx new file mode 100644 index 0000000000..83c13f3567 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const STORAGE_KEY_EXPANSION_IN_PROGRESS = 'EXPANSION_IN_PROGRESS'; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss new file mode 100644 index 0000000000..9a25362e9d --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.scss @@ -0,0 +1,3 @@ +.self_hosted_expansion_failed { + margin-top: 163px; +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx new file mode 100644 index 0000000000..76b9af34d4 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -0,0 +1,70 @@ +// 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 {useSelector} from 'react-redux'; + +import {getCloudContactUsLink, InquiryType} from 'selectors/cloud'; + +import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg'; +import IconMessage from 'components/purchase_modal/icon_message'; + +import './error_page.scss'; + +export default function SelfHostedExpansionErrorPage() { + const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical); + + const formattedTitle = ( + + ); + + const formattedButtonText = ( + + ); + + const formattedSubtitle = ( + + ); + + const tertiaryButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ { + //TODO: Open self hosted expansion modal + }} + formattedTertiaryButonText={tertiaryButtonText} + tertiaryButtonHandler={() => window.open(contactSupportLink, '_blank', 'noreferrer')} + /> +
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss new file mode 100644 index 0000000000..79efc3e325 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss @@ -0,0 +1,140 @@ +.SelfHostedExpansionRHSCard { + display: flex; + max-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-family: 'Open Sans'; + font-size: 14px; + font-weight: 600; + text-align: center; + text-transform: capitalize; + } + + .seatsInput { + width: 73px; + margin-left: auto; + font-family: 'Open Sans'; + 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-family: 'Metropolis'; + font-size: 20px; + font-weight: 400; + text-transform: capitalize; + } + + .usage { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.56); + font-family: 'Open Sans'; + 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-family: 'Open Sans'; + font-size: 14px; + } + + .costPerUser > span:last-child { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-family: 'Open Sans'; + font-size: 12px; + } + + .totalCost { + width: 141px; + } + + .totalCost > span:first-child { + color: var(--sys-denim-center-channel-text); + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 700; + } + + .totalCost > span:last-child { + color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); + font-family: 'Open Sans'; + font-size: 12px; + } + + .costAmount { + margin-right: 0; + margin-left: auto; + font-weight: 700; + } + } + + &__AddSeatsWarning { + display: block; + width: 100%; + height: 35px; + margin-bottom: 15px; + color: var(--dnd-indicator); + font-family: 'Open Sans'; + 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-family: 'Open Sans'; + font-size: 12px; + font-weight: 400; + } +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx new file mode 100644 index 0000000000..d79d6b66fc --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx @@ -0,0 +1,268 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {OutlinedInput} from '@mui/material'; + +import moment from 'moment-timezone'; +import React, {Fragment, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {DocLinks, RecurringIntervals} from 'utils/constants'; +import WarningIcon from 'components/widgets/icons/fa_warning_icon'; + +import './expansion_card.scss'; +import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts'; +import {findSelfHostedProductBySku} from 'utils/hosted_customer'; +import ExternalLink from 'components/external_link'; + +const MONTHS_IN_YEAR = 12; +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; +const MAX_TRANSACTION_VALUE = 1_000_000 - 1; + +interface Props { + canSubmit: boolean; + licensedSeats: number; + initialSeats: number; + submit: () => void; + updateSeats: (seats: number) => void; +} + +export default function SelfHostedExpansionCard(props: Props) { + 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.initialSeats); + const [overMaxSeats, setOverMaxSeats] = useState(false); + const licenseExpiry = parseInt(license.ExpiresAt, 10); + const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats); + const [products] = useGetSelfHostedProducts(); + const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName); + + const getMonthsUntilExpiry = () => { + const now = new Date(); + return Math.ceil((licenseExpiry - now.getTime()) / MILLISECONDS_PER_DAY / 30); + }; + + const getMonthlyPrice = () => { + if (currentProduct === null) { + return 0; + } + + if (currentProduct?.recurring_interval === RecurringIntervals.MONTH) { + return currentProduct.price_per_seat; + } + + const costPerMonth = (currentProduct.price_per_seat / MONTHS_IN_YEAR); + + // Only display 2 decimal places if the cost per month is not evenly divisible over 12 months. + if (!Number.isInteger(costPerMonth)) { + // Keep the return value as a number. + return costPerMonth; + } + + return costPerMonth; + }; + + const getCostPerUser = () => { + if (isNaN(additionalSeats)) { + return 0; + } + const monthlyPrice = getMonthlyPrice(); + const monthsUntilExpiry = getMonthsUntilExpiry(); + return monthlyPrice * monthsUntilExpiry; + }; + + const getTotal = () => { + if (isNaN(additionalSeats)) { + return 0; + } + const monthlyPrice = getMonthlyPrice(); + const monthsUntilExpiry = getMonthsUntilExpiry(); + return additionalSeats * monthlyPrice * 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 * Price Per Seat)) / price_per_seat + const getMaximumAdditionalSeats = () => { + if (currentProduct === null) { + return 0; + } + + let recurringCost = 0; + + // if monthly + if (currentProduct.recurring_interval === RecurringIntervals.MONTH) { + recurringCost = getMonthlyPrice(); + } else { // if yearly + recurringCost = currentProduct.price_per_seat; + } + + const currentPaymentPrice = recurringCost * props.licensedSeats; + const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice; + const remainingSeats = Math.floor(remainingTransactionLimit / recurringCost); + return Math.max(0, remainingSeats); + }; + + const maxAdditionalSeats = getMaximumAdditionalSeats(); + + const handleNewSeatsInputChange = (e: React.ChangeEvent) => { + setOverMaxSeats(false); + + const requestedSeats = parseInt(e.target.value, 10); + + const overMaxAdditionalSeats = requestedSeats > maxAdditionalSeats; + setOverMaxSeats(overMaxAdditionalSeats); + + const finalSeatCount = overMaxAdditionalSeats ? maxAdditionalSeats : requestedSeats; + setAdditionalSeats(finalSeatCount); + + props.updateSeats(finalSeatCount); + }; + + return ( +
+
+ +
+
+
+ {license.SkuShortName} +
+ +
+ +
+
+
+
+ + +
+
+ {invalidAdditionalSeats && !overMaxSeats && + , + }} + /> + } + {overMaxSeats && maxAdditionalSeats > 0 && + , + }} + /> + } + {maxAdditionalSeats === 0 && + , + warningIcon: , + }} + /> + } +
+
+
+ +
+ +
+
+ {'$' + getCostPerUser().toFixed(2)} +
+
+ +
+ +
+ + {'$' + getTotal().toFixed(2)} + +
+ +
+ ( + +
+ + {text} + +
+ ), + }} + /> +
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx new file mode 100644 index 0000000000..05f6386302 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -0,0 +1,418 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {screen, fireEvent} 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} from 'utils/constants'; + +import {DeepPartial} from '@mattermost/types/utilities'; + +import SelfHostedExpansionModal, {makeInitialState, canSubmit, FormState} from './'; + +interface MockCardInputProps { + onCardInputChange: (event: {complete: boolean}) => void; + forwardedRef: React.MutableRefObject; +} + +// number borrowed from stripe +const successCardNumber = '4242424242424242'; +function MockCardInput(props: MockCardInputProps) { + props.forwardedRef.current = { + getCard: () => ({}), + }; + return ( + ) => { + 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_purchase_modal/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, +}); + +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, + }); + }, + confirmSelfHostedSignup: () => Promise.resolve({ + progress: mockCreatedLicense, + license: {Users: existingUsers * 2}, + }), + getClientLicenseOld: () => Promise.resolve({ + data: {Sku: 'Enterprise'}, + }), + }, + }; +}); + +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; + +const initialState: DeepPartial = { + views: { + modals: { + modalState: { + [ModalIdentifiers.SELF_HOSTED_EXPANSION]: { + open: true, + }, + }, + }, + }, + storage: { + storage: {}, + }, + entities: { + admin: { + analytics: { + TOTAL_USERS: existingUsers, + }, + }, + teams: { + currentTeamId: '', + }, + preferences: { + myPreferences: { + theme: {}, + }, + }, + general: { + config: { + EnableDeveloper: 'false', + }, + license: { + Sku: productName, + Users: '50', + }, + }, + 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; +} + +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: '10', +}; + +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); + changeByTestId('seatsInput', form.seats); + + expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled(); + + // not changing the license seats number, + // because it is expected to be pre-filled with the correct number of seats. + + const completeButton = screen.getByText('Complete purchase'); + + if (form === defaultSuccessForm) { + expect(completeButton).toBeEnabled(); + } + + return completeButton; +} + +describe('SelfHostedExpansionModal', () => { + it('renders the form', () => { + renderWithIntlAndStore(
, 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(
, initialState); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + fillForm(defaultSuccessForm); + }); + + it('disables expansion if too few seats or no seats entered', () => { + renderWithIntlAndStore(
, initialState); + fillForm(defaultSuccessForm); + + // 0 seats entered. + const tooFewSeats = 0; + fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, valueEvent(tooFewSeats.toString())); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + expect(screen.getByText('You must add a seat to continue')).toBeVisible(); + + // No seats value entered. + fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, undefined); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + expect(screen.getByText('You must add a seat to continue')).toBeVisible(); + }); + + // it('happy path submit shows success screen', async () => { + // renderWithIntlAndStore(
, initialState); + // expect(screen.getByText('Complete purchase')).toBeDisabled(); + // const upgradeButton = fillForm(defaultSuccessForm); + + // upgradeButton.click(); + // await waitFor(() => expect(screen.getByText(`You're now subscribed to ${productName}`)).toBeTruthy(), {timeout: 1234}); + // }); + + // it('sad path submit shows error screen', async () => { + // renderWithIntlAndStore(
, 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 :: canSubmit', () => { + function makeHappyPathState(): FormState { + return { + address: 'string', + address2: 'string', + city: 'string', + state: 'string', + country: 'string', + postalCode: '12345', + 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); + }); + + // TODO: Needed? + 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); + }); +}); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx new file mode 100644 index 0000000000..914d464877 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -0,0 +1,503 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useRef, useState} from 'react'; + +import {useIntl} from 'react-intl'; + +import {useDispatch, useSelector} from 'react-redux'; + +import {StripeCardElementChangeEvent} from '@stripe/stripe-js'; + +import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg'; +import RootPortal from 'components/root_portal'; +import ContactSalesLink from 'components/self_hosted_purchase_modal/contact_sales_link'; + +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 {COUNTRIES} from 'utils/countries'; +import StateSelector from 'components/payment_form/state_selector'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import DropdownInput from 'components/dropdown_input'; +import StripeProvider from '../self_hosted_purchase_modal/stripe_provider'; + +import {closeModal} from 'actions/views/modals'; +import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getCurrentUser, getFilteredUsersStats} from 'mattermost-redux/selectors/entities/users'; +import {pageVisited} from 'actions/telemetry_actions'; + +import {Client4} from 'mattermost-redux/client'; +import {HostedCustomerTypes} from 'mattermost-redux/action_types'; +import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer'; +import {inferNames} from 'utils/hosted_customer'; +import {SelfHostedSignupCustomerResponse, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; +import {isDevModeEnabled} from 'selectors/general'; +import {getLicenseConfig} from 'mattermost-redux/actions/general'; +import {confirmSelfHostedExpansion} from 'actions/hosted_customer'; +import {DispatchFunc} from 'mattermost-redux/types/actions'; +import {ValueOf} from '@mattermost/types/utilities'; + +import SelfHostedExpansionCard from './expansion_card'; + +import './self_hosted_expansion_modal.scss'; + +import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from './constants'; + +export interface FormState { + address: string; + address2: string; + city: string; + state: string; + country: string; + postalCode: string; + cardName: string; + organization: string; + cardFilled: boolean; + seats: number; + submitting: boolean; + succeeded: boolean; + progressBar: number; + error: string; +} + +export function makeInitialState(seats: number): FormState { + return { + address: '', + address2: '', + city: '', + state: '', + country: '', + postalCode: '', + cardName: '', + organization: '', + cardFilled: false, + seats, + submitting: false, + succeeded: false, + progressBar: 0, + error: '', + }; +} + +export function canSubmit(formState: FormState, progress: ValueOf) { + if (formState.submitting) { + return false; + } + + const validAddress = Boolean( + formState.organization && + formState.address && + formState.city && + formState.state && + formState.postalCode && + formState.country, + ); + 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 && + validSeats, + ); + } + case SelfHostedSignupProgress.START: + case SelfHostedSignupProgress.CREATED_CUSTOMER: + case SelfHostedSignupProgress.CREATED_INTENT: + return Boolean( + validCard && + validAddress && + validSeats, + ); + default: { + return false; + } + } +} + +export default function SelfHostedExpansionModal() { + const dispatch = useDispatch(); + const intl = useIntl(); + const cardRef = useRef(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 activeUsers = useSelector(getFilteredUsersStats)?.total_users_count || 0; + const [additionalSeats, setAdditionalSeats] = useState(activeUsers <= licensedSeats ? 1 : activeUsers - licensedSeats); + + const [stripeLoadHint, setStripeLoadHint] = useState(Math.random()); + const stripeRef = useLoadStripe(stripeLoadHint); + + const initialState = makeInitialState(additionalSeats); + const [formState, setFormState] = useState(initialState); + const [show] = useState(true); + + 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; + 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, + }, + 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'; + // eslint-disable-next-line no-console + console.error(message); + 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, + }, + )); + + 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) { + // eslint-disable-next-line no-console + console.error('could not complete setup', 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 ( + + + { + dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); + resetToken(); + }} + > +
+
+
+

{title}

+ +
{'Questions?'}
+ +
+
+
+ + {intl.formatMessage({ + id: 'payment_form.credit_card', + defaultMessage: 'Credit Card', + })} + +
+ { + setFormState({...formState, cardFilled: event.complete}); + }} + theme={theme} + /> +
+
+ ) => { + setFormState({...formState, organization: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'self_hosted_signup.organization', + defaultMessage: 'Organization Name', + })} + required={true} + /> +
+
+ ) => { + setFormState({...formState, cardName: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.name_on_card', + defaultMessage: 'Name on Card', + })} + required={true} + /> +
+ + {intl.formatMessage({ + id: 'payment_form.billing_address', + defaultMessage: 'Billing address', + })} + + { + setFormState({...formState, country: option.value}); + }} + value={ + formState.country ? {value: formState.country, label: formState.country} : undefined + } + options={COUNTRIES.map((country) => ({ + value: country.name, + label: country.name, + }))} + legend={intl.formatMessage({ + id: 'payment_form.country', + defaultMessage: 'Country', + })} + placeholder={intl.formatMessage({ + id: 'payment_form.country', + defaultMessage: 'Country', + })} + name={'billing_dropdown'} + /> +
+ ) => { + setFormState({...formState, address: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.address', + defaultMessage: 'Address', + })} + required={true} + /> +
+
+ ) => { + setFormState({...formState, address2: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.address_2', + defaultMessage: 'Address 2', + })} + /> +
+
+ ) => { + setFormState({...formState, city: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.city', + defaultMessage: 'City', + })} + required={true} + /> +
+
+
+ { + setFormState({...formState, state}); + }} + /> +
+
+ ) => { + setFormState({...formState, postalCode: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.zipcode', + defaultMessage: 'Zip/Postal Code', + })} + required={true} + /> +
+
+
+
+
+ { + setFormState({...formState, seats}); + setAdditionalSeats(seats); + }} + canSubmit={canSubmitForm} + submit={submit} + licensedSeats={licensedSeats} + initialSeats={additionalSeats} + /> +
+
+ {/* {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE) && hasLicense) && !formState.error && !formState.submitting && ( + + )} + {formState.submitting && ( + + )} + {formState.error && ( + + )} */} +
+ +
+
+
+
+
+ ); +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss new file mode 100644 index 0000000000..beb6c32e08 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -0,0 +1,178 @@ +.SelfHostedExpansionModal { + height: 100%; + + .form-view { + display: flex; + overflow: hidden; + 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; + + .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 { + z-index: 99999; + margin-top: 0; + } + } + + .DropdownInput { + position: relative; + z-index: 999999; + height: 36px; + margin-bottom: 24px; + + .Input_fieldset { + height: 43px; + } + } + + .form-row-third-2 { + width: 34%; + max-width: 144px; + } + + .section-title { + margin-bottom: 24px; + 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 { + height: 32px; + background: inherit; + } + + .Input_wrapper { + margin: 0; + } + } + } + + >.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] { + 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; + } + } +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss new file mode 100644 index 0000000000..7b4bab61f8 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.scss @@ -0,0 +1,20 @@ +.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-family: "Open Sans"; + font-size: 16px; + font-weight: 600; +} + +.self_hosted_expansion_success { + margin-top: 163px; +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx new file mode 100644 index 0000000000..cda885fa0d --- /dev/null +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx @@ -0,0 +1,77 @@ +// 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 {NavLink} from 'react-router-dom'; + +import {useDispatch} from 'react-redux'; + +import IconMessage from 'components/purchase_modal/icon_message'; +import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg'; +import {ConsolePages, ModalIdentifiers} from 'utils/constants'; +import BackgroundSvg from 'components/common/svg_images_components/background_svg'; +import {closeModal} from 'actions/views/modals'; + +import './success_page.scss'; + +export default function SelfHostedExpansionSuccessPage() { + const dispatch = useDispatch(); + const titleText = ( + + ); + + const formattedSubtitleText = ( + Billing section of the system console.'} + values={{ + billing: (billingText: React.ReactNode) => ( + + {billingText} + + ), + }} + /> + ); + + const formattedButtonText = ( + + ); + + const icon = ( + + ); + + return ( +
+ dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL))} + /> +
+ +
+
+ ); +} + diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx index af43bfd229..6dd1332b49 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx @@ -26,6 +26,9 @@ import {GlobalState} from 'types/store'; import {isModalOpen} from 'selectors/views/modals'; import {isDevModeEnabled} from 'selectors/general'; +import {COUNTRIES} from 'utils/countries'; +import {inferNames} from 'utils/hosted_customer'; + import { ModalIdentifiers, StatTypes, @@ -46,7 +49,6 @@ 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, @@ -309,17 +311,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(); diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index ad18170d15..9febc6e1c6 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -461,6 +461,7 @@ export const ModalIdentifiers = { DELETE_WORKSPACE_RESULT: 'delete_workspace_result', SCREENING_IN_PROGRESS: 'screening_in_progress', CONFIRM_SWITCH_TO_YEARLY: 'confirm_switch_to_yearly', + SELF_HOSTED_EXPANSION: 'self_hosted_expansion', }; export const UserStatuses = { @@ -740,6 +741,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', @@ -1069,6 +1071,7 @@ export const CloudLinks = { SELF_HOSTED_SIGNUP: 'https://customers.mattermost.com/signup', DELINQUENCY_DOCS: 'https://docs.mattermost.com/about/cloud-subscriptions.html#failed-or-late-payments', SELF_HOSTED_PRICING: 'https://mattermost.com/pricing/#self-hosted', + SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html', }; export const HostedCustomerLinks = { @@ -1999,6 +2002,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 = { diff --git a/webapp/channels/src/utils/hosted_customer.ts b/webapp/channels/src/utils/hosted_customer.ts index 130bba706c..6ea29269f7 100644 --- a/webapp/channels/src/utils/hosted_customer.ts +++ b/webapp/channels/src/utils/hosted_customer.ts @@ -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, 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(' ')]; +}; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 56e2f552f1..8aca0c6326 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -32,6 +32,7 @@ import { SelfHostedSignupCustomerResponse, SelfHostedSignupSuccessResponse, SelfHostedSignupBootstrapResponse, + SelfHostedExpansionRequest, } from '@mattermost/types/hosted_customer'; import {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories'; @@ -3892,6 +3893,13 @@ export default class Client4 { ); }; + confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => { + return this.doFetch( + `${this.getHostedCustomerRoute()}/confirm?expand=true`, + {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, subscription: expandRequest})}, + ); + } + createPaymentMethod = async () => { return this.doFetch( `${this.getCloudRoute()}/payment`, diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index fcd5b4e70b..bb6b7f856a 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -75,3 +75,7 @@ export interface TrueUpReviewProfileReducer extends TrueUpReviewProfile { export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { getRequestState: RequestState; } + +export interface SelfHostedExpansionRequest { + seats: number; +} From cd5b836015dfa5737e3ab09fc099db9600a0ee2c Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 16:30:07 -0400 Subject: [PATCH 13/56] fix cost per user movement when total is large. --- .../components/self_hosted_expansion_modal/expansion_card.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss index 79efc3e325..0ac7a31fd4 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss @@ -106,6 +106,7 @@ } .costAmount { + width: 100%; margin-right: 0; margin-left: auto; font-weight: 700; From d5a891702c51e198e7e452dfe0f0d174d4022655 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 24 Mar 2023 16:37:45 -0400 Subject: [PATCH 14/56] add license_id param. --- .../src/components/self_hosted_expansion_modal/index.tsx | 1 + webapp/platform/types/src/hosted_customer.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 914d464877..06eda5dafb 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -228,6 +228,7 @@ export default function SelfHostedExpansionModal() { submitProgress, { seats: formState.seats, + license_id: license.ID, }, )); diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index bb6b7f856a..73878205af 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -78,4 +78,5 @@ export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { export interface SelfHostedExpansionRequest { seats: number; + license_id: string; } From 1941349ba9dcacbeea047d12d2b95c791b9d09b5 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 10:01:55 -0400 Subject: [PATCH 15/56] update expansion request. --- webapp/platform/types/src/hosted_customer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/platform/types/src/hosted_customer.ts b/webapp/platform/types/src/hosted_customer.ts index 73878205af..8b3a7099f7 100644 --- a/webapp/platform/types/src/hosted_customer.ts +++ b/webapp/platform/types/src/hosted_customer.ts @@ -76,7 +76,7 @@ export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus { getRequestState: RequestState; } -export interface SelfHostedExpansionRequest { +export type SelfHostedExpansionRequest = { seats: number; license_id: string; } From 923ae3941e5a9426d4928042675d276a97adad24 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 11:49:23 -0400 Subject: [PATCH 16/56] Add shiping address and add back terms. --- .../self_hosted_expansion_modal/index.tsx | 262 ++++++++++-------- .../self_hosted_expansion_modal.scss | 5 +- 2 files changed, 149 insertions(+), 118 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 06eda5dafb..1a2320db7c 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -3,7 +3,7 @@ import React, {useEffect, useRef, useState} from 'react'; -import {useIntl} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; @@ -47,18 +47,34 @@ import SelfHostedExpansionCard from './expansion_card'; import './self_hosted_expansion_modal.scss'; import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from './constants'; +import Address from 'components/self_hosted_purchase_modal/address'; +import ChooseDifferentShipping from 'components/choose_different_shipping'; +import Terms from 'components/self_hosted_purchase_modal/terms'; export interface FormState { + cardName: string; + cardFilled: boolean; + address: string; address2: string; city: string; state: string; country: string; postalCode: string; - cardName: string; organization: string; - cardFilled: boolean; + seats: number; + + shippingSame: boolean; + shippingAddress: string; + shippingAddress2: string; + shippingCity: string; + shippingState: string; + shippingCountry: string; + shippingPostalCode: string; + + agreedTerms: boolean; + submitting: boolean; succeeded: boolean; progressBar: number; @@ -67,16 +83,24 @@ export interface FormState { export function makeInitialState(seats: number): FormState { return { + cardName: '', + cardFilled: false, address: '', address2: '', city: '', state: '', country: '', postalCode: '', - cardName: '', organization: '', - cardFilled: false, + shippingSame: true, + shippingAddress: '', + shippingAddress2: '', + shippingCity: '', + shippingState: '', + shippingCountry: '', + shippingPostalCode: '', seats, + agreedTerms: false, submitting: false, succeeded: false, progressBar: 0, @@ -97,6 +121,18 @@ export function canSubmit(formState: FormState, progress: ValueOf 0; switch (progress) { - case SelfHostedSignupProgress.PAID: - case SelfHostedSignupProgress.CREATED_LICENSE: - case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: - return true; - case SelfHostedSignupProgress.CONFIRMED_INTENT: { - return Boolean( - validAddress && - validSeats, - ); - } - case SelfHostedSignupProgress.START: - case SelfHostedSignupProgress.CREATED_CUSTOMER: - case SelfHostedSignupProgress.CREATED_INTENT: - return Boolean( - validCard && + 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 && - validSeats, - ); - default: { - return false; - } + validShippingAddress && + validSeats && + agreedToTerms + ); + default: { + return false; + } } } @@ -173,6 +210,14 @@ export default function SelfHostedExpansionModal() { 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 { @@ -361,104 +406,87 @@ export default function SelfHostedExpansionModal() { />
- {intl.formatMessage({ - id: 'payment_form.billing_address', - defaultMessage: 'Billing address', - })} + - { +
{ setFormState({...formState, country: option.value}); }} - value={ - formState.country ? {value: formState.country, label: formState.country} : undefined - } - options={COUNTRIES.map((country) => ({ - value: country.name, - label: country.name, - }))} - legend={intl.formatMessage({ - id: 'payment_form.country', - defaultMessage: 'Country', - })} - placeholder={intl.formatMessage({ - id: 'payment_form.country', - defaultMessage: 'Country', - })} - name={'billing_dropdown'} + 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}); + }} /> -
- ) => { - setFormState({...formState, address: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.address', - defaultMessage: 'Address', - })} - required={true} - /> -
-
- ) => { - setFormState({...formState, address2: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.address_2', - defaultMessage: 'Address 2', - })} - /> -
-
- ) => { - setFormState({...formState, city: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.city', - defaultMessage: 'City', - })} - required={true} - /> -
-
-
- { - setFormState({...formState, state}); + { + setFormState({...formState, shippingSame: val}); + }} + /> + {!formState.shippingSame && ( + <> +
+ +
+
{ + 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}); }} /> -
-
- ) => { - setFormState({...formState, postalCode: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.zipcode', - defaultMessage: 'Zip/Postal Code', - })} - required={true} - /> -
-
+ + )} + { + setFormState({...formState, agreedTerms: data}); + }} + />
diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss index beb6c32e08..888532b85e 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -3,7 +3,7 @@ .form-view { display: flex; - overflow: hidden; + overflow-x: hidden; width: 100%; height: 100%; flex-direction: row; @@ -144,6 +144,9 @@ } input[type=checkbox] { + width: 17px; + height: 17px; + flex-shrink: 0; margin-right: 12px; } From 7d152b2cd175890967b683477afccf9d20d0bb18 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 13:21:22 -0400 Subject: [PATCH 17/56] add layers, add missing model. --- model/hosted_customer.go | 10 ++++++++++ plugin/api_timer_layer_generated.go | 2 +- plugin/hooks_timer_layer_generated.go | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/model/hosted_customer.go b/model/hosted_customer.go index 543ea12b74..572537c428 100644 --- a/model/hosted_customer.go +++ b/model/hosted_customer.go @@ -59,3 +59,13 @@ type SelfHostedBillingAccessRequest struct { type SelfHostedBillingAccessResponse struct { Token string `json:"token"` } + +type SelfHostedExpansionRequest struct { + Seats int `json:"seats"` + LicenseId string `json:"license_id"` +} + +type SelfHostedExpansionConfirmPaymentMethodRequest struct { + StripeSetupIntentID string `json:"stripe_setup_intent_id"` + ExpandRequest SelfHostedExpansionRequest `json:"expand_request"` +} diff --git a/plugin/api_timer_layer_generated.go b/plugin/api_timer_layer_generated.go index a084188c62..c54c6ac7bb 100644 --- a/plugin/api_timer_layer_generated.go +++ b/plugin/api_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" ) type apiTimerLayer struct { diff --git a/plugin/hooks_timer_layer_generated.go b/plugin/hooks_timer_layer_generated.go index 6093048d54..87e79ca7e6 100644 --- a/plugin/hooks_timer_layer_generated.go +++ b/plugin/hooks_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" ) type hooksTimerLayer struct { From c4f6f21ca0821e1f478a7394ce449aa264caec4d Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 27 Mar 2023 13:53:27 -0400 Subject: [PATCH 18/56] lint. --- .../enterprise_edition.scss | 2 +- .../self_hosted_expansion_modal/index.tsx | 47 +++++++++---------- .../self_hosted_expansion_modal.scss | 2 +- webapp/platform/client/src/client4.ts | 2 +- 4 files changed, 25 insertions(+), 28 deletions(-) diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss index 69ab2d6e1b..acce9b4621 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition.scss @@ -209,4 +209,4 @@ } } } -} \ No newline at end of file +} diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 1a2320db7c..da54d4ce65 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -19,10 +19,7 @@ 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 {COUNTRIES} from 'utils/countries'; -import StateSelector from 'components/payment_form/state_selector'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import DropdownInput from 'components/dropdown_input'; import StripeProvider from '../self_hosted_purchase_modal/stripe_provider'; import {closeModal} from 'actions/views/modals'; @@ -124,11 +121,11 @@ export function canSubmit(formState: FormState, progress: ValueOf 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 && + 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; - } + agreedToTerms, + ); + default: { + return false; + } } } @@ -273,7 +270,7 @@ export default function SelfHostedExpansionModal() { submitProgress, { seats: formState.seats, - license_id: license.ID, + license_id: license.Id, }, )); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss index 888532b85e..7166c369e7 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -3,7 +3,6 @@ .form-view { display: flex; - overflow-x: hidden; width: 100%; height: 100%; flex-direction: row; @@ -16,6 +15,7 @@ font-family: "Open Sans"; font-size: 16px; font-weight: 600; + overflow-x: hidden; .title { font-size: 22px; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 8aca0c6326..a71207f42f 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -3896,7 +3896,7 @@ export default class Client4 { confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => { return this.doFetch( `${this.getHostedCustomerRoute()}/confirm?expand=true`, - {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, subscription: expandRequest})}, + {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, expand_request: expandRequest})}, ); } From 8b76d05f986e466fd0490ce98f46a6682a888de7 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 10:47:47 -0400 Subject: [PATCH 19/56] i18n. --- webapp/channels/src/i18n/en.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index b7458e403a..2510bf6364 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1314,6 +1314,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?", @@ -4700,6 +4701,25 @@ "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_additional_seats_limit_warning": "{warningIcon} Transaction amount limit reached.{break}Please contact sales", + "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_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.", + "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 section of the system console.", + "self_hosted_expansion.paymentFailed": "Payment failed. Please try again or contact support.", "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", From 2dcc12d242ed70f0d7203a991b09eb9ed0c512a2 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 11:30:15 -0400 Subject: [PATCH 20/56] fix links, types. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 2 +- .../components/self_hosted_expansion_modal/error_page.tsx | 5 ++--- .../components/self_hosted_expansion_modal/index.test.tsx | 8 ++++++++ .../src/components/self_hosted_expansion_modal/index.tsx | 2 +- .../src/components/self_hosted_purchase_modal/index.tsx | 1 + webapp/channels/src/utils/constants.tsx | 4 ++-- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index acca8853e0..ce77aff2a8 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -26,7 +26,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) const dispatch = useDispatch(); const currentUser = useSelector(getCurrentUser); const controlModal = useControlModal({ - modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, + modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, dialogType: SelfHostedExpansionModal, }); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index 76b9af34d4..c811fc4c5c 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -4,9 +4,8 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; -import {useSelector} from 'react-redux'; +import {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm'; -import {getCloudContactUsLink, InquiryType} from 'selectors/cloud'; import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg'; import IconMessage from 'components/purchase_modal/icon_message'; @@ -14,7 +13,7 @@ import IconMessage from 'components/purchase_modal/icon_message'; import './error_page.scss'; export default function SelfHostedExpansionErrorPage() { - const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical); + const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error'); const formattedTitle = ( { 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, diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index da54d4ce65..00672eddf2 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -336,7 +336,7 @@ export default function SelfHostedExpansionModal() { show={show} ariaLabelledBy='self_hosted_expansion_modal_title' onClose={() => { - dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); + dispatch(closeModal(ModalIdentifiers.EXPANSION_IN_PROGRESS)); resetToken(); }} > diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx index 6dd1332b49..aa8f41fdd4 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx @@ -71,6 +71,7 @@ import {SetPrefix, UnionSetActions} from './types'; import './self_hosted_purchase_modal.scss'; import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from './constants'; +import {inferNames} from 'utils/hosted_customer'; export interface State { diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 9febc6e1c6..e20039599e 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -461,7 +461,7 @@ export const ModalIdentifiers = { DELETE_WORKSPACE_RESULT: 'delete_workspace_result', SCREENING_IN_PROGRESS: 'screening_in_progress', CONFIRM_SWITCH_TO_YEARLY: 'confirm_switch_to_yearly', - SELF_HOSTED_EXPANSION: 'self_hosted_expansion', + EXPANSION_IN_PROGRESS: 'expansion_in_progress', }; export const UserStatuses = { @@ -1071,7 +1071,6 @@ export const CloudLinks = { SELF_HOSTED_SIGNUP: 'https://customers.mattermost.com/signup', DELINQUENCY_DOCS: 'https://docs.mattermost.com/about/cloud-subscriptions.html#failed-or-late-payments', SELF_HOSTED_PRICING: 'https://mattermost.com/pricing/#self-hosted', - SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html', }; export const HostedCustomerLinks = { @@ -1091,6 +1090,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', }; export const LicenseLinks = { From 4856f846dab0c1825fd90a9b3bdf727515593dcb Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 11:52:21 -0400 Subject: [PATCH 21/56] lint. --- .../src/components/self_hosted_expansion_modal/error_page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index c811fc4c5c..80e852d584 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -6,7 +6,6 @@ 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'; From 43b25fa4881d4e5a931b1c41978da20f0b94da40 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 13:46:40 -0400 Subject: [PATCH 22/56] fix types. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 4 ++-- .../src/components/self_hosted_expansion_modal/index.tsx | 2 +- webapp/channels/src/utils/constants.tsx | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index ce77aff2a8..bda0099ca8 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -26,7 +26,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) const dispatch = useDispatch(); const currentUser = useSelector(getCurrentUser); const controlModal = useControlModal({ - modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, + modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, dialogType: SelfHostedExpansionModal, }); @@ -42,7 +42,7 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) // is already trying to purchase. Notify them of this // and request the exit that purchase flow before attempting again. dispatch(openModal({ - modalId: ModalIdentifiers.PURCHASE_IN_PROGRESS, + modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, dialogType: PurchaseInProgressModal, dialogProps: { purchaserEmail: currentUser.email, diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 00672eddf2..da54d4ce65 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -336,7 +336,7 @@ export default function SelfHostedExpansionModal() { show={show} ariaLabelledBy='self_hosted_expansion_modal_title' onClose={() => { - dispatch(closeModal(ModalIdentifiers.EXPANSION_IN_PROGRESS)); + dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); resetToken(); }} > diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index e20039599e..dd620c2840 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -462,6 +462,7 @@ export const ModalIdentifiers = { 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', }; export const UserStatuses = { From 406df06ef68779c8b8da11e73dbcd3d17c0e2dbd Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 14:37:33 -0400 Subject: [PATCH 23/56] Revert "add layers, add missing model." This reverts commit 7d152b2cd175890967b683477afccf9d20d0bb18. --- model/hosted_customer.go | 10 ---------- plugin/api_timer_layer_generated.go | 2 +- plugin/hooks_timer_layer_generated.go | 2 +- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/model/hosted_customer.go b/model/hosted_customer.go index 572537c428..543ea12b74 100644 --- a/model/hosted_customer.go +++ b/model/hosted_customer.go @@ -59,13 +59,3 @@ type SelfHostedBillingAccessRequest struct { type SelfHostedBillingAccessResponse struct { Token string `json:"token"` } - -type SelfHostedExpansionRequest struct { - Seats int `json:"seats"` - LicenseId string `json:"license_id"` -} - -type SelfHostedExpansionConfirmPaymentMethodRequest struct { - StripeSetupIntentID string `json:"stripe_setup_intent_id"` - ExpandRequest SelfHostedExpansionRequest `json:"expand_request"` -} diff --git a/plugin/api_timer_layer_generated.go b/plugin/api_timer_layer_generated.go index c54c6ac7bb..a084188c62 100644 --- a/plugin/api_timer_layer_generated.go +++ b/plugin/api_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" ) type apiTimerLayer struct { diff --git a/plugin/hooks_timer_layer_generated.go b/plugin/hooks_timer_layer_generated.go index 87e79ca7e6..6093048d54 100644 --- a/plugin/hooks_timer_layer_generated.go +++ b/plugin/hooks_timer_layer_generated.go @@ -11,8 +11,8 @@ import ( "net/http" timePkg "time" - "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/server/channels/einterfaces" + "github.com/mattermost/mattermost-server/v6/model" ) type hooksTimerLayer struct { From 93214efc54f980fe2a7014c4650c3ae5c1124037 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 14:41:28 -0400 Subject: [PATCH 24/56] add further check for ability to expand. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index bda0099ca8..42ef497e86 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -16,6 +16,7 @@ import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_expansio import SelfHostedExpansionModal from 'components/self_hosted_expansion_modal'; import {useControlModal, ControlModal} from './useControlModal'; +import useCanSelfHostedExpand from './useCanSelfHostedExpand'; interface HookOptions{ onClick?: () => void; @@ -25,6 +26,7 @@ interface HookOptions{ 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, @@ -34,6 +36,10 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) 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. From 5df7e62f8ab7a65ceefbdace47939a42936d9a14 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 14:50:30 -0400 Subject: [PATCH 25/56] lint. --- .../src/components/self_hosted_purchase_modal/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx index aa8f41fdd4..d4b55b80a4 100644 --- a/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchase_modal/index.tsx @@ -26,7 +26,6 @@ import {GlobalState} from 'types/store'; import {isModalOpen} from 'selectors/views/modals'; import {isDevModeEnabled} from 'selectors/general'; -import {COUNTRIES} from 'utils/countries'; import {inferNames} from 'utils/hosted_customer'; import { @@ -71,7 +70,6 @@ import {SetPrefix, UnionSetActions} from './types'; import './self_hosted_purchase_modal.scss'; import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from './constants'; -import {inferNames} from 'utils/hosted_customer'; export interface State { From 58fead7d9dc381e5254b2d1861ea693b32fff27d Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 28 Mar 2023 16:15:14 -0400 Subject: [PATCH 26/56] add more e2e tests. --- .../expansion_card.scss | 6 +- .../expansion_card.tsx | 11 ++-- .../index.test.tsx | 64 ++++++++++++++++--- .../self_hosted_expansion_modal/index.tsx | 2 + 4 files changed, 64 insertions(+), 19 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss index 0ac7a31fd4..f9d7ca4d36 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.scss @@ -88,18 +88,18 @@ font-size: 12px; } - .totalCost { + .totalCostWarning { width: 141px; } - .totalCost > span:first-child { + .totalCostWarning > span:first-child { color: var(--sys-denim-center-channel-text); font-family: 'Open Sans'; font-size: 14px; font-weight: 700; } - .totalCost > span:last-child { + .totalCostWarning > span:last-child { color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); font-family: 'Open Sans'; font-size: 12px; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx index d79d6b66fc..6eadd2de6e 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx @@ -18,7 +18,6 @@ import {findSelfHostedProductBySku} from 'utils/hosted_customer'; import ExternalLink from 'components/external_link'; const MONTHS_IN_YEAR = 12; -const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; const MAX_TRANSACTION_VALUE = 1_000_000 - 1; interface Props { @@ -35,14 +34,14 @@ export default function SelfHostedExpansionCard(props: Props) { const endsAt = moment(parseInt(license.ExpiresAt, 10)).format('MMM. D, YYYY'); const [additionalSeats, setAdditionalSeats] = useState(props.initialSeats); const [overMaxSeats, setOverMaxSeats] = useState(false); - const licenseExpiry = parseInt(license.ExpiresAt, 10); + const licenseExpiry = new Date(parseInt(license.ExpiresAt, 10)); const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats); const [products] = useGetSelfHostedProducts(); const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName); const getMonthsUntilExpiry = () => { const now = new Date(); - return Math.ceil((licenseExpiry - now.getTime()) / MILLISECONDS_PER_DAY / 30); + return (licenseExpiry.getMonth() - now.getMonth()) + 12 * (licenseExpiry.getFullYear() - now.getFullYear()); }; const getMonthlyPrice = () => { @@ -209,7 +208,7 @@ export default function SelfHostedExpansionCard(props: Props) {
{'$' + getCostPerUser().toFixed(2)}
-
+
- + {'$' + getTotal().toFixed(2)}
diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx index c44aae70a3..0097108ae8 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; -import {screen, fireEvent} from '@testing-library/react'; +import {screen, fireEvent, waitFor} from '@testing-library/react'; import {GlobalState} from 'types/store'; @@ -11,7 +11,7 @@ import {SelfHostedSignupForm, SelfHostedSignupProgress} from '@mattermost/types/ import {renderWithIntlAndStore} from 'tests/react_testing_utils'; import {TestHelper as TH} from 'utils/test_helper'; -import {SelfHostedProducts, ModalIdentifiers} from 'utils/constants'; +import {SelfHostedProducts, ModalIdentifiers, RecurringIntervals} from 'utils/constants'; import {DeepPartial} from '@mattermost/types/utilities'; @@ -76,6 +76,7 @@ const mockProfessionalProduct = TH.getProductMock({ name: 'Professional', sku: SelfHostedProducts.PROFESSIONAL, price_per_seat: 7.5, + recurring_interval: RecurringIntervals.MONTH }); jest.mock('mattermost-redux/client', () => { @@ -129,6 +130,11 @@ jest.mock('utils/hosted_customer', () => { const productName = SelfHostedProducts.PROFESSIONAL; +// Licensed expiry set as 3 months from the current date (rolls over to new years). +const licenseExpiry = new Date(); +const monthsUntilLicenseExpiry = 3; +licenseExpiry.setMonth(licenseExpiry.getMonth() + monthsUntilLicenseExpiry); + const initialState: DeepPartial = { views: { modals: { @@ -143,11 +149,6 @@ const initialState: DeepPartial = { storage: {}, }, entities: { - admin: { - analytics: { - TOTAL_USERS: existingUsers, - }, - }, teams: { currentTeamId: '', }, @@ -163,6 +164,7 @@ const initialState: DeepPartial = { license: { Sku: productName, Users: '50', + ExpiresAt: licenseExpiry.getTime().toString() }, }, cloud: { @@ -224,6 +226,7 @@ interface PurchaseForm { state: string; zip: string; seats: string; + agree: boolean; } const defaultSuccessForm: PurchaseForm = { @@ -236,6 +239,7 @@ const defaultSuccessForm: PurchaseForm = { state: 'MN', zip: '55423', seats: '10', + agree: true, }; function fillForm(form: PurchaseForm) { @@ -248,6 +252,9 @@ function fillForm(form: PurchaseForm) { selectDropdownValue('selfHostedExpansionStateSelector', form.state); changeByPlaceholder('Zip/Postal Code', form.zip); changeByTestId('seatsInput', form.seats); + if (form.agree) { + fireEvent.click(screen.getByText('I have read and agree', {exact: false})); + } expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled(); @@ -263,7 +270,7 @@ function fillForm(form: PurchaseForm) { return completeButton; } -describe('SelfHostedExpansionModal', () => { +describe('SelfHostedExpansionModal Open', () => { it('renders the form', () => { renderWithIntlAndStore(
, initialState); @@ -321,7 +328,45 @@ describe('SelfHostedExpansionModal', () => { // }); }); -describe('SelfHostedExpansionModal :: canSubmit', () => { +describe('SelfHostedExpansionModal RHS Card', () => { + it("New seats input should be pre-populated with the difference from the active users and licensed seats", () => { + renderWithIntlAndStore(
, initialState); + + const expectedPrePopulatedSeats = (initialState.entities?.users?.filteredStats?.total_users_count || 1) - parseInt(initialState.entities?.general?.license?.Users || '0', 10); + + const seatsField = screen.getByTestId('seatsInput').querySelector('input'); + expect(seatsField).toBeInTheDocument(); + expect(seatsField?.value).toBe(expectedPrePopulatedSeats.toString()); + }); + + it("Cost per User should be represented as the current subscription price multiplied by the remaining months", () => { + renderWithIntlAndStore(
, initialState); + + const expectedCostPerUser = monthsUntilLicenseExpiry * mockProfessionalProduct.price_per_seat; + + const costPerUser = document.getElementsByClassName('costPerUser')[0]; + expect(costPerUser).toBeInTheDocument(); + expect(costPerUser.innerHTML).toContain('Cost per user
$' + 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(
, 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.innerHTML).toContain('$' + expectedTotalCost) + }); +}); + +describe('SelfHostedExpansionModal Submit', () => { function makeHappyPathState(): FormState { return { address: 'string', @@ -366,7 +411,6 @@ describe('SelfHostedExpansionModal :: canSubmit', () => { expect(canSubmit(state, SelfHostedSignupProgress.PAID)).toBe(true); }); - // TODO: Needed? it('if created subscription, can submit', () => { const state = makeInitialState(1); state.submitting = false; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index da54d4ce65..845fafcfe2 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -409,6 +409,7 @@ export default function SelfHostedExpansionModal() { />
{ @@ -450,6 +451,7 @@ export default function SelfHostedExpansionModal() { />
{ From 62b46a630d0f48f461f4f94512c4533177b0515a Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 29 Mar 2023 12:29:57 -0400 Subject: [PATCH 27/56] lint. --- .../self_hosted_expansion_modal/index.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx index 0097108ae8..7e19fb88cf 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -76,7 +76,7 @@ const mockProfessionalProduct = TH.getProductMock({ name: 'Professional', sku: SelfHostedProducts.PROFESSIONAL, price_per_seat: 7.5, - recurring_interval: RecurringIntervals.MONTH + recurring_interval: RecurringIntervals.MONTH, }); jest.mock('mattermost-redux/client', () => { @@ -164,7 +164,7 @@ const initialState: DeepPartial = { license: { Sku: productName, Users: '50', - ExpiresAt: licenseExpiry.getTime().toString() + ExpiresAt: licenseExpiry.getTime().toString(), }, }, cloud: { @@ -329,7 +329,7 @@ describe('SelfHostedExpansionModal Open', () => { }); describe('SelfHostedExpansionModal RHS Card', () => { - it("New seats input should be pre-populated with the difference from the active users and licensed seats", () => { + it('New seats input should be pre-populated with the difference from the active users and licensed seats', () => { renderWithIntlAndStore(
, initialState); const expectedPrePopulatedSeats = (initialState.entities?.users?.filteredStats?.total_users_count || 1) - parseInt(initialState.entities?.general?.license?.Users || '0', 10); @@ -339,7 +339,7 @@ describe('SelfHostedExpansionModal RHS Card', () => { expect(seatsField?.value).toBe(expectedPrePopulatedSeats.toString()); }); - it("Cost per User should be represented as the current subscription price multiplied by the remaining months", () => { + it('Cost per User should be represented as the current subscription price multiplied by the remaining months', () => { renderWithIntlAndStore(
, initialState); const expectedCostPerUser = monthsUntilLicenseExpiry * mockProfessionalProduct.price_per_seat; @@ -350,10 +350,10 @@ describe('SelfHostedExpansionModal RHS Card', () => { const costAmount = document.getElementsByClassName('costAmount')[0]; expect(costAmount).toBeInTheDocument(); - expect(costAmount.innerHTML).toContain('$' + expectedCostPerUser) + 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", () => { + it('Total cost User should be represented as the current subscription price multiplied by the remaining months multiplied by the number of users', () => { renderWithIntlAndStore(
, initialState); const seatsInputValue = 100; changeByTestId('seatsInput', seatsInputValue.toString()); @@ -362,7 +362,7 @@ describe('SelfHostedExpansionModal RHS Card', () => { const costAmount = document.getElementsByClassName('totalCostAmount')[0]; expect(costAmount).toBeInTheDocument(); - expect(costAmount.innerHTML).toContain('$' + expectedTotalCost) + expect(costAmount.innerHTML).toContain('$' + expectedTotalCost); }); }); From 6fe45fe890973007f64e2abb9ccf58db8ff4ed3d Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 31 Mar 2023 13:38:37 -0400 Subject: [PATCH 28/56] lint. --- .../components/self_hosted_expansion_modal/expansion_card.tsx | 3 ++- .../src/components/self_hosted_expansion_modal/index.test.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx index 6eadd2de6e..47ecfa49aa 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx @@ -41,7 +41,7 @@ export default function SelfHostedExpansionCard(props: Props) { const getMonthsUntilExpiry = () => { const now = new Date(); - return (licenseExpiry.getMonth() - now.getMonth()) + 12 * (licenseExpiry.getFullYear() - now.getFullYear()); + return (licenseExpiry.getMonth() - now.getMonth()) + (MONTHS_IN_YEAR * (licenseExpiry.getFullYear() - now.getFullYear())); }; const getMonthlyPrice = () => { @@ -208,6 +208,7 @@ export default function SelfHostedExpansionCard(props: Props) {
Date: Fri, 31 Mar 2023 15:53:25 -0400 Subject: [PATCH 29/56] Fix tests. --- .../self_hosted_expansion_modal/expansion_card.tsx | 2 +- .../self_hosted_expansion_modal/index.test.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx index 47ecfa49aa..caa3afb2d9 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/expansion_card.tsx @@ -208,7 +208,7 @@ export default function SelfHostedExpansionCard(props: Props) {
void; @@ -131,9 +132,9 @@ jest.mock('utils/hosted_customer', () => { const productName = SelfHostedProducts.PROFESSIONAL; // Licensed expiry set as 3 months from the current date (rolls over to new years). -const licenseExpiry = new Date(); +let licenseExpiry = moment() const monthsUntilLicenseExpiry = 3; -licenseExpiry.setMonth(licenseExpiry.getMonth() + monthsUntilLicenseExpiry); +licenseExpiry = licenseExpiry.add(monthsUntilLicenseExpiry, 'months'); const initialState: DeepPartial = { views: { @@ -164,7 +165,7 @@ const initialState: DeepPartial = { license: { Sku: productName, Users: '50', - ExpiresAt: licenseExpiry.getTime().toString(), + ExpiresAt: licenseExpiry.valueOf().toString(), }, }, cloud: { @@ -362,7 +363,7 @@ describe('SelfHostedExpansionModal RHS Card', () => { const costAmount = document.getElementsByClassName('totalCostAmount')[0]; expect(costAmount).toBeInTheDocument(); - expect(costAmount.innerHTML).toContain('$' + expectedTotalCost); + expect(costAmount).toHaveTextContent('$' + expectedTotalCost); }); }); From 379f19701e25af00726c21831260c1e70d378db7 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Fri, 31 Mar 2023 16:21:36 -0400 Subject: [PATCH 30/56] Add success, error, loading modals, lint. --- .../useControlSelfHostedExpansionModal.ts | 2 +- .../error_page.tsx | 17 ++++++++++++++-- .../expansion_card.tsx | 4 ++-- .../index.test.tsx | 2 +- .../self_hosted_expansion_modal/index.tsx | 20 +++++++++++-------- .../success_page.tsx | 11 ++++++++-- 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index 42ef497e86..db44768fd0 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -20,7 +20,7 @@ import useCanSelfHostedExpand from './useCanSelfHostedExpand'; interface HookOptions{ onClick?: () => void; - trackingLocation: string; + trackingLocation?: string; } export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal { diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index 80e852d584..2b9748de26 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -11,7 +11,11 @@ import IconMessage from 'components/purchase_modal/icon_message'; import './error_page.scss'; -export default function SelfHostedExpansionErrorPage() { +interface Props { + canRetry: boolean; +} + +export default function SelfHostedExpansionErrorPage(props: Props) { const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error'); const formattedTitle = ( @@ -21,13 +25,22 @@ export default function SelfHostedExpansionErrorPage() { /> ); - const formattedButtonText = ( + let formattedButtonText = ( ); + if (!props.canRetry) { + formattedButtonText = ( + + ); + } + const formattedSubtitle = ( ( - + <>
{text} -
+ ), }} /> diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx index 524cb26e1f..9ca3718f22 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -132,7 +132,7 @@ jest.mock('utils/hosted_customer', () => { const productName = SelfHostedProducts.PROFESSIONAL; // Licensed expiry set as 3 months from the current date (rolls over to new years). -let licenseExpiry = moment() +let licenseExpiry = moment(); const monthsUntilLicenseExpiry = 3; licenseExpiry = licenseExpiry.add(monthsUntilLicenseExpiry, 'months'); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index 845fafcfe2..efc62324a7 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -12,6 +12,9 @@ import {StripeCardElementChangeEvent} from '@stripe/stripe-js'; import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg'; import RootPortal from 'components/root_portal'; import ContactSalesLink from 'components/self_hosted_purchase_modal/contact_sales_link'; +import ErrorPage from 'components/self_hosted_expansion_modal/error_page'; +import SuccessPage from 'components/self_hosted_expansion_modal/success_page'; +import Submitting from 'components/self_hosted_purchase_modal/submitting'; import useLoadStripe from 'components/common/hooks/useLoadStripe'; import CardInput, {CardInputType} from 'components/payment_form/card_input'; @@ -47,6 +50,7 @@ import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from './constants'; import Address from 'components/self_hosted_purchase_modal/address'; import ChooseDifferentShipping from 'components/choose_different_shipping'; import Terms from 'components/self_hosted_purchase_modal/terms'; +import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal'; export interface FormState { cardName: string; @@ -163,6 +167,7 @@ export function canSubmit(formState: FormState, progress: ValueOf(); const intl = useIntl(); const cardRef = useRef(null); @@ -173,6 +178,7 @@ export default function SelfHostedExpansionModal() { const license = useSelector(getLicense); const licensedSeats = parseInt(license.Users, 10); + const currentPlan = license.SkuName; const activeUsers = useSelector(getFilteredUsersStats)?.total_users_count || 0; const [additionalSeats, setAdditionalSeats] = useState(activeUsers <= licensedSeats ? 1 : activeUsers - licensedSeats); @@ -182,6 +188,7 @@ export default function SelfHostedExpansionModal() { const initialState = makeInitialState(additionalSeats); const [formState, setFormState] = useState(initialState); const [show] = useState(true); + const canRetry = formState.error !== '422'; const title = intl.formatMessage({ id: 'self_hosted_expansion.expansion_modal.title', @@ -501,25 +508,22 @@ export default function SelfHostedExpansionModal() { /> - {/* {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE) && hasLicense) && !formState.error && !formState.submitting && ( + {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && ( )} - {formState.submitting && ( + {formState.submitting && !formState.error && ( )} {formState.error && ( - )} */} + )}
diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx index cda885fa0d..0e08a50b22 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx @@ -16,7 +16,11 @@ import {closeModal} from 'actions/views/modals'; import './success_page.scss'; -export default function SelfHostedExpansionSuccessPage() { +interface Props { + onClose: () => void; +} + +export default function SelfHostedExpansionSuccessPage(props: Props) { const dispatch = useDispatch(); const titleText = ( dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL))} + buttonHandler={() => { + props.onClose(); + dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL)); + }} />
From c0d4b0dfdcc3e0958df375d001d3123666abaedb Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 3 Apr 2023 10:01:40 -0400 Subject: [PATCH 31/56] Fixup i18n, error/success/progress modals. --- .../error_page.tsx | 4 +- .../index.test.tsx | 43 ++++++++++--------- .../self_hosted_expansion_modal/index.tsx | 4 -- .../success_page.tsx | 2 +- webapp/channels/src/i18n/en.json | 1 + 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx index 2b9748de26..dc68cb4137 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/error_page.tsx @@ -27,7 +27,7 @@ export default function SelfHostedExpansionErrorPage(props: Props) { let formattedButtonText = ( ); @@ -35,7 +35,7 @@ export default function SelfHostedExpansionErrorPage(props: Props) { if (!props.canRetry) { formattedButtonText = ( ); diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx index 9ca3718f22..e793b267e3 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; -import {screen, fireEvent} from '@testing-library/react'; +import {screen, fireEvent, waitFor} from '@testing-library/react'; import {GlobalState} from 'types/store'; @@ -252,15 +252,15 @@ function fillForm(form: PurchaseForm) { changeByPlaceholder('City', form.city); selectDropdownValue('selfHostedExpansionStateSelector', form.state); changeByPlaceholder('Zip/Postal Code', form.zip); - changeByTestId('seatsInput', form.seats); if (form.agree) { fireEvent.click(screen.getByText('I have read and agree', {exact: false})); } + // not changing the license seats number, because it is expected to be pre-filled, + // with the correct number of seats (current active users - current licensed seats, or 1 if the difference is 0). + expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled(); - // not changing the license seats number, - // because it is expected to be pre-filled with the correct number of seats. const completeButton = screen.getByText('Complete purchase'); @@ -307,26 +307,27 @@ describe('SelfHostedExpansionModal Open', () => { expect(screen.getByText('You must add a seat to continue')).toBeVisible(); }); - // it('happy path submit shows success screen', async () => { - // renderWithIntlAndStore(
, initialState); - // expect(screen.getByText('Complete purchase')).toBeDisabled(); - // const upgradeButton = fillForm(defaultSuccessForm); + it('happy path submit shows success screen', async () => { + renderWithIntlAndStore(
, initialState); + expect(screen.getByText('Complete purchase')).toBeDisabled(); + const upgradeButton = fillForm(defaultSuccessForm); - // upgradeButton.click(); - // await waitFor(() => expect(screen.getByText(`You're now subscribed to ${productName}`)).toBeTruthy(), {timeout: 1234}); - // }); + expect(upgradeButton).toBeEnabled(); + upgradeButton.click(); + await waitFor(() => expect(screen.getByText('You\'ve successfully updated your license seat count')).toBeTruthy(), {timeout: 1234}); + }); - // it('sad path submit shows error screen', async () => { - // renderWithIntlAndStore(
, initialState); - // expect(screen.getByText('Complete purchase')).toBeDisabled(); - // fillForm(defaultSuccessForm); - // changeByPlaceholder('Organization Name', failOrg); + it('sad path submit shows error screen', async () => { + renderWithIntlAndStore(
, 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}); - // }); + 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', () => { diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx index efc62324a7..4bb217bc32 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.tsx @@ -252,8 +252,6 @@ export default function SelfHostedExpansionModal() { const card = cardRef.current?.getCard(); if (!card) { const message = 'Failed to get card when it was expected'; - // eslint-disable-next-line no-console - console.error(message); setFormState({...formState, error: message}); return; } @@ -302,8 +300,6 @@ export default function SelfHostedExpansionModal() { } setFormState({...formState, submitting: false}); } catch (e) { - // eslint-disable-next-line no-console - console.error('could not complete setup', e); setFormState({...formState, error: 'unable to complete signup'}); } }; diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx index 0e08a50b22..77916c9de7 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/success_page.tsx @@ -25,7 +25,7 @@ export default function SelfHostedExpansionSuccessPage(props: Props) { const titleText = ( ); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 88c3105fb1..93298430ab 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4724,6 +4724,7 @@ "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 section 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", From 09e93fc5e4626afa11bfbf6d7751c937f17f75c6 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 3 Apr 2023 10:15:27 -0400 Subject: [PATCH 32/56] lint. --- .../src/components/self_hosted_expansion_modal/index.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx index e793b267e3..a0d1a62a6d 100644 --- a/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_expansion_modal/index.test.tsx @@ -261,7 +261,6 @@ function fillForm(form: PurchaseForm) { expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled(); - const completeButton = screen.getByText('Complete purchase'); if (form === defaultSuccessForm) { From 0ce1b6c12c36a4bc55dc1ae37730d7108c039e37 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 3 Apr 2023 17:02:54 -0400 Subject: [PATCH 33/56] move self hosted purchase modals into one folder for shared resources, update banners to redirect to the self hosted purchase modal (in the case of air gapped, link to CWS. In the future, this will be a modal). --- .../enterprise_edition_left_panel.tsx | 16 ++- .../overage_users_banner/index.tsx | 15 ++- .../useControlSelfHostedExpansionModal.ts | 4 +- .../useControlSelfHostedPurchaseModal.ts | 4 +- .../overage_users_banner_notice/index.tsx | 31 ++++- .../purchase_in_progress_modal/index.test.tsx | 2 +- .../self_hosted_expansion_modal/constants.tsx | 4 - .../address.tsx | 0 .../constants.ts | 1 + .../contact_sales_link.tsx | 0 .../error_page.scss | 0 .../error_page.tsx | 5 +- .../expansion_card.scss | 0 .../expansion_card.tsx | 0 .../index.test.tsx | 0 .../self_hosted_expansion_modal/index.tsx | 30 +++-- .../self_hosted_expansion_modal.scss | 4 + .../submitting.tsx | 122 ++++++++++++++++++ .../success_page.scss | 0 .../success_page.tsx | 0 .../self_hosted_purchase_modal/error.tsx | 0 .../self_hosted_purchase_modal/index.test.tsx | 2 +- .../self_hosted_purchase_modal/index.tsx | 10 +- .../self_hosted_card.tsx | 4 +- .../self_hosted_purchase_modal.scss | 0 .../self_hosted_purchase_modal/submitting.tsx | 0 .../success_page.scss | 0 .../success_page.tsx | 0 .../self_hosted_purchase_modal/terms.tsx | 0 .../self_hosted_purchase_modal/types.ts | 0 .../self_hosted_purchase_modal/useNoEscape.ts | 0 .../stripe_provider.tsx | 0 32 files changed, 214 insertions(+), 40 deletions(-) delete mode 100644 webapp/channels/src/components/self_hosted_expansion_modal/constants.tsx rename webapp/channels/src/components/{self_hosted_purchase_modal => self_hosted_purchases}/address.tsx (100%) rename webapp/channels/src/components/{self_hosted_purchase_modal => self_hosted_purchases}/constants.ts (71%) rename webapp/channels/src/components/{self_hosted_purchase_modal => self_hosted_purchases}/contact_sales_link.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/error_page.scss (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/error_page.tsx (95%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/expansion_card.scss (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/expansion_card.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/index.test.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/index.tsx (94%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/self_hosted_expansion_modal.scss (98%) create mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/success_page.scss (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_expansion_modal/success_page.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/error.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/index.test.tsx (99%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/index.tsx (99%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/self_hosted_card.tsx (97%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/self_hosted_purchase_modal.scss (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/submitting.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/success_page.scss (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/success_page.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/terms.tsx (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/types.ts (100%) rename webapp/channels/src/components/{ => self_hosted_purchases}/self_hosted_purchase_modal/useNoEscape.ts (100%) rename webapp/channels/src/components/{self_hosted_purchase_modal => self_hosted_purchases}/stripe_provider.tsx (100%) diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx index 0d4a422a27..aea8e93cb5 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx @@ -23,6 +23,8 @@ 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'; +import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_purchases/constants'; const DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD = 30; const DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD = 5; @@ -58,6 +60,19 @@ const EnterpriseEditionLeftPanel = ({ const canExpand = useCanSelfHostedExpand(); const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'}); const expandableLink = useSelector(getExpandSeatsLink); + const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion; + + const query = useQuery(); + const actionQueryParam = query.get('action'); + + useEffect(() => { + console.log(actionQueryParam); + if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedExpansionEnabled) { + console.log("Open modal!"); + selfHostedExpansionModal.open(); + query.set('action', ''); + } + }, []) useEffect(() => { async function fetchUnSanitizedLicense() { @@ -73,7 +88,6 @@ const EnterpriseEditionLeftPanel = ({ const skuName = getSkuDisplayName(unsanitizedLicense.SkuShortName, unsanitizedLicense.IsGovSku === 'true'); const expirationDays = getRemainingDaysFromFutureTimestamp(parseInt(unsanitizedLicense.ExpiresAt, 10)); - const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion; const viewPlansButton = (
} {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && ( {setFormState({...formState, submitting: false, error: ''})}} + tryAgain={() => { + setFormState({...formState, submitting: false, error: ''}); + }} /> )}
diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx index 72cd5b6810..f734d5b82a 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx @@ -93,7 +93,7 @@ export default function Submitting(props: Props) { setBarProgress(Math.min(maxProgressForCurrentSignupProgress, barProgress + maxFakeProgressIncrement)); } }, fakeProgressInterval); - + return () => clearInterval(interval); }, [barProgress]); From 9e41644c5b3e17556fd0b32f14e48488a6957dba Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 4 Apr 2023 16:55:25 -0400 Subject: [PATCH 35/56] lint. --- .../enterprise_edition_left_panel.tsx | 3 - .../self_hosted_expansion_modal/index.tsx | 138 +++++++++--------- 2 files changed, 69 insertions(+), 72 deletions(-) diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx index ab7feb5107..432e38011e 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx @@ -24,7 +24,6 @@ import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpa import {getExpandSeatsLink} from 'selectors/cloud'; import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal'; import {useQuery} from 'utils/http_utils'; -import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_purchases/constants'; const DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD = 30; const DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD = 5; @@ -66,9 +65,7 @@ const EnterpriseEditionLeftPanel = ({ const actionQueryParam = query.get('action'); useEffect(() => { - console.log(actionQueryParam); if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedExpansionEnabled) { - console.log('Open modal!'); selfHostedExpansionModal.open(); query.set('action', ''); } diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx index 0661ad0637..83f6ff7320 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx @@ -356,98 +356,98 @@ export default function SelfHostedExpansionModal() {
{'Questions?'}
-
-
- - {intl.formatMessage({ +
+
+ + {intl.formatMessage({ id: 'payment_form.credit_card', defaultMessage: 'Credit Card', })} - -
- { + +
+ { setFormState({...formState, cardFilled: event.complete}); }} - theme={theme} - /> -
-
- ) => { + theme={theme} + /> +
+
+ ) => { setFormState({...formState, organization: e.target.value}); }} - placeholder={intl.formatMessage({ + placeholder={intl.formatMessage({ id: 'self_hosted_signup.organization', defaultMessage: 'Organization Name', })} - required={true} - /> -
-
- ) => { + required={true} + /> +
+
+ ) => { setFormState({...formState, cardName: e.target.value}); }} - placeholder={intl.formatMessage({ + placeholder={intl.formatMessage({ id: 'payment_form.name_on_card', defaultMessage: 'Name on Card', })} - required={true} - /> -
- - - -
{ + required={true} + /> +
+ + + +
{ setFormState({...formState, country: option.value}); }} - address={formState.address} - changeAddress={(e) => { + address={formState.address} + changeAddress={(e) => { setFormState({...formState, address: e.target.value}); }} - address2={formState.address2} - changeAddress2={(e) => { + address2={formState.address2} + changeAddress2={(e) => { setFormState({...formState, address2: e.target.value}); }} - city={formState.city} - changeCity={(e) => { + city={formState.city} + changeCity={(e) => { setFormState({...formState, city: e.target.value}); }} - state={formState.state} - changeState={(state: string) => { + state={formState.state} + changeState={(state: string) => { setFormState({...formState, state}); }} - postalCode={formState.postalCode} - changePostalCode={(e) => { + postalCode={formState.postalCode} + changePostalCode={(e) => { setFormState({...formState, postalCode: e.target.value}); }} - /> - { + /> + { setFormState({...formState, shippingSame: val}); }} - /> - {!formState.shippingSame && ( + /> + {!formState.shippingSame && ( <>
)} - { + { setFormState({...formState, agreedTerms: data}); }} - /> -
-
+ /> +
+
{ From d8544a1db7747da98a50cec46374129e6b4c5a07 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 10 Apr 2023 16:03:35 -0400 Subject: [PATCH 36/56] lint. --- .../expansion_card.tsx | 44 +++---------------- 1 file changed, 7 insertions(+), 37 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx index 5528402613..3cf1243e17 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx @@ -9,7 +9,7 @@ import {FormattedMessage} from 'react-intl'; import {useSelector} from 'react-redux'; import {getLicense} from 'mattermost-redux/selectors/entities/general'; -import {DocLinks, RecurringIntervals} from 'utils/constants'; +import {DocLinks} from 'utils/constants'; import WarningIcon from 'components/widgets/icons/fa_warning_icon'; import './expansion_card.scss'; @@ -38,48 +38,27 @@ export default function SelfHostedExpansionCard(props: Props) { const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats); 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 getMonthlyPrice = () => { - if (currentProduct === null) { - return 0; - } - - if (currentProduct?.recurring_interval === RecurringIntervals.MONTH) { - return currentProduct.price_per_seat; - } - - const costPerMonth = (currentProduct.price_per_seat / MONTHS_IN_YEAR); - - // Only display 2 decimal places if the cost per month is not evenly divisible over 12 months. - if (!Number.isInteger(costPerMonth)) { - // Keep the return value as a number. - return costPerMonth; - } - - return costPerMonth; - }; - const getCostPerUser = () => { if (isNaN(additionalSeats)) { return 0; } - const monthlyPrice = getMonthlyPrice(); const monthsUntilExpiry = getMonthsUntilExpiry(); - return monthlyPrice * monthsUntilExpiry; + return costPerMonth * monthsUntilExpiry; }; const getTotal = () => { if (isNaN(additionalSeats)) { return 0; } - const monthlyPrice = getMonthlyPrice(); const monthsUntilExpiry = getMonthsUntilExpiry(); - return additionalSeats * monthlyPrice * monthsUntilExpiry; + return additionalSeats * costPerMonth * monthsUntilExpiry; }; // Finds the maximum number of additional seats that is possible, taking into account @@ -90,18 +69,9 @@ export default function SelfHostedExpansionCard(props: Props) { return 0; } - let recurringCost = 0; - - // if monthly - if (currentProduct.recurring_interval === RecurringIntervals.MONTH) { - recurringCost = getMonthlyPrice(); - } else { // if yearly - recurringCost = currentProduct.price_per_seat; - } - - const currentPaymentPrice = recurringCost * props.licensedSeats; + const currentPaymentPrice = costPerMonth * props.licensedSeats; const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice; - const remainingSeats = Math.floor(remainingTransactionLimit / recurringCost); + const remainingSeats = Math.floor(remainingTransactionLimit / costPerMonth); return Math.max(0, remainingSeats); }; @@ -211,7 +181,7 @@ export default function SelfHostedExpansionCard(props: Props) { /* eslint-disable no-template-curly-in-string*/ defaultMessage='${costPerUser} x {monthsUntilExpiry} months' values={{ - costPerUser: getMonthlyPrice().toFixed(2), + costPerUser: costPerMonth.toFixed(2), monthsUntilExpiry: getMonthsUntilExpiry(), }} /> From 214bd6dd0709c71cbac94fcaebf34c8966c8f913 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 10 Apr 2023 16:17:36 -0400 Subject: [PATCH 37/56] lint. --- .../self_hosted_expansion_modal/index.tsx | 235 +++++++++--------- 1 file changed, 118 insertions(+), 117 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx index 83f6ff7320..7eeedb5460 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx @@ -347,165 +347,166 @@ export default function SelfHostedExpansionModal() { }} >
- {
-

{title}

- -
{'Questions?'}
- -
-
+
+
+

{title}

+ +
{'Questions?'}
+ +
+
- + className='form' + data-testid='shpm-form' + > + {intl.formatMessage({ - id: 'payment_form.credit_card', - defaultMessage: 'Credit Card', - })} + id: 'payment_form.credit_card', + defaultMessage: 'Credit Card', + })} -
+
{ - setFormState({...formState, cardFilled: event.complete}); - }} - theme={theme} - /> + forwardedRef={cardRef} + required={true} + onCardInputChange={(event: StripeCardElementChangeEvent) => { + setFormState({...formState, cardFilled: event.complete}); + }} + theme={theme} + />
-
+
) => { - setFormState({...formState, organization: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'self_hosted_signup.organization', - defaultMessage: 'Organization Name', - })} - required={true} - /> + name='organization' + type='text' + value={formState.organization} + onChange={(e: React.ChangeEvent) => { + setFormState({...formState, organization: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'self_hosted_signup.organization', + defaultMessage: 'Organization Name', + })} + required={true} + />
-
+
) => { - setFormState({...formState, cardName: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.name_on_card', - defaultMessage: 'Name on Card', - })} - required={true} - /> + name='name' + type='text' + value={formState.cardName} + onChange={(e: React.ChangeEvent) => { + setFormState({...formState, cardName: e.target.value}); + }} + placeholder={intl.formatMessage({ + id: 'payment_form.name_on_card', + defaultMessage: 'Name on Card', + })} + required={true} + />
- + + id='payment_form.billing_address' + defaultMessage='Billing address' + /> -
{ - setFormState({...formState, country: option.value}); - }} + setFormState({...formState, country: option.value}); + }} address={formState.address} changeAddress={(e) => { - setFormState({...formState, address: e.target.value}); - }} + setFormState({...formState, address: e.target.value}); + }} address2={formState.address2} changeAddress2={(e) => { - setFormState({...formState, address2: e.target.value}); - }} + setFormState({...formState, address2: e.target.value}); + }} city={formState.city} changeCity={(e) => { - setFormState({...formState, city: e.target.value}); - }} + setFormState({...formState, city: e.target.value}); + }} state={formState.state} changeState={(state: string) => { - setFormState({...formState, state}); - }} + setFormState({...formState, state}); + }} postalCode={formState.postalCode} changePostalCode={(e) => { - setFormState({...formState, postalCode: e.target.value}); - }} + setFormState({...formState, postalCode: e.target.value}); + }} /> - { - setFormState({...formState, shippingSame: val}); - }} + setFormState({...formState, shippingSame: val}); + }} /> - {!formState.shippingSame && ( - <> -
- +
+ +
+
{ + 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}); + }} /> -
-
{ - 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}); - }} - /> - - )} - + )} + { - setFormState({...formState, agreedTerms: data}); - }} + setFormState({...formState, agreedTerms: data}); + }} /> -
+
- { - setFormState({...formState, seats}); - setAdditionalSeats(seats); - }} + setFormState({...formState, seats}); + setAdditionalSeats(seats); + }} canSubmit={canSubmitForm} submit={submit} licensedSeats={licensedSeats} initialSeats={additionalSeats} /> +
-
} {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && ( Date: Mon, 10 Apr 2023 17:16:10 -0400 Subject: [PATCH 38/56] fix some tests. --- .../self_hosted_expansion_modal/index.test.tsx | 3 ++- .../self_hosted_purchase_modal/index.test.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx index a0d1a62a6d..882953af5f 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx @@ -51,7 +51,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; }; @@ -164,6 +164,7 @@ const initialState: DeepPartial = { }, license: { Sku: productName, + SkuName: productName, Users: '50', ExpiresAt: licenseExpiry.valueOf().toString(), }, diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx index bfce0c46d4..79f710f080 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx @@ -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; }; From 4ff63f60b0453e90df28dbd5c642d4ba0f887e26 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 12 Apr 2023 09:38:43 -0400 Subject: [PATCH 39/56] fix tests for expansion modal, lint. --- .../useControlSelfHostedExpansionModal.ts | 1 - .../index.test.tsx | 31 ++++++++++--------- .../self_hosted_expansion_modal/index.tsx | 9 +++--- .../success_page.tsx | 22 ++++++------- webapp/channels/src/utils/constants.tsx | 2 +- 5 files changed, 33 insertions(+), 32 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index dd581f23d5..df2d59eaf7 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -19,7 +19,6 @@ import {useControlModal, ControlModal} from './useControlModal'; import useCanSelfHostedExpand from './useCanSelfHostedExpand'; interface HookOptions{ - onClick?: () => void; trackingLocation?: string; } diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx index 882953af5f..2d2931483e 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx @@ -98,13 +98,10 @@ jest.mock('mattermost-redux/client', () => { progress: mockCreatedIntent, }); }, - confirmSelfHostedSignup: () => Promise.resolve({ + confirmSelfHostedExpansion: () => Promise.resolve({ progress: mockCreatedLicense, license: {Users: existingUsers * 2}, }), - getClientLicenseOld: () => Promise.resolve({ - data: {Sku: 'Enterprise'}, - }), }, }; }); @@ -163,6 +160,7 @@ const initialState: DeepPartial = { EnableDeveloper: 'false', }, license: { + SkuName: productName, Sku: productName, SkuName: productName, Users: '50', @@ -240,7 +238,7 @@ const defaultSuccessForm: PurchaseForm = { city: 'Minneapolis', state: 'MN', zip: '55423', - seats: '10', + seats: '50', agree: true, }; @@ -257,11 +255,6 @@ function fillForm(form: PurchaseForm) { fireEvent.click(screen.getByText('I have read and agree', {exact: false})); } - // not changing the license seats number, because it is expected to be pre-filled, - // with the correct number of seats (current active users - current licensed seats, or 1 if the difference is 0). - - expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled(); - const completeButton = screen.getByText('Complete purchase'); if (form === defaultSuccessForm) { @@ -307,14 +300,24 @@ describe('SelfHostedExpansionModal Open', () => { expect(screen.getByText('You must add a seat to continue')).toBeVisible(); }); - it('happy path submit shows success screen', async () => { + it('happy path submit shows success screen when confirmation succeeds', async () => { renderWithIntlAndStore(
, initialState); expect(screen.getByText('Complete purchase')).toBeDisabled(); - const upgradeButton = fillForm(defaultSuccessForm); - expect(upgradeButton).toBeEnabled(); + const upgradeButton = fillForm(defaultSuccessForm); upgradeButton.click(); - await waitFor(() => expect(screen.getByText('You\'ve successfully updated your license seat count')).toBeTruthy(), {timeout: 1234}); + + expect(screen.findByText('The license has been automatically applied')).toBeTruthy(); + }); + + it('happy path submit shows submitting screen while requesting confirmation', async () => { + renderWithIntlAndStore(
, 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 () => { diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx index 7eeedb5460..5fbd4d6ebc 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx @@ -50,7 +50,6 @@ import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from '../constants'; import Address from 'components/self_hosted_purchases/address'; import ChooseDifferentShipping from 'components/choose_different_shipping'; import Terms from 'components/self_hosted_purchases/self_hosted_purchase_modal/terms'; -import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal'; import classNames from 'classnames'; export interface FormState { @@ -168,7 +167,6 @@ export function canSubmit(formState: FormState, progress: ValueOf(); const intl = useIntl(); const cardRef = useRef(null); @@ -457,7 +455,7 @@ export default function SelfHostedExpansionModal() { />
{ @@ -509,7 +507,10 @@ export default function SelfHostedExpansionModal() {
{((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && ( { + setFormState({...formState, submitting: false, error: '', succeeded: false}); + closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION); + }} /> )} {formState.submitting && ( diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx index 77916c9de7..b87592dd74 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx @@ -4,15 +4,12 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; -import {NavLink} from 'react-router-dom'; - -import {useDispatch} from 'react-redux'; +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, ModalIdentifiers} from 'utils/constants'; +import {ConsolePages} from 'utils/constants'; import BackgroundSvg from 'components/common/svg_images_components/background_svg'; -import {closeModal} from 'actions/views/modals'; import './success_page.scss'; @@ -21,7 +18,7 @@ interface Props { } export default function SelfHostedExpansionSuccessPage(props: Props) { - const dispatch = useDispatch(); + const history = useHistory(); const titleText = ( Billing section of the system console.'} values={{ billing: (billingText: React.ReactNode) => ( - { + history.push(ConsolePages.BILLING_HISTORY); + props.onClose(); + }} > {billingText} - + ), }} /> @@ -72,7 +71,6 @@ export default function SelfHostedExpansionSuccessPage(props: Props) { formattedButtonText={formattedButtonText} buttonHandler={() => { props.onClose(); - dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL)); }} />
diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 9c59ab386d..e07e5c70af 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -2007,7 +2007,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', + BILLING_HISTORY: '/admin_console/billing/billing_history', }; export const WindowSizes = { From 02947aa0095a9631cb18738cb446a754b6f70ca3 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 12 Apr 2023 10:43:36 -0400 Subject: [PATCH 40/56] fix type checks. --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index df2d59eaf7..8f7e1dfcdb 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -61,10 +61,6 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) callerInfo: options.trackingLocation, }); - if (options.onClick) { - options.onClick(); - } - try { const result = await Client4.bootstrapSelfHostedSignup(); From d5d1b0317686da3cc22ecb3c730f5696713c0253 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 12 Apr 2023 10:44:12 -0400 Subject: [PATCH 41/56] fix type checks (missed two). --- .../common/hooks/useControlSelfHostedExpansionModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index 8f7e1dfcdb..a310c4538e 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -90,5 +90,5 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) } }, }; - }, [controlModal, options.onClick, options.trackingLocation]); + }, [controlModal, options.trackingLocation]); } From 34eece7462533f2ac3e73e4ab028a04068f0ba28 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 12 Apr 2023 13:44:56 -0400 Subject: [PATCH 42/56] revert changes to overage users banner in favor of getting self hoste expansion modal pushed through faster. --- .../enterprise_edition_left_panel.test.tsx | 11 ++++++- .../overage_users_banner/index.tsx | 15 ++------- .../overage_users_banner_notice/index.tsx | 31 ++----------------- .../index.test.tsx | 1 - 4 files changed, 15 insertions(+), 43 deletions(-) diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx index 551af99212..3c8a8f4697 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx @@ -22,6 +22,15 @@ import * as useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHoste 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', @@ -113,7 +122,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris return n.children().length === 2 && n.childAt(0).type() === 'span' && !n.childAt(0).text().includes('ACTIVE') && - n.childAt(0).text().includes('USERS'); + n.childAt(0).text().includes('LICENSED SEATS'); }); expect(item.text()).toContain('1,000'); diff --git a/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx b/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx index ab53ac8be9..d22fe6389f 100644 --- a/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx +++ b/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx @@ -4,7 +4,6 @@ import React, {useMemo} from 'react'; import {FormattedMessage} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; -import {useHistory} from 'react-router-dom'; import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import {GlobalState} from 'types/store'; @@ -17,10 +16,9 @@ import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences'; import {PreferenceType} from '@mattermost/types/preferences'; import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck'; import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; -import {StatTypes, Preferences, AnnouncementBarTypes, ConsolePages} from 'utils/constants'; +import {StatTypes, Preferences, AnnouncementBarTypes} from 'utils/constants'; import './overage_users_banner.scss'; -import useCWSAvailabilityCheck from 'components/common/hooks/useCWSAvailabilityCheck'; type AdminHasDismissedItArgs = { preferenceName: string; @@ -58,8 +56,6 @@ const OverageUsersBanner = () => { const prefixPreferences = isOver10PercerntPurchasedSeats ? 'error' : 'warn'; const prefixLicenseId = (license.Id || '').substring(0, 8); const preferenceName = `${prefixPreferences}_overage_seats_${prefixLicenseId}`; - const history = useHistory(); - const isAirGapped = !useCWSAvailabilityCheck(); const overageByUsers = activeUsers - seatsPurchased; @@ -90,14 +86,7 @@ const OverageUsersBanner = () => { const handleUpdateSeatsSelfServeClick = (e: React.MouseEvent) => { e.preventDefault(); trackEventFn('Self Serve'); - - if (isAirGapped) { - window.open(expandableLink(license.Id), '_blank'); - } - - if (isExpandable) { - history.push(`${ConsolePages.LICENSE}?action=show_expansion_modal`); - } + window.open(expandableLink(license.Id), '_blank'); }; const handleContactSalesClick = (e: React.MouseEvent) => { diff --git a/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx b/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx index f72ec26388..ce1cd6a1c3 100644 --- a/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx +++ b/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx @@ -16,13 +16,10 @@ import {savePreferences} from 'mattermost-redux/actions/preferences'; import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences'; import {PreferenceType} from '@mattermost/types/preferences'; import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck'; -import {LicenseLinks, StatTypes, Preferences, ConsolePages} from 'utils/constants'; +import {LicenseLinks, StatTypes, Preferences} from 'utils/constants'; import './overage_users_banner_notice.scss'; import ExternalLink from 'components/external_link'; -import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal'; -import {NavLink} from 'react-router-dom'; -import useCWSAvailabilityCheck from 'components/common/hooks/useCWSAvailabilityCheck'; type AdminHasDismissedArgs = { preferenceName: string; @@ -56,16 +53,15 @@ const OverageUsersBannerNotice = () => { const prefixLicenseId = (license.Id || '').substring(0, 8); const preferenceName = `${prefixPreferences}_overage_seats_${prefixLicenseId}`; - const isAirGapped = !useCWSAvailabilityCheck(); const overageByUsers = activeUsers - seatsPurchased; const isOverageState = isBetween5PercerntAnd10PercentPurchasedSeats || isOver10PercerntPurchasedSeats; const hasPermission = isAdmin && isOverageState && !isCloud; const { cta, + expandableLink, trackEventFn, getRequestState, isExpandable, - expandableLink, } = useExpandOverageUsersCheck({ shouldRequest: hasPermission && !adminHasDismissed({overagePreferences, preferenceName}), licenseId: license.Id, @@ -73,8 +69,6 @@ const OverageUsersBannerNotice = () => { banner: 'invite modal', }); - const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'overage_user_banner_notice'}); - if (!hasPermission || adminHasDismissed({overagePreferences, preferenceName})) { return null; } @@ -102,31 +96,12 @@ const OverageUsersBannerNotice = () => { const handleClick = () => { trackEventFn(isExpandable ? 'Self Serve' : 'Contact Sales'); - if (isExpandable) { - selfHostedExpansionModal.open(); - } }; - if (isAirGapped) { - window.open(expandableLink(license.Id), '_blank'); - } - - if (isExpandable) { - return ( - - {cta} - - ); - } - return ( {cta} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx index 2d2931483e..8a6efee355 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx @@ -162,7 +162,6 @@ const initialState: DeepPartial = { license: { SkuName: productName, Sku: productName, - SkuName: productName, Users: '50', ExpiresAt: licenseExpiry.valueOf().toString(), }, From 183e3c6033a70e079f447372de0d806fc5cc0cdc Mon Sep 17 00:00:00 2001 From: Conor Macpherson <116016004+ConorMacpherson@users.noreply.github.com> Date: Fri, 14 Apr 2023 15:00:18 -0400 Subject: [PATCH 43/56] Update index.tsx Fix success modal not closing. --- .../self_hosted_purchases/self_hosted_expansion_modal/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx index 5fbd4d6ebc..88efd97956 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx @@ -509,7 +509,7 @@ export default function SelfHostedExpansionModal() { { setFormState({...formState, submitting: false, error: '', succeeded: false}); - closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION); + dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); }} /> )} From ecbdd917879ca2d1ed9b49ba6cd9e405b75deebb Mon Sep 17 00:00:00 2001 From: Conor Macpherson <116016004+ConorMacpherson@users.noreply.github.com> Date: Fri, 14 Apr 2023 15:01:54 -0400 Subject: [PATCH 44/56] Update enterprise_edition_left_panel.tsx Fix handle click `+Add Seats` button to ensure checks for expansion availability look at service settings OR expansion availability are --- .../enterprise_edition/enterprise_edition_left_panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx index b2616d9453..7cc45db259 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx @@ -100,7 +100,7 @@ const EnterpriseEditionLeftPanel = ({ ); const handleClickAddSeats = () => { - if (!isSelfHostedExpansionEnabled && !canExpand) { + if (!isSelfHostedExpansionEnabled || !canExpand) { window.open(expandableLink(unsanitizedLicense.Id), '_blank'); } else { selfHostedExpansionModal.open(); From 5b42689529e156d5c70c3be51d14a3ef26759dbb Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 17 Apr 2023 14:24:58 -0400 Subject: [PATCH 45/56] Address code review comments (styling, clean-up css, re-org imports, math errors, etc). --- .../src/components/outlined_input/index.tsx | 26 ++++++++ .../error_page.tsx | 2 +- .../expansion_card.scss | 10 --- .../expansion_card.tsx | 63 +++++++++---------- .../self_hosted_expansion_modal/index.tsx | 62 +++++++++--------- .../self_hosted_expansion_modal.scss | 3 - .../submitting.tsx | 3 +- .../success_page.scss | 1 - .../success_page.tsx | 8 +-- webapp/channels/src/i18n/en.json | 1 - 10 files changed, 90 insertions(+), 89 deletions(-) create mode 100644 webapp/channels/src/components/outlined_input/index.tsx diff --git a/webapp/channels/src/components/outlined_input/index.tsx b/webapp/channels/src/components/outlined_input/index.tsx new file mode 100644 index 0000000000..5644bf958c --- /dev/null +++ b/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 + * span:first-child { - font-family: 'Open Sans'; font-size: 14px; } .costPerUser > span:last-child { color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); - font-family: 'Open Sans'; font-size: 12px; } @@ -94,14 +88,12 @@ .totalCostWarning > span:first-child { color: var(--sys-denim-center-channel-text); - font-family: 'Open Sans'; font-size: 14px; font-weight: 700; } .totalCostWarning > span:last-child { color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); - font-family: 'Open Sans'; font-size: 12px; } @@ -119,7 +111,6 @@ height: 35px; margin-bottom: 15px; color: var(--dnd-indicator); - font-family: 'Open Sans'; font-size: 12px; font-weight: 600; text-align: right; @@ -134,7 +125,6 @@ &__ChargedTodayDisclaimer { color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72); - font-family: 'Open Sans'; font-size: 12px; font-weight: 400; } diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx index 3cf1243e17..aa9a5edfb8 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx @@ -1,21 +1,22 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {OutlinedInput} from '@mui/material'; - -import moment from 'moment-timezone'; -import React, {Fragment, useState} from 'react'; -import {FormattedMessage} from 'react-intl'; +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 {DocLinks} from 'utils/constants'; + 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'; -import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts'; -import {findSelfHostedProductBySku} from 'utils/hosted_customer'; -import ExternalLink from 'components/external_link'; const MONTHS_IN_YEAR = 12; const MAX_TRANSACTION_VALUE = 1_000_000 - 1; @@ -29,6 +30,7 @@ interface Props { } 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'); @@ -46,14 +48,11 @@ export default function SelfHostedExpansionCard(props: Props) { }; const getCostPerUser = () => { - if (isNaN(additionalSeats)) { - return 0; - } const monthsUntilExpiry = getMonthsUntilExpiry(); return costPerMonth * monthsUntilExpiry; }; - const getTotal = () => { + const getPaymentTotal = () => { if (isNaN(additionalSeats)) { return 0; } @@ -63,25 +62,29 @@ export default function SelfHostedExpansionCard(props: Props) { // 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 * Price Per Seat)) / price_per_seat + // (StripeTransaction Limit - (current_seats * yearly_price_per_seat)) / yearly_price_per_seat const getMaximumAdditionalSeats = () => { if (currentProduct === null) { return 0; } - const currentPaymentPrice = costPerMonth * props.licensedSeats; + const currentPaymentPrice = costPerMonth * props.licensedSeats * 12; const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice; - const remainingSeats = Math.floor(remainingTransactionLimit / costPerMonth); + const remainingSeats = Math.floor(remainingTransactionLimit / (costPerMonth * 12)); return Math.max(0, remainingSeats); }; - const maxAdditionalSeats = getMaximumAdditionalSeats(); const handleNewSeatsInputChange = (e: React.ChangeEvent) => { - setOverMaxSeats(false); - const requestedSeats = parseInt(e.target.value, 10); + if (requestedSeats <= 0) { + e.preventDefault(); + return; + } + + setOverMaxSeats(false); + const overMaxAdditionalSeats = requestedSeats > maxAdditionalSeats; setOverMaxSeats(overMaxAdditionalSeats); @@ -91,6 +94,10 @@ export default function SelfHostedExpansionCard(props: Props) { props.updateSeats(finalSeatCount); }; + const formatCurrency = (value: number) => { + return intl.formatNumber(value, {style: 'currency', currency: 'USD'}); + }; + return (
@@ -158,16 +165,6 @@ export default function SelfHostedExpansionCard(props: Props) { }} /> } - {maxAdditionalSeats === 0 && - , - warningIcon: , - }} - /> - }
@@ -179,15 +176,15 @@ export default function SelfHostedExpansionCard(props: Props) {
- {'$' + getCostPerUser().toFixed(2)} + {formatCurrency(getCostPerUser())}
- {'$' + getTotal().toFixed(2)} + {formatCurrency(getPaymentTotal()) }
From 093a17db7074da96502d1966cfbbdb84510c09f0 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 17 Apr 2023 15:37:09 -0400 Subject: [PATCH 47/56] lint. --- .../self_hosted_expansion_modal/expansion_card.tsx | 4 ++-- .../self_hosted_expansion_modal/index.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx index 3bd91c348d..b6440d986b 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx @@ -76,7 +76,7 @@ export default function SelfHostedExpansionCard(props: Props) { const maxAdditionalSeats = getMaximumAdditionalSeats(); const handleNewSeatsInputChange = (e: React.ChangeEvent) => { - let requestedSeats = parseInt(e.target.value, 10); + const requestedSeats = parseInt(e.target.value, 10); if (!isNaN(requestedSeats) && requestedSeats <= 0) { e.preventDefault(); @@ -160,7 +160,7 @@ export default function SelfHostedExpansionCard(props: Props) { defaultMessage='{warningIcon} You must purchase at least {minimumSeats} seats to be compliant with your license' values={{ warningIcon: , - minimumSeats: props.minimumSeats + minimumSeats: props.minimumSeats, }} /> } diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx index 48b1e276f8..b66a81f517 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx @@ -176,7 +176,7 @@ export default function SelfHostedExpansionModal() { 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 [requestedSeats, setRequestedSeats] = useState(minimumSeats); const [stripeLoadHint, setStripeLoadHint] = useState(Math.random()); const stripeRef = useLoadStripe(stripeLoadHint); From 7bfebcc80a7c77f5c4def02f75f4d6de42b7d9f4 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 17 Apr 2023 15:38:52 -0400 Subject: [PATCH 48/56] Change confirm expand client request to hit a separate endpoint from self hosted purchases confirm. --- webapp/platform/client/src/client4.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index b194c71946..47e6741b0e 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -3899,7 +3899,7 @@ export default class Client4 { confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => { return this.doFetch( - `${this.getHostedCustomerRoute()}/confirm?expand=true`, + `${this.getHostedCustomerRoute()}/confirm-expand`, {method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, expand_request: expandRequest})}, ); } From f613c655e7af639c06f4beb8b5245dba9b450ecc Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 17 Apr 2023 16:11:43 -0400 Subject: [PATCH 49/56] remove self hosted expansion flag in favor of grouping hosted expansion with hosted purhcase and it's flag. --- e2e-tests/playwright/support/server/default_config.ts | 1 - model/config.go | 5 ----- server/platform/services/telemetry/telemetry.go | 1 - .../enterprise_edition_left_panel.test.tsx | 2 +- .../enterprise_edition/enterprise_edition_left_panel.tsx | 6 +++--- webapp/platform/types/src/config.ts | 1 - 6 files changed, 4 insertions(+), 12 deletions(-) diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index a82775d783..a5b92aeb89 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -170,7 +170,6 @@ const defaultServerConfig: AdminConfig = { EnableCustomGroups: true, SelfHostedPurchase: true, AllowSyncedDrafts: true, - SelfHostedExpansion: false, }, TeamSettings: { SiteName: 'Mattermost', diff --git a/model/config.go b/model/config.go index 4868229bbf..95c0b6514e 100644 --- a/model/config.go +++ b/model/config.go @@ -390,7 +390,6 @@ type ServiceSettings struct { EnableCustomGroups *bool `access:"site_users_and_teams"` SelfHostedPurchase *bool `access:"write_restrictable,cloud_restrictable"` AllowSyncedDrafts *bool `access:"site_posts"` - SelfHostedExpansion *bool `access:"write_restrictable,cloud_restrictable"` } func (s *ServiceSettings) SetDefaults(isUpdate bool) { @@ -863,10 +862,6 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.SelfHostedPurchase == nil { s.SelfHostedPurchase = NewBool(true) } - - if s.SelfHostedExpansion == nil { - s.SelfHostedExpansion = NewBool(false) - } } type ClusterSettings struct { diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index a214911f20..157db35f48 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -476,7 +476,6 @@ func (ts *TelemetryService) trackConfig() { "post_priority": *cfg.ServiceSettings.PostPriority, "self_hosted_purchase": *cfg.ServiceSettings.SelfHostedPurchase, "allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts, - "self_hosted_expansion": *cfg.ServiceSettings.SelfHostedExpansion, }) ts.SendTelemetry(TrackConfigTeam, map[string]any{ diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx index 3c8a8f4697..987a3421f5 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.test.tsx @@ -69,7 +69,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris admin: { config: { ServiceSettings: { - SelfHostedExpansion: true, + SelfHostedPurchase: true, }, }, }, diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx index 7cc45db259..1a09207a64 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx @@ -59,13 +59,13 @@ const EnterpriseEditionLeftPanel = ({ const canExpand = useCanSelfHostedExpand(); const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'}); const expandableLink = useSelector(getExpandSeatsLink); - const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion; + const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase; const query = useQuery(); const actionQueryParam = query.get('action'); useEffect(() => { - if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedExpansionEnabled) { + if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedPurchaseEnabled) { selfHostedExpansionModal.open(); query.set('action', ''); } @@ -100,7 +100,7 @@ const EnterpriseEditionLeftPanel = ({ ); const handleClickAddSeats = () => { - if (!isSelfHostedExpansionEnabled || !canExpand) { + if (!isSelfHostedPurchaseEnabled || !canExpand) { window.open(expandableLink(unsanitizedLicense.Id), '_blank'); } else { selfHostedExpansionModal.open(); diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index a9053a7a51..0a56b79b6a 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -369,7 +369,6 @@ export type ServiceSettings = { EnableCustomGroups: boolean; SelfHostedPurchase: boolean; AllowSyncedDrafts: boolean; - SelfHostedExpansion: boolean; }; export type TeamSettings = { From dd314373be184b69f313c1899f19e5897c4d2ce5 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Mon, 17 Apr 2023 16:26:43 -0400 Subject: [PATCH 50/56] i18n. --- webapp/channels/src/i18n/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 92034bf68e..cde81b5186 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4758,6 +4758,7 @@ "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", From 2f8e9f16e3863c60b5e7fbefc667e8016adf43bf Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 18 Apr 2023 12:04:23 -0400 Subject: [PATCH 51/56] update and fix tests. --- .../index.test.tsx | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx index 8a6efee355..2915d932de 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx @@ -283,22 +283,6 @@ describe('SelfHostedExpansionModal Open', () => { fillForm(defaultSuccessForm); }); - it('disables expansion if too few seats or no seats entered', () => { - renderWithIntlAndStore(
, initialState); - fillForm(defaultSuccessForm); - - // 0 seats entered. - const tooFewSeats = 0; - fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, valueEvent(tooFewSeats.toString())); - expect(screen.getByText('Complete purchase')).toBeDisabled(); - expect(screen.getByText('You must add a seat to continue')).toBeVisible(); - - // No seats value entered. - fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, undefined); - expect(screen.getByText('Complete purchase')).toBeDisabled(); - expect(screen.getByText('You must add a seat to continue')).toBeVisible(); - }); - it('happy path submit shows success screen when confirmation succeeds', async () => { renderWithIntlAndStore(
, initialState); expect(screen.getByText('Complete purchase')).toBeDisabled(); @@ -336,13 +320,49 @@ describe('SelfHostedExpansionModal RHS Card', () => { it('New seats input should be pre-populated with the difference from the active users and licensed seats', () => { renderWithIntlAndStore(
, initialState); - const expectedPrePopulatedSeats = (initialState.entities?.users?.filteredStats?.total_users_count || 1) - parseInt(initialState.entities?.general?.license?.Users || '0', 10); + 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(
, 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(
, 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(
, initialState); @@ -366,7 +386,7 @@ describe('SelfHostedExpansionModal RHS Card', () => { const costAmount = document.getElementsByClassName('totalCostAmount')[0]; expect(costAmount).toBeInTheDocument(); - expect(costAmount).toHaveTextContent('$' + expectedTotalCost); + expect(costAmount).toHaveTextContent(Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(expectedTotalCost)); }); }); From 7bdc5a4a39d6e6bec4247a711c5be390caab048a Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 18 Apr 2023 16:27:37 -0400 Subject: [PATCH 52/56] Fix rhs expansion card resize in resized windows, fix credit card title section being overlapped with the credit card number input hint. --- .../self_hosted_expansion_modal/expansion_card.scss | 2 +- .../self_hosted_expansion_modal.scss | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss index 089f296671..e6910940f0 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss @@ -1,6 +1,6 @@ .SelfHostedExpansionRHSCard { display: flex; - max-width: 280px; + width: 280px; flex-direction: column; &__Content { diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss index 5a98adfbbf..1938890b37 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -58,7 +58,8 @@ } .section-title { - margin-bottom: 24px; + display: block; + margin-bottom: 10px; color: rgba(var(--center-channel-color-rgb), 0.72); font-size: 16px; font-weight: 600; From c9e081d0b1781ee724dec87e0d6adeca893be6f1 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 18 Apr 2023 17:03:20 -0400 Subject: [PATCH 53/56] Ensure submitting and success screen icon messages are at the same position, ensure no overflow at the bottom of the page, try to hide overflow peeking through on the right side of the page. --- .../self_hosted_expansion_modal/submitting.tsx | 2 ++ .../self_hosted_expansion_modal/submitting_page.scss | 6 ++++++ .../self_hosted_expansion_modal/success_page.scss | 5 ++++- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx index 9dc1ce0620..e87af1842e 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx @@ -13,6 +13,8 @@ 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, planName: string): React.ReactNode { const intl = useIntl(); switch (progress) { diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss new file mode 100644 index 0000000000..7171883350 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss @@ -0,0 +1,6 @@ +.submitting { + overflow: hidden; + .processing { + margin-top: 163px; + } +} \ No newline at end of file diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss index 8986942706..b32204d8f3 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss @@ -15,5 +15,8 @@ } .self_hosted_expansion_success { - margin-top: 163px; + overflow: hidden; + .selfHostedExpansionModal__success { + margin-top: 163px; + } } From 42f457fec24c32178647b3e94a03b37db250f110 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Tue, 18 Apr 2023 17:16:06 -0400 Subject: [PATCH 54/56] lint. --- .../self_hosted_expansion_modal/submitting.tsx | 2 +- .../self_hosted_expansion_modal/submitting_page.scss | 3 ++- .../self_hosted_expansion_modal/success_page.scss | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx index e87af1842e..7eafc6c9b1 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx @@ -13,7 +13,7 @@ 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' +import './submitting_page.scss'; function useConvertProgressToWaitingExplanation(progress: ValueOf, planName: string): React.ReactNode { const intl = useIntl(); diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss index 7171883350..46179472b8 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss @@ -1,6 +1,7 @@ .submitting { overflow: hidden; + .processing { margin-top: 163px; } -} \ No newline at end of file +} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss index b32204d8f3..522384347e 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss @@ -16,6 +16,7 @@ .self_hosted_expansion_success { overflow: hidden; + .selfHostedExpansionModal__success { margin-top: 163px; } From 6d62acb13e9b8be26a101150c249693cfb2c4fc4 Mon Sep 17 00:00:00 2001 From: Conor Macpherson Date: Wed, 19 Apr 2023 12:03:06 -0400 Subject: [PATCH 55/56] remove exta background svg. --- .../self_hosted_expansion_modal/success_page.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx index 9549ef2e24..c82df09951 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx @@ -71,9 +71,6 @@ export default function SelfHostedExpansionSuccessPage(props: Props) { formattedButtonText={formattedButtonText} buttonHandler={props.onClose} /> -
- -
); } From 4a77773774045ebce705e3d1affa40b0b6f12789 Mon Sep 17 00:00:00 2001 From: Conor Macpherson <116016004+ConorMacpherson@users.noreply.github.com> Date: Wed, 19 Apr 2023 12:12:52 -0400 Subject: [PATCH 56/56] Remove import of background svg --- .../self_hosted_expansion_modal/success_page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx index c82df09951..f8c362780c 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx @@ -7,7 +7,6 @@ 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 BackgroundSvg from 'components/common/svg_images_components/background_svg'; import {ConsolePages} from 'utils/constants';