[CLD-7567] Deprecate Self Serve: Second Pass (#26853)
* Deprecate Self Serve: First Pass * Fix ci * Fix more ci * Remmove outdated server tests * Fix a missed spot opening purchase modal in Self Hosted * Fix i18n * Clean up some more server code, fix webapp test * Fix alignment of button * Fix linter * Fix i18n server side * Deprecate in product true up * Add back translation * Remove client functions * Put back client functions * webapp deprecation * Deprecate Self Serve: Second Pass * Fix various pipeline issues * Fix linter * Fix pipelines * Fix handlers_test.go * Fix console.error around hostedCustomer in reducer * PICKY LINTER PLEASE * Fix webapp tests, various other fixes for the CI pipelines * Fix i18n * Updates to accomadate enterprise code removal * Fix mocks * More removal * Fix * Adjustments from PR * Fixes for QA Feedback * Update * Add migrations to remove true up review history * Fix migrations check --------- Co-authored-by: Mattermost Build <build@mattermost.com> Co-authored-by: maria.nunez <maria.nunez@mattermost.com>
Этот коммит содержится в:
@@ -18,8 +18,6 @@
|
||||
"@mui/base": "5.0.0-alpha.127",
|
||||
"@mui/material": "5.11.16",
|
||||
"@mui/styled-engine-sc": "5.11.11",
|
||||
"@stripe/react-stripe-js": "1.13.0",
|
||||
"@stripe/stripe-js": "1.41.0",
|
||||
"@tanstack/react-table": "8.10.7",
|
||||
"@tippyjs/react": "4.2.6",
|
||||
"@types/color-hash": "1.0.2",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Stripe} from '@stripe/stripe-js';
|
||||
|
||||
import type {Address, CloudCustomerPatch, Feedback, WorkspaceDeletionRequest} from '@mattermost/types/cloud';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
|
||||
import {CloudTypes} from 'mattermost-redux/action_types';
|
||||
@@ -14,77 +11,8 @@ import type {ActionFunc, ThunkActionFunc} from 'mattermost-redux/types/actions';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions.jsx';
|
||||
|
||||
import {getConfirmCardSetup} from 'components/payment_form/stripe';
|
||||
|
||||
import {getBlankAddressWithCountry} from 'utils/utils';
|
||||
|
||||
import type {StripeSetupIntent, BillingDetails} from 'types/cloud/sku';
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
// Returns true for success, and false for any error
|
||||
export function completeStripeAddPaymentMethod(
|
||||
stripe: Stripe,
|
||||
billingDetails: BillingDetails,
|
||||
cwsMockMode: boolean,
|
||||
) {
|
||||
return async () => {
|
||||
let paymentSetupIntent: StripeSetupIntent;
|
||||
try {
|
||||
paymentSetupIntent = await Client4.createPaymentMethod() as StripeSetupIntent;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
const cardSetupFunction = getConfirmCardSetup(cwsMockMode);
|
||||
const confirmCardSetup = cardSetupFunction(stripe.confirmCardSetup);
|
||||
|
||||
const result = await confirmCardSetup(
|
||||
paymentSetupIntent.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: billingDetails.country,
|
||||
postal_code: billingDetails.postalCode,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {setupIntent, error: stripeError} = result;
|
||||
|
||||
if (stripeError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (setupIntent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (setupIntent.status !== 'succeeded') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await Client4.confirmPaymentMethod(setupIntent.id);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function getInstallation() {
|
||||
return async () => {
|
||||
try {
|
||||
@@ -96,47 +24,6 @@ export function getInstallation() {
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeCloudSubscription(
|
||||
productId: string,
|
||||
shippingAddress: Address = getBlankAddressWithCountry(),
|
||||
seats = 0,
|
||||
downgradeFeedback?: Feedback,
|
||||
customerPatch?: CloudCustomerPatch,
|
||||
) {
|
||||
return async () => {
|
||||
try {
|
||||
const subscription = await Client4.subscribeCloudProduct(
|
||||
productId,
|
||||
shippingAddress,
|
||||
seats,
|
||||
downgradeFeedback,
|
||||
customerPatch,
|
||||
);
|
||||
|
||||
return {data: subscription};
|
||||
} catch (e: any) {
|
||||
// In the event that the status code returned is 422, this request has been blocked by export compliance
|
||||
return {data: false, error: {error: e.message, status: e.status_code}};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function requestCloudTrial(page: string, subscriptionId: string, email = ''): ThunkActionFunc<Promise<boolean>> {
|
||||
trackEvent('api', 'api_request_cloud_trial_license', {from_page: page});
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const newSubscription = await Client4.requestCloudTrial(subscriptionId, email);
|
||||
dispatch({
|
||||
type: CloudTypes.RECEIVED_CLOUD_SUBSCRIPTION,
|
||||
data: newSubscription.data,
|
||||
});
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function validateBusinessEmail(email = '') {
|
||||
trackEvent('api', 'api_validate_business_email');
|
||||
return async () => {
|
||||
@@ -238,17 +125,6 @@ export function getTeamsUsage(): ThunkActionFunc<Promise<boolean | ServerError>>
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteWorkspace(deletionRequest: WorkspaceDeletionRequest) {
|
||||
return async () => {
|
||||
try {
|
||||
await Client4.deleteWorkspace(deletionRequest);
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function retryFailedCloudFetches(): ActionFunc<boolean, GlobalState> {
|
||||
return (dispatch, getState) => {
|
||||
const errors = getCloudErrors(getState());
|
||||
|
||||
@@ -1,124 +1,11 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Stripe} from '@stripe/stripe-js';
|
||||
import {getCode} from 'country-list';
|
||||
|
||||
import type {CreateSubscriptionRequest} from '@mattermost/types/cloud';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
import type {SelfHostedExpansionRequest, SelfHostedSignupSuccessResponse} from '@mattermost/types/hosted_customer';
|
||||
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
|
||||
import type {ValueOf} from '@mattermost/types/utilities';
|
||||
|
||||
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
|
||||
import {bindClientFunc} from 'mattermost-redux/actions/helpers';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getSelfHostedErrors} from 'mattermost-redux/selectors/entities/hosted_customer';
|
||||
import type {ActionFunc, ActionFuncAsync, ThunkActionFunc} from 'mattermost-redux/types/actions';
|
||||
|
||||
import {getConfirmCardSetup} from 'components/payment_form/stripe';
|
||||
|
||||
import type {StripeSetupIntent, BillingDetails} from 'types/cloud/sku';
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
function selfHostedNeedsConfirmation(progress: ValueOf<typeof SelfHostedSignupProgress>): boolean {
|
||||
switch (progress) {
|
||||
case SelfHostedSignupProgress.START:
|
||||
case SelfHostedSignupProgress.CREATED_CUSTOMER:
|
||||
case SelfHostedSignupProgress.CREATED_INTENT:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const STRIPE_UNEXPECTED_STATE = 'setup_intent_unexpected_state';
|
||||
const STRIPE_ALREADY_SUCCEEDED = 'You cannot update this SetupIntent because it has already succeeded.';
|
||||
|
||||
export function confirmSelfHostedSignup(
|
||||
stripe: Stripe,
|
||||
stripeSetupIntent: StripeSetupIntent,
|
||||
cwsMockMode: boolean,
|
||||
billingDetails: BillingDetails,
|
||||
initialProgress: ValueOf<typeof SelfHostedSignupProgress>,
|
||||
subscriptionRequest: CreateSubscriptionRequest,
|
||||
): ActionFuncAsync<SelfHostedSignupSuccessResponse['license'] | false> {
|
||||
return async (dispatch) => {
|
||||
const cardSetupFunction = getConfirmCardSetup(cwsMockMode);
|
||||
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.confirmSelfHostedSignup(stripeSetupIntent.id, subscriptionRequest);
|
||||
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};
|
||||
};
|
||||
}
|
||||
import type {ThunkActionFunc} from 'mattermost-redux/types/actions';
|
||||
|
||||
export function getSelfHostedProducts(): ThunkActionFunc<Promise<boolean | ServerError>> {
|
||||
return async (dispatch) => {
|
||||
@@ -143,147 +30,3 @@ export function getSelfHostedProducts(): ThunkActionFunc<Promise<boolean | Serve
|
||||
};
|
||||
}
|
||||
|
||||
export function getSelfHostedInvoices(): ThunkActionFunc<Promise<boolean | ServerError>> {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.SELF_HOSTED_INVOICES_REQUEST,
|
||||
});
|
||||
const result = await Client4.getSelfHostedInvoices();
|
||||
if (result) {
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_INVOICES,
|
||||
data: result,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.SELF_HOSTED_INVOICES_FAILED,
|
||||
});
|
||||
return error;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
export function retryFailedHostedCustomerFetches(): ActionFunc<boolean, GlobalState> {
|
||||
return (dispatch, getState) => {
|
||||
const errors = getSelfHostedErrors(getState());
|
||||
if (Object.keys(errors).length === 0) {
|
||||
return {data: true};
|
||||
}
|
||||
|
||||
if (errors.products) {
|
||||
dispatch(getSelfHostedProducts());
|
||||
}
|
||||
|
||||
if (errors.invoices) {
|
||||
dispatch(getSelfHostedInvoices());
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function submitTrueUpReview() {
|
||||
return bindClientFunc({
|
||||
clientFunc: Client4.submitTrueUpReview,
|
||||
onSuccess: [HostedCustomerTypes.RECEIVED_TRUE_UP_REVIEW_BUNDLE],
|
||||
onFailure: HostedCustomerTypes.TRUE_UP_REVIEW_PROFILE_FAILED,
|
||||
onRequest: HostedCustomerTypes.TRUE_UP_REVIEW_PROFILE_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
export function getTrueUpReviewStatus() {
|
||||
return bindClientFunc({
|
||||
clientFunc: Client4.getTrueUpReviewStatus,
|
||||
onSuccess: [HostedCustomerTypes.RECEIVED_TRUE_UP_REVIEW_STATUS],
|
||||
onFailure: HostedCustomerTypes.TRUE_UP_REVIEW_STATUS_FAILED,
|
||||
onRequest: HostedCustomerTypes.TRUE_UP_REVIEW_STATUS_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmSelfHostedExpansion(
|
||||
stripe: Stripe,
|
||||
stripeSetupIntent: StripeSetupIntent,
|
||||
cwsMockMode: boolean,
|
||||
billingDetails: BillingDetails,
|
||||
initialProgress: ValueOf<typeof SelfHostedSignupProgress>,
|
||||
expansionRequest: SelfHostedExpansionRequest,
|
||||
): ActionFuncAsync<SelfHostedSignupSuccessResponse['license'] | false> {
|
||||
return async (dispatch) => {
|
||||
const cardSetupFunction = getConfirmCardSetup(cwsMockMode);
|
||||
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};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -326,15 +326,7 @@ const AdminDefinition: AdminDefinitionType = {
|
||||
/>
|
||||
),
|
||||
sectionTitle: defineMessage({id: 'admin.sidebar.billing', defaultMessage: 'Billing & Account'}),
|
||||
isHidden: it.any(
|
||||
it.not(it.enterpriseReady),
|
||||
it.not(it.userHasReadPermissionOnResource('billing')),
|
||||
it.not(it.licensed),
|
||||
it.all(
|
||||
it.not(it.licensedForFeature('Cloud')),
|
||||
it.configIsFalse('ServiceSettings', 'SelfHostedPurchase'),
|
||||
),
|
||||
),
|
||||
isHidden: it.not(it.licensedForFeature('Cloud')),
|
||||
subsections: {
|
||||
subscription: {
|
||||
url: 'billing/subscription',
|
||||
@@ -357,6 +349,7 @@ const AdminDefinition: AdminDefinitionType = {
|
||||
id: 'BillingHistory',
|
||||
component: BillingHistory,
|
||||
},
|
||||
isHidden: it.not(it.licensedForFeature('Cloud')),
|
||||
isDisabled: it.not(it.userHasWritePermissionOnResource('billing')),
|
||||
},
|
||||
company_info: {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {SelfHostedSignupProgress} from '@mattermost/types/cloud';
|
||||
import type {ExperimentalSettings, PluginSettings, SSOSettings, Office365Settings} from '@mattermost/types/config';
|
||||
|
||||
import {RESOURCE_KEYS} from 'mattermost-redux/constants/permissions_sysconsole';
|
||||
@@ -93,9 +92,6 @@ describe('components/AdminSidebar', () => {
|
||||
limits: {},
|
||||
},
|
||||
errors: {},
|
||||
selfHostedSignup: {
|
||||
progress: SelfHostedSignupProgress.START,
|
||||
},
|
||||
},
|
||||
showTaskList: false,
|
||||
};
|
||||
|
||||
@@ -163,79 +163,6 @@ describe('components/admin_console/billing/billing_history', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('BillingHistory -- self-hosted', () => {
|
||||
// required state to mount using the provider
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'false',
|
||||
},
|
||||
config: {
|
||||
DiagnosticsEnabled: 'false',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'current_user_id',
|
||||
profiles: {
|
||||
current_user_id: {roles: 'system_role'},
|
||||
},
|
||||
},
|
||||
hostedCustomer: {
|
||||
errors: {},
|
||||
invoices: {
|
||||
invoices: {
|
||||
in_1KNb3DI67GP2qpb4ueaJYBt8: invoiceA,
|
||||
in_1KIWNTI67GP2qpb4KjGj1KAy: invoiceB,
|
||||
},
|
||||
invoicesLoaded: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {},
|
||||
};
|
||||
|
||||
test('Billing history section shows template when no invoices have been emitted yet', () => {
|
||||
const noBillingHistoryState = {
|
||||
...state,
|
||||
entities: {...state.entities, hostedCustomer: {invoices: {invoices: {}, invoicesLoaded: true}, errors: {}}},
|
||||
};
|
||||
renderWithContext(
|
||||
<BillingHistory/>,
|
||||
noBillingHistoryState,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Date')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Description')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Total')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Status')).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId(invoiceA.number)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(invoiceB.number)).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId(invoiceA.id)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(invoiceB.id)).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', HostedCustomerLinks.SELF_HOSTED_BILLING + '?utm_source=mattermost&utm_medium=in-product&utm_content=billing_history&uid=current_user_id&sid=');
|
||||
expect(screen.getByRole('link')).toHaveTextContent('See how billing works');
|
||||
expect(screen.getByTestId('no-invoices')).toHaveTextContent(NO_INVOICES_LEGEND);
|
||||
});
|
||||
|
||||
test('Billing history section shows two invoices to download', () => {
|
||||
renderWithContext(
|
||||
<BillingHistory/>,
|
||||
state,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Date')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Description')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Total')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Status')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('billingHistoryTableRow')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoBillingHistorySection', () => {
|
||||
const state = {entities: {users: {}, general: {config: {}, license: {}}}} as any;
|
||||
test('goes to cloud docs on cloud', () => {
|
||||
|
||||
@@ -7,9 +7,7 @@ import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {getInvoices} from 'mattermost-redux/actions/cloud';
|
||||
import {getCloudErrors, getCloudInvoices, isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getSelfHostedErrors, getSelfHostedInvoices} from 'mattermost-redux/selectors/entities/hosted_customer';
|
||||
|
||||
import {getSelfHostedInvoices as getSelfHostedInvoicesAction} from 'actions/hosted_customer';
|
||||
import {pageVisited, trackEvent} from 'actions/telemetry_actions';
|
||||
|
||||
import CloudFetchError from 'components/cloud_fetch_error';
|
||||
@@ -65,14 +63,14 @@ export const NoBillingHistorySection = (props: NoBillingHistorySectionProps) =>
|
||||
const BillingHistory = () => {
|
||||
const dispatch = useDispatch();
|
||||
const isCloud = useSelector(isCurrentLicenseCloud);
|
||||
const invoices = useSelector(isCloud ? getCloudInvoices : getSelfHostedInvoices);
|
||||
const {invoices: invoicesError} = useSelector(isCloud ? getCloudErrors : getSelfHostedErrors);
|
||||
const invoices = useSelector(getCloudInvoices);
|
||||
const {invoices: invoicesError} = useSelector(getCloudErrors);
|
||||
|
||||
useEffect(() => {
|
||||
pageVisited('cloud_admin', 'pageview_billing_history');
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
dispatch(isCloud ? getInvoices() : getSelfHostedInvoicesAction());
|
||||
dispatch(getInvoices());
|
||||
}, [isCloud]);
|
||||
const billingHistoryTable = invoices && <BillingHistoryTable invoices={invoices}/>;
|
||||
const areInvoicesEmpty = Object.keys(invoices || {}).length === 0;
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {FormattedDate, FormattedMessage, FormattedNumber} from 'react-intl';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import type {Invoice} from '@mattermost/types/cloud';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
@@ -61,8 +60,6 @@ const getPaymentStatus = (status: string) => {
|
||||
|
||||
export default function BillingHistoryTable({invoices}: BillingHistoryTableProps) {
|
||||
const dispatch = useDispatch();
|
||||
const isCloud = useSelector(isCurrentLicenseCloud);
|
||||
|
||||
const [billingHistory, setBillingHistory] = useState<Invoice[] | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -161,7 +158,7 @@ export default function BillingHistoryTable({invoices}: BillingHistoryTableProps
|
||||
<th>{''}</th>
|
||||
</tr>
|
||||
{billingHistory?.map((invoice: Invoice) => {
|
||||
const url = isCloud ? Client4.getInvoicePdfUrl(invoice.id) : Client4.getSelfHostedInvoicePdfUrl(invoice.id);
|
||||
const url = Client4.getInvoicePdfUrl(invoice.id);
|
||||
return (
|
||||
<tr
|
||||
className='BillingHistory__table-row'
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
@import 'utils/mixins';
|
||||
|
||||
.UpsellCard {
|
||||
&__illustration {
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 10px 0;
|
||||
color: var(--center-channel-color);
|
||||
font-family: Metropolis;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
&__advantages {
|
||||
margin: 7px 0;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
|
||||
.advantage {
|
||||
margin: 12px 0;
|
||||
|
||||
i {
|
||||
margin-right: 7px;
|
||||
}
|
||||
|
||||
&--more {
|
||||
color: rgba(63, 69, 80, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__cta {
|
||||
@include secondary-button;
|
||||
|
||||
width: fit-content;
|
||||
padding: 13px 20px;
|
||||
// override cloud start trial border used in other contexts
|
||||
border: 1px solid var(--denim-button-bg) !important;
|
||||
border: none;
|
||||
background: var(--sys-center-channel-bg);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 14px;
|
||||
|
||||
&.btn-primary {
|
||||
@include primary-button;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin: 5px 0;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 14px;
|
||||
text-align: justify;
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import WomanUpArrowsAndCloudsSvg from 'components/common/svg_images_components/woman_up_arrows_and_clouds_svg';
|
||||
import StartTrialCaution from 'components/pricing_modal/start_trial_caution';
|
||||
|
||||
import {openExternalPricingLink, FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS} from 'utils/cloud_utils';
|
||||
import {t} from 'utils/i18n';
|
||||
import type {Message} from 'utils/i18n';
|
||||
|
||||
import './upsell_card.scss';
|
||||
|
||||
const enterpriseAdvantages = [
|
||||
{
|
||||
id: t('upsell_advantages.onelogin_saml'),
|
||||
defaultMessage: 'OneLogin/ADFS SAML 2.0',
|
||||
},
|
||||
{
|
||||
id: t('upsell_advantages.openid'),
|
||||
defaultMessage: 'OpenID Connect',
|
||||
},
|
||||
{
|
||||
id: t('upsell_advantages.office365'),
|
||||
defaultMessage: 'Office365 suite integration',
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
advantages: Message[];
|
||||
title: Message;
|
||||
andMore: boolean;
|
||||
cta: Message;
|
||||
ctaAction?: () => void;
|
||||
ctaPrimary?: boolean;
|
||||
upsellIsTrial?: boolean;
|
||||
}
|
||||
|
||||
const andMore = {
|
||||
id: t('upsell_advantages.more'),
|
||||
defaultMessage: 'And more...',
|
||||
};
|
||||
|
||||
export default function UpsellCard(props: Props) {
|
||||
const intl = useIntl();
|
||||
|
||||
const ctaClassname = classNames(
|
||||
'UpsellCard__cta',
|
||||
{
|
||||
btn: props.ctaPrimary,
|
||||
'btn-primary': props.ctaPrimary,
|
||||
},
|
||||
);
|
||||
|
||||
let callToAction = (
|
||||
<button
|
||||
className={ctaClassname}
|
||||
onClick={props.ctaAction}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: props.cta.id,
|
||||
defaultMessage: props.cta.defaultMessage,
|
||||
},
|
||||
props.cta.values,
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
if (props.upsellIsTrial) {
|
||||
callToAction = (
|
||||
<>
|
||||
<CloudStartTrialButton
|
||||
message={
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: props.cta.id,
|
||||
defaultMessage: props.cta.defaultMessage,
|
||||
},
|
||||
props.cta.values,
|
||||
)
|
||||
}
|
||||
telemetryId={'start_cloud_trial_billing_subscription'}
|
||||
extraClass={ctaClassname}
|
||||
/>
|
||||
<p className='disclaimer'>
|
||||
<StartTrialCaution/>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='UpsellCard'>
|
||||
<div className='UpsellCard__illustration'>
|
||||
<WomanUpArrowsAndCloudsSvg
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
<div className='UpsellCard__title'>
|
||||
{intl.formatMessage(props.title)}
|
||||
</div>
|
||||
<div className='UpsellCard__advantages'>
|
||||
{props.advantages.map((message: Message) => {
|
||||
return (
|
||||
<div
|
||||
className='advantage'
|
||||
key={message.id}
|
||||
>
|
||||
<i className='fa fa-lock'/>{intl.formatMessage(message)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{props.andMore && <div className='advantage advantage--more'>
|
||||
<i className='fa fa-lock'/>{intl.formatMessage(andMore)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
{callToAction}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const tryEnterpriseCard = (
|
||||
<UpsellCard
|
||||
title={{
|
||||
id: t('admin.billing.subscriptions.billing_summary.try_enterprise'),
|
||||
defaultMessage: 'Try Enterprise features for free',
|
||||
}}
|
||||
cta={{
|
||||
id: t('admin.billing.subscriptions.billing_summary.try_enterprise.cta'),
|
||||
defaultMessage: 'Try free for {trialLength} days',
|
||||
values: {
|
||||
trialLength: FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS,
|
||||
},
|
||||
}}
|
||||
andMore={true}
|
||||
advantages={enterpriseAdvantages}
|
||||
upsellIsTrial={true}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ExploreEnterpriseCard = () => {
|
||||
return (
|
||||
<UpsellCard
|
||||
title={{
|
||||
|
||||
id: t('admin.billing.subscriptions.billing_summary.explore_enterprise'),
|
||||
defaultMessage: 'Explore Enterprise features',
|
||||
}}
|
||||
cta={{
|
||||
id: t('admin.billing.subscriptions.billing_summary.explore_enterprise.cta'),
|
||||
defaultMessage: 'View all features',
|
||||
}}
|
||||
ctaAction={openExternalPricingLink}
|
||||
andMore={true}
|
||||
advantages={enterpriseAdvantages}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {injectIntl} from 'react-intl';
|
||||
import type {WrappedComponentProps} from 'react-intl';
|
||||
|
||||
import type {Feedback} from '@mattermost/types/cloud';
|
||||
|
||||
import FeedbackModal from 'components/feedback_modal/feedback';
|
||||
import type {FeedbackOption} from 'components/feedback_modal/feedback';
|
||||
|
||||
type Props = {
|
||||
onSubmit: (deleteFeedback: Feedback) => void;
|
||||
} &WrappedComponentProps
|
||||
|
||||
const DeleteFeedbackModal = (props: Props) => {
|
||||
const deleteFeedbackModalTitle = props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackTitle',
|
||||
defaultMessage: 'Please share your reason for deleting',
|
||||
});
|
||||
|
||||
const placeHolder = props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackPlaceholder',
|
||||
defaultMessage: 'Please tell us why you are deleting',
|
||||
});
|
||||
|
||||
const deleteButtonText = props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.submitText',
|
||||
defaultMessage: 'Delete Workspace',
|
||||
});
|
||||
|
||||
const deleteFeedbackOptions: FeedbackOption[] = [
|
||||
{
|
||||
translatedMessage: props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackNoValue',
|
||||
defaultMessage: 'No longer found value',
|
||||
}),
|
||||
submissionValue: 'No longer found value',
|
||||
},
|
||||
{
|
||||
translatedMessage: props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackMoving',
|
||||
defaultMessage: 'Moving to a different solution',
|
||||
}),
|
||||
submissionValue: 'Moving to a different solution',
|
||||
},
|
||||
{
|
||||
translatedMessage: props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackMistake',
|
||||
defaultMessage: 'Created a workspace by mistake',
|
||||
}),
|
||||
submissionValue: 'Created a workspace by mistake',
|
||||
},
|
||||
{
|
||||
translatedMessage: props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackHosting',
|
||||
defaultMessage: 'Moving to hosting my own Mattermost instance (self-hosted)',
|
||||
}),
|
||||
submissionValue: 'Moving to hosting my own Mattermost instance (self-hosted)',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<FeedbackModal
|
||||
title={deleteFeedbackModalTitle}
|
||||
feedbackOptions={deleteFeedbackOptions}
|
||||
freeformTextPlaceholder={placeHolder}
|
||||
submitText={deleteButtonText}
|
||||
onSubmit={props.onSubmit}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default injectIntl(DeleteFeedbackModal);
|
||||
@@ -1,92 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage, defineMessages} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {getCloudSubscription, getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
import {CloudProducts, ModalIdentifiers} from 'utils/constants';
|
||||
import {isCloudLicense} from 'utils/license_utils';
|
||||
|
||||
import DeleteWorkspaceModal from './delete_workspace_modal';
|
||||
|
||||
export const messages = defineMessages({
|
||||
title: {id: 'admin.billing.subscription.deleteWorkspaceSection.title', defaultMessage: 'Delete your workspace'},
|
||||
});
|
||||
export default function DeleteWorkspaceCTA() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const workspaceUrl = window.location.host;
|
||||
|
||||
const license = useSelector(getLicense);
|
||||
const subscription = useSelector(getCloudSubscription);
|
||||
const product = useSelector(getSubscriptionProduct);
|
||||
|
||||
const isNotCloud = !isCloudLicense(license);
|
||||
const isFreeTrial = subscription?.is_free_trial === 'true';
|
||||
const isEnterprise = product?.sku === CloudProducts.ENTERPRISE;
|
||||
|
||||
const handleOnClickDelete = () => {
|
||||
trackEvent('cloud_admin', 'click_delete_workspace');
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE,
|
||||
dialogType: DeleteWorkspaceModal,
|
||||
dialogProps: {
|
||||
callerCTA: 'system_console > billing > subscription > delete_workspace_cta',
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Can only delete or downgrade via workspace deletion modal if:
|
||||
// - the user has a cloud product
|
||||
// - the user is on a free trial (enterprise product with trial status)
|
||||
// - the user is on a starter subscription
|
||||
// - the user is on a monthly professional subscription
|
||||
//
|
||||
// For clarity, workspaces with the following subscriptions may be deleted:
|
||||
// - Cloud-Starter
|
||||
// - Cloud-Professional (monthly)
|
||||
// - Enterprise Free Trial
|
||||
if (isNotCloud || (isEnterprise && !isFreeTrial)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='cancelSubscriptionSection'>
|
||||
<div className='cancelSubscriptionSection__text'>
|
||||
<div className='cancelSubscriptionSection__text-title'>
|
||||
<FormattedMessage {...messages.title}/>
|
||||
</div>
|
||||
<div className='cancelSubscriptionSection__text-description'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceSection.description'
|
||||
defaultMessage='Deleting {workspaceLink} is final and cannot be reversed.'
|
||||
values={{
|
||||
workspaceLink: (
|
||||
<a href={`${workspaceUrl}`}>{workspaceUrl}</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className='btn cancelSubscriptionSection__contactUs'
|
||||
onClick={handleOnClickDelete}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceSection.delete'
|
||||
defaultMessage='Delete Workspace'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
.DeleteWorkspaceModal {
|
||||
width: 600px;
|
||||
|
||||
.modal-body {
|
||||
.GenericModal__body {
|
||||
padding: 24px 24px 0 24px;
|
||||
text-align: center;
|
||||
|
||||
* {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__Icon {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
&__Title {
|
||||
color: var(--sys-denim-center-channel-text);
|
||||
font-family: Metropolis;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
&__Usage {
|
||||
color: var(--center-channel-color);
|
||||
text-align: left;
|
||||
|
||||
&-Highlighted {
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&__Warning {
|
||||
color: var(--center-channel-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__Buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&-Delete {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dnd-indicator);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-Downgrade {
|
||||
border-color: var(--denim-button-bg);
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
color: var(--denim-button-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-Cancel {
|
||||
margin-left: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage, defineMessages} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
import type {Feedback} from '@mattermost/types/cloud';
|
||||
|
||||
import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import {subscribeCloudSubscription, deleteWorkspace as deleteWorkspaceRequest} from 'actions/cloud';
|
||||
import {closeModal, openModal} from 'actions/views/modals';
|
||||
|
||||
import DeleteFeedbackModal from 'components/admin_console/billing/delete_workspace/delete_feedback';
|
||||
import DeleteWorkspaceProgressModal from 'components/admin_console/billing/delete_workspace/progress_modal';
|
||||
import ErrorModal from 'components/cloud_subscribe_result_modal/error';
|
||||
import SuccessModal from 'components/cloud_subscribe_result_modal/success';
|
||||
import useGetSubscription from 'components/common/hooks/useGetSubscription';
|
||||
import useGetUsage from 'components/common/hooks/useGetUsage';
|
||||
import useOpenDowngradeModal from 'components/common/hooks/useOpenDowngradeModal';
|
||||
import LaptopAlertSVG from 'components/common/svg_images_components/laptop_alert_svg';
|
||||
import DowngradeFeedbackModal from 'components/feedback_modal/downgrade_feedback';
|
||||
|
||||
import {CloudProducts, ModalIdentifiers, StatTypes} from 'utils/constants';
|
||||
import {isCloudLicense} from 'utils/license_utils';
|
||||
import {fileSizeToString} from 'utils/utils';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import DeleteWorkspaceFailureModal from './failure_modal';
|
||||
import DeleteWorkspaceSuccessModal from './success_modal';
|
||||
|
||||
import './delete_workspace_modal.scss';
|
||||
|
||||
type Props = {
|
||||
callerCTA: string;
|
||||
}
|
||||
|
||||
export const messages = defineMessages({
|
||||
deleteButton: {id: 'admin.billing.subscription.deleteWorkspaceModal.deleteButton', defaultMessage: 'Delete Workspace'},
|
||||
});
|
||||
|
||||
export default function DeleteWorkspaceModal(props: Props) {
|
||||
const dispatch = useDispatch();
|
||||
const openDowngradeModal = useOpenDowngradeModal();
|
||||
|
||||
// License/product checks.
|
||||
const subscription = useGetSubscription();
|
||||
const product = useSelector(getSubscriptionProduct);
|
||||
const isStarter = product?.sku === CloudProducts.STARTER;
|
||||
const isEnterprise = product?.sku === CloudProducts.ENTERPRISE;
|
||||
const license = useSelector(getLicense);
|
||||
const isNotCloud = !isCloudLicense(license);
|
||||
|
||||
// Starter product for downgrade purposes.
|
||||
const starterProduct = useSelector((state: GlobalState) => {
|
||||
return Object.values(state.entities.cloud.products || {}).find((product) => {
|
||||
return product.sku === CloudProducts.STARTER;
|
||||
});
|
||||
});
|
||||
|
||||
// Get usage information in an attempt to defer customer from deleting.
|
||||
const usage = useGetUsage();
|
||||
const totalFileSize = fileSizeToString(usage.files.totalStorage);
|
||||
const totalMessages = useSelector((state: GlobalState) => {
|
||||
if (!state.entities.admin.analytics) {
|
||||
return 0;
|
||||
}
|
||||
return state.entities.admin.analytics[StatTypes.TOTAL_POSTS];
|
||||
});
|
||||
|
||||
// Handles the delete button clicks.
|
||||
const handleClickDeleteWorkspace = () => {
|
||||
// Close the delete workspace modal and ope na feedback modal, with a workspace
|
||||
// deletion upon completion of the feedback.
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE));
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.FEEDBACK,
|
||||
dialogType: DeleteFeedbackModal,
|
||||
dialogProps: {
|
||||
onSubmit: deleteWorkspace,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Handles the downgrade button clicks.
|
||||
const handleClickDowngradeWorkspace = () => {
|
||||
// Close the delete workspace modal and ope na feedback modal, with a workspace
|
||||
// downgrade upon completion of the feedback.
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE));
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.FEEDBACK,
|
||||
dialogType: DowngradeFeedbackModal,
|
||||
dialogProps: {
|
||||
onSubmit: downgradeWorkspace,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Handles the cancel button clicks.
|
||||
const handleClickCancel = () => {
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE));
|
||||
dispatch(closeModal(ModalIdentifiers.FEEDBACK));
|
||||
};
|
||||
|
||||
// Processes the workspace deletion, opening and closing the appropriate modals (progress, success/failure).
|
||||
const deleteWorkspace = async (deleteFeedback: Feedback) => {
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE_PROGRESS,
|
||||
dialogType: DeleteWorkspaceProgressModal,
|
||||
}));
|
||||
dispatch(closeModal(ModalIdentifiers.DOWNGRADE_MODAL));
|
||||
|
||||
if (subscription === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dispatch(deleteWorkspaceRequest({subscription_id: subscription?.id, delete_feedback: deleteFeedback}));
|
||||
|
||||
if (typeof result === 'boolean' && result) {
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE_PROGRESS));
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE_RESULT,
|
||||
dialogType: DeleteWorkspaceSuccessModal,
|
||||
}));
|
||||
} else { // Failure
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE_RESULT,
|
||||
dialogType: DeleteWorkspaceFailureModal,
|
||||
}));
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE_PROGRESS));
|
||||
}
|
||||
};
|
||||
|
||||
// Processes the workspace downgrade, opening and closing the appropriate modals (progress, success/failure).
|
||||
const downgradeWorkspace = async (downgradeFeedback: Feedback) => {
|
||||
if (!starterProduct) {
|
||||
return;
|
||||
}
|
||||
|
||||
const telemetryInfo = props.callerCTA + ' > delete_workspace_modal';
|
||||
openDowngradeModal({trackingLocation: telemetryInfo});
|
||||
|
||||
const result = await dispatch(subscribeCloudSubscription(starterProduct.id, undefined, 0, downgradeFeedback));
|
||||
|
||||
// Success
|
||||
if (result.data) {
|
||||
dispatch(closeModal(ModalIdentifiers.DOWNGRADE_MODAL));
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.SUCCESS_MODAL,
|
||||
dialogType: SuccessModal,
|
||||
dialogProps: {
|
||||
newProductName: starterProduct.name,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else { // Failure
|
||||
dispatch(closeModal(ModalIdentifiers.DOWNGRADE_MODAL));
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.ERROR_MODAL,
|
||||
dialogType: ErrorModal,
|
||||
dialogProps: {
|
||||
backButtonAction: () => {
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE,
|
||||
dialogType: DeleteWorkspaceModal,
|
||||
dialogProps: {
|
||||
callerCTA: props.callerCTA,
|
||||
},
|
||||
}));
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isNotCloud) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
compassDesign={true}
|
||||
className='DeleteWorkspaceModal'
|
||||
onExited={handleClickCancel}
|
||||
>
|
||||
<div className='DeleteWorkspaceModal__Icon'>
|
||||
<LaptopAlertSVG height={156}/>
|
||||
</div>
|
||||
<div className='DeleteWorkspaceModal__Title'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.title'
|
||||
defaultMessage='Are you sure you want to delete?'
|
||||
/>
|
||||
</div>
|
||||
<div className='DeleteWorkspaceModal__Usage'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.usage'
|
||||
defaultMessage='As part of your subscription to Mattermost {sku} you have created '
|
||||
values={{
|
||||
sku: product?.name,
|
||||
}}
|
||||
/>
|
||||
<span className='DeleteWorkspaceModal__Usage-Highlighted'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.usageDetails'
|
||||
defaultMessage='{messageCount} messages and {fileSize} of files'
|
||||
values={{
|
||||
messageCount: totalMessages,
|
||||
fileSize: totalFileSize,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className='DeleteWorkspaceModal__Warning'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.warning'
|
||||
defaultMessage="Deleting your workspace is final. Upon deleting, you'll lose all of the above with no ability to recover. If you downgrade to Free, you will not lose this information."
|
||||
/>
|
||||
</div>
|
||||
<div className='DeleteWorkspaceModal__Buttons'>
|
||||
<button
|
||||
className='btn DeleteWorkspaceModal__Buttons-Delete'
|
||||
onClick={handleClickDeleteWorkspace}
|
||||
>
|
||||
<FormattedMessage {...messages.deleteButton}/>
|
||||
</button>
|
||||
{!isStarter && !isEnterprise &&
|
||||
<button
|
||||
className='btn DeleteWorkspaceModal__Buttons-Downgrade'
|
||||
onClick={handleClickDowngradeWorkspace}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.downgradeButton'
|
||||
defaultMessage='Downgrade To Free'
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
className='btn btn-primary DeleteWorkspaceModal__Buttons-Cancel'
|
||||
onClick={handleClickCancel}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.cancelButton'
|
||||
defaultMessage='Keep Subscription'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</GenericModal>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// 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 {useDispatch} from 'react-redux';
|
||||
|
||||
import {closeModal, openModal} from 'actions/views/modals';
|
||||
|
||||
import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg';
|
||||
|
||||
import {ModalIdentifiers} from 'utils/constants';
|
||||
|
||||
import DeleteWorkspaceModal from './delete_workspace_modal';
|
||||
import ResultModal from './result_modal';
|
||||
|
||||
export default function DeleteWorkspaceFailureModal() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleButtonClick = () => {
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE_RESULT));
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE,
|
||||
dialogType: DeleteWorkspaceModal,
|
||||
dialogProps: {
|
||||
callerCTA: 'delete_workspace_failure_modal',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const title = (
|
||||
<FormattedMessage
|
||||
defaultMessage={'Workspace deletion failed'}
|
||||
id={'admin.billing.deleteWorkspace.failureModal.title'}
|
||||
/>
|
||||
);
|
||||
|
||||
const subtitle = (
|
||||
<FormattedMessage
|
||||
id={'admin.billing.deleteWorkspace.failureModal.subtitle'}
|
||||
defaultMessage={'We ran into an issue deleting your workspace. Please try again or contact support.'}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttonText = (
|
||||
<FormattedMessage
|
||||
id='admin.billing.deleteWorkspace.failureModal.buttonText'
|
||||
defaultMessage={'Try Again'}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResultModal
|
||||
primaryButtonText={buttonText}
|
||||
primaryButtonHandler={handleButtonClick}
|
||||
identifier={ModalIdentifiers.DELETE_WORKSPACE_RESULT}
|
||||
subtitle={subtitle}
|
||||
title={title}
|
||||
ignoreExit={false}
|
||||
resultType='failure'
|
||||
icon={
|
||||
<PaymentFailedSvg
|
||||
width={444}
|
||||
height={313}
|
||||
/>
|
||||
}
|
||||
contactSupportButtonVisible={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -113,13 +113,10 @@ describe('components/feature_discovery', () => {
|
||||
expect(screen.queryByText('Foo')).toBeInTheDocument();
|
||||
|
||||
//this option is visible only when it is cloud environment
|
||||
expect(screen.getByRole('button', {name: 'Try free for 30 days'})).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Try free for 30 days')).toHaveLength(2);
|
||||
expect(screen.getByRole('button', {name: 'Contact sales'})).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId('featureDiscovery_secondaryCallToAction')).toHaveAttribute('href', 'https://test.mattermost.com/secondary/?utm_source=mattermost&utm_medium=in-product&utm_content=feature_discovery&uid=&sid=');
|
||||
|
||||
expect(screen.getByText('Privacy Policy')).toHaveAttribute('href', 'https://mattermost.com/pl/privacy-policy/?utm_source=mattermost&utm_medium=in-product&utm_content=feature_discovery&uid=&sid=');
|
||||
|
||||
const featureLink = screen.getByTestId('featureDiscovery_secondaryCallToAction');
|
||||
|
||||
expect(featureLink).toBeInTheDocument();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {AnalyticsState} from '@mattermost/types/admin';
|
||||
import type {CloudCustomer} from '@mattermost/types/cloud';
|
||||
@@ -13,13 +13,11 @@ import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {EmbargoedEntityTrialError} from 'components/admin_console/license_settings/trial_banner/trial_banner';
|
||||
import AlertBanner from 'components/alert_banner';
|
||||
import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_link';
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
|
||||
import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn';
|
||||
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
|
||||
|
||||
import {FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS} from 'utils/cloud_utils';
|
||||
import {TELEMETRY_CATEGORIES, AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants';
|
||||
import {goToMattermostContactSalesForm} from 'utils/contact_support_sales';
|
||||
import * as Utils from 'utils/utils';
|
||||
@@ -147,13 +145,8 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
|
||||
renderStartTrial = (learnMoreURL: string, gettingTrialError: React.ReactNode) => {
|
||||
const {
|
||||
isCloud,
|
||||
isCloudTrial,
|
||||
hadPrevCloudTrial,
|
||||
isPaidSubscription,
|
||||
} = this.props;
|
||||
|
||||
const canRequestCloudFreeTrial = isCloud && !isCloudTrial && !hadPrevCloudTrial && !isPaidSubscription;
|
||||
|
||||
// by default we assume is not cloud, so the cta button is Start Trial (which will request a trial license)
|
||||
let ctaPrimaryButton = (
|
||||
<StartTrialBtn
|
||||
@@ -169,32 +162,22 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
|
||||
);
|
||||
|
||||
if (isCloud) {
|
||||
// if all conditions are set for being able to request a cloud trial, then show the cta start cloud trial button
|
||||
if (canRequestCloudFreeTrial) {
|
||||
ctaPrimaryButton = (
|
||||
<FeatureDiscoveryCloudStartTrialButton
|
||||
telemetryId={`start_cloud_trial_from_${this.props.featureName}`}
|
||||
extraClass='btn btn-primary'
|
||||
// In cloud, only option is to contact sales.
|
||||
ctaPrimaryButton = (
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
data-testid='featureDiscovery_primaryCallToAction'
|
||||
onClick={() => {
|
||||
trackEvent(TELEMETRY_CATEGORIES.CLOUD_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
|
||||
this.contactSalesFunc();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.ldap_feature_discovery_cloud.call_to_action.primary_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
);
|
||||
if (this.props.cloudFreeDeprecated) {
|
||||
ctaPrimaryButton = (
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
data-testid='featureDiscovery_primaryCallToAction'
|
||||
onClick={() => {
|
||||
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
|
||||
this.contactSalesFunc();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.ldap_feature_discovery_cloud.call_to_action.primary_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -212,62 +195,35 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
|
||||
/>
|
||||
</ExternalLink>
|
||||
{gettingTrialError}
|
||||
{((!this.props.isCloud || canRequestCloudFreeTrial) && !this.props.cloudFreeDeprecated) && <p className='trial-legal-terms'>
|
||||
{canRequestCloudFreeTrial ? (
|
||||
<FormattedMessage
|
||||
id='admin.feature_discovery.trial-request.accept-terms.cloudFree'
|
||||
defaultMessage='By selecting <highlight>Try free for {trialLength} days</highlight>, I agree to the <linkEvaluation>Mattermost Software and Services License Agreement</linkEvaluation>, <linkPrivacy>Privacy Policy</linkPrivacy>, and receiving product emails.'
|
||||
values={{
|
||||
trialLength: FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS,
|
||||
highlight: (msg: React.ReactNode) => (
|
||||
<strong>{msg}</strong>
|
||||
),
|
||||
linkEvaluation: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={LicenseLinks.SOFTWARE_SERVICES_LICENSE_AGREEMENT}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
linkPrivacy: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={AboutLinks.PRIVACY_POLICY}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='admin.feature_discovery.trial-request.accept-terms'
|
||||
defaultMessage='By clicking <highlight>Start trial</highlight>, I agree to the <linkEvaluation>Mattermost Software and Services License Agreement</linkEvaluation>, <linkPrivacy>Privacy Policy</linkPrivacy> and receiving product emails.'
|
||||
values={{
|
||||
highlight: (msg: React.ReactNode) => (
|
||||
<strong>{msg}</strong>
|
||||
),
|
||||
linkEvaluation: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={LicenseLinks.SOFTWARE_SERVICES_LICENSE_AGREEMENT}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
linkPrivacy: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={AboutLinks.PRIVACY_POLICY}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>}
|
||||
{(!this.props.isCloud) && (<p className='trial-legal-terms'>
|
||||
|
||||
<FormattedMessage
|
||||
id='admin.feature_discovery.trial-request.accept-terms'
|
||||
defaultMessage='By clicking <highlight>Start trial</highlight>, I agree to the <linkEvaluation>Mattermost Software and Services License Agreement</linkEvaluation>, <linkPrivacy>Privacy Policy</linkPrivacy> and receiving product emails.'
|
||||
values={{
|
||||
highlight: (msg: React.ReactNode) => (
|
||||
<strong>{msg}</strong>
|
||||
),
|
||||
linkEvaluation: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={LicenseLinks.SOFTWARE_SERVICES_LICENSE_AGREEMENT}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
linkPrivacy: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={AboutLinks.PRIVACY_POLICY}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
</p>)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -375,22 +331,3 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function FeatureDiscoveryCloudStartTrialButton(props: Omit<React.ComponentProps<typeof CloudStartTrialButton>, 'message'>) {
|
||||
const message = useIntl().formatMessage(
|
||||
{
|
||||
id: 'admin.ldap_feature_discovery.call_to_action.primary.cloudFree',
|
||||
defaultMessage: 'Try free for {trialLength} days',
|
||||
},
|
||||
{
|
||||
trialLength: FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<CloudStartTrialButton
|
||||
{...props}
|
||||
message={message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ exports[`components/admin_console/license_settings/LicenseSettings load screen a
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -125,7 +124,6 @@ exports[`components/admin_console/license_settings/LicenseSettings load screen w
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -230,7 +228,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -366,7 +363,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -490,7 +486,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -614,7 +609,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -738,7 +732,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={true}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -862,7 +855,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -1311,7 +1303,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -1735,7 +1726,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
|
||||
@@ -10,8 +10,6 @@ import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
|
||||
import * as useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
|
||||
|
||||
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
@@ -240,34 +238,4 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
|
||||
|
||||
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', () => {
|
||||
const testLicense = {
|
||||
...license,
|
||||
ExpiresAt: moment().add(61, 'days').valueOf().toString(),
|
||||
};
|
||||
|
||||
const testState = mergeObjects(initialState, {
|
||||
entities: {
|
||||
general: {
|
||||
license: testLicense,
|
||||
},
|
||||
},
|
||||
});
|
||||
const props = {
|
||||
...baseProps,
|
||||
license: testLicense,
|
||||
};
|
||||
|
||||
jest.spyOn(useCanSelfHostedExpand, 'default').mockImplementation(() => true);
|
||||
|
||||
renderWithContext(
|
||||
<EnterpriseEditionLeftPanel
|
||||
{...props}
|
||||
/>,
|
||||
testState,
|
||||
);
|
||||
|
||||
expect(screen.getByText('+ Add seats')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,8 +394,6 @@ export default class LicenseSettings extends React.PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
renewLicenseCard = () => {
|
||||
const {isDisabled} = this.props;
|
||||
|
||||
if (isTrialLicense(this.props.license)) {
|
||||
return (
|
||||
<TrialLicenseCard
|
||||
@@ -409,7 +407,6 @@ export default class LicenseSettings extends React.PureComponent<Props, State> {
|
||||
license={this.props.license}
|
||||
isLicenseExpired={isLicenseExpired(this.props.license)}
|
||||
totalUsers={this.props.totalUsers}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import React from 'react';
|
||||
import {act} from 'react-dom/test-utils';
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import mockStore from 'tests/test_store';
|
||||
|
||||
@@ -71,31 +69,7 @@ describe('components/RenewalLicenseCard', () => {
|
||||
isDisabled: false,
|
||||
};
|
||||
|
||||
test('should show Renew and Contact sales buttons when a renewal link is successfully returned', async () => {
|
||||
const getRenewalLinkSpy = jest.spyOn(Client4, 'getRenewalLink');
|
||||
const promise = new Promise<{renewal_link: string}>((resolve) => {
|
||||
resolve({
|
||||
renewal_link: 'https://testrenewallink',
|
||||
});
|
||||
});
|
||||
getRenewalLinkSpy.mockImplementation(() => promise);
|
||||
const store = mockStore(initialState);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><RenewalLicenseCard {...props}/></Provider>);
|
||||
|
||||
// wait for the promise to resolve and component to update
|
||||
await actImmediate(wrapper);
|
||||
|
||||
expect(wrapper.find('button').length).toEqual(2);
|
||||
expect(wrapper.find('button').at(0).text().includes('Renew')).toBe(true);
|
||||
expect(wrapper.find('button').at(1).text().includes('Contact sales')).toBe(true);
|
||||
});
|
||||
|
||||
test('should show only Contact sales button when a renewal link is not able to renew license', async () => {
|
||||
const getRenewalLinkSpy = jest.spyOn(Client4, 'getRenewalLink');
|
||||
const promise = new Promise<{renewal_link: string}>((resolve, reject) => {
|
||||
reject(new Error('License cannot be renewed from portal'));
|
||||
});
|
||||
getRenewalLinkSpy.mockImplementation(() => promise);
|
||||
test('should show Contact sales button', async () => {
|
||||
const store = mockStore(initialState);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><RenewalLicenseCard {...props}/></Provider>);
|
||||
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import moment from 'moment';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {ClientLicense} from '@mattermost/types/config';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import AlertBanner from 'components/alert_banner';
|
||||
import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us';
|
||||
import RenewalLink from 'components/announcement_bar/renewal_link/';
|
||||
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
|
||||
|
||||
import {getSkuDisplayName} from 'utils/subscription';
|
||||
@@ -23,23 +20,12 @@ export interface RenewLicenseCardProps {
|
||||
license: ClientLicense;
|
||||
isLicenseExpired: boolean;
|
||||
totalUsers: number;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
const RenewLicenseCard: React.FC<RenewLicenseCardProps> = ({license, totalUsers, isLicenseExpired, isDisabled}: RenewLicenseCardProps) => {
|
||||
const [showContactSalesBtn, setShowContactSalesBtn] = useState(true);
|
||||
useEffect(() => {
|
||||
Client4.getRenewalLink().catch(() => {
|
||||
// if we have an error with getting the renewal link, do not show contact sales button because
|
||||
// it is already shown by the RenewalLink component
|
||||
setShowContactSalesBtn(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const RenewLicenseCard: React.FC<RenewLicenseCardProps> = ({license, totalUsers, isLicenseExpired}: RenewLicenseCardProps) => {
|
||||
let bannerType: 'info' | 'warning' | 'danger' = 'info';
|
||||
const endOfLicense = moment.utc(new Date(parseInt(license?.ExpiresAt, 10)));
|
||||
const daysToEndLicense = getRemainingDaysFromFutureTimestamp(parseInt(license?.ExpiresAt, 10));
|
||||
const renewLinkTelemetry = {success: 'renew_license_admin_console_success', error: 'renew_license_admin_console_fail'};
|
||||
const contactSalesBtn = (
|
||||
<div className='purchase-card'>
|
||||
<ContactUsButton
|
||||
@@ -71,18 +57,12 @@ const RenewLicenseCard: React.FC<RenewLicenseCardProps> = ({license, totalUsers,
|
||||
/>
|
||||
);
|
||||
}
|
||||
const customBtnText = (
|
||||
<FormattedMessage
|
||||
id='admin.license.warn.renew'
|
||||
defaultMessage='Renew'
|
||||
/>
|
||||
);
|
||||
const message = (
|
||||
<div className='RenewLicenseCard__text'>
|
||||
<div className='RenewLicenseCard__text-description bolder'>
|
||||
<FormattedMessage
|
||||
id='admin.license.renewalCard.description'
|
||||
defaultMessage='Renew your {licenseSku} license through the Customer Portal to avoid any disruption.'
|
||||
id='admin.license.renewalCard.description.contact_sales'
|
||||
defaultMessage='Renew your {licenseSku} license by contacting sales to avoid any disruption.'
|
||||
values={{
|
||||
licenseSku: getSkuDisplayName(license.SkuShortName, license.IsGovSku === 'true'),
|
||||
}}
|
||||
@@ -113,12 +93,7 @@ const RenewLicenseCard: React.FC<RenewLicenseCardProps> = ({license, totalUsers,
|
||||
/>
|
||||
</div>
|
||||
<div className='RenewLicenseCard__buttons'>
|
||||
<RenewalLink
|
||||
isDisabled={isDisabled}
|
||||
telemetryInfo={renewLinkTelemetry}
|
||||
customBtnText={customBtnText}
|
||||
/>
|
||||
{showContactSalesBtn && contactSalesBtn}
|
||||
{contactSalesBtn}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
}
|
||||
|
||||
.RenewLicenseCard__buttons {
|
||||
button {
|
||||
padding: 6px 12px !important;
|
||||
.contact_us_primary_cta {
|
||||
padding: 6px 12px;
|
||||
margin-left: 0px;
|
||||
background-color: var(--sys-button-bg);
|
||||
color: var(--sys-center-channel-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +31,5 @@
|
||||
|
||||
button.contact-us {
|
||||
padding: 11px 19px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {ClientLicense} from '@mattermost/types/config';
|
||||
|
||||
import AlertBanner from 'components/alert_banner';
|
||||
import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us';
|
||||
import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_link';
|
||||
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
|
||||
|
||||
import {daysToLicenseExpire} from 'utils/license_utils';
|
||||
@@ -57,16 +56,8 @@ const TrialLicenseCard: React.FC<Props> = ({license}: Props) => {
|
||||
{messageBody()}
|
||||
</div>
|
||||
<div className='RenewLicenseCard__buttons'>
|
||||
<PurchaseLink
|
||||
buttonTextElement={
|
||||
<FormattedMessage
|
||||
id='admin.license.trialCard.purchase_license'
|
||||
defaultMessage='Purchase a license'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ContactUsButton
|
||||
customClass='light-blue-btn'
|
||||
customClass='contact_us_primary_cta'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {ClientLicense} from '@mattermost/types/config';
|
||||
import * as AdminActions from 'actions/admin_actions.jsx';
|
||||
|
||||
import ActivatedUserCard from 'components/analytics/activated_users_card';
|
||||
import TrueUpReview from 'components/analytics/true_up_review';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import AdminHeader from 'components/widgets/admin_console/admin_header';
|
||||
|
||||
@@ -444,7 +443,6 @@ export default class SystemAnalytics extends React.PureComponent<Props, State> {
|
||||
<div className='admin-console__wrapper'>
|
||||
<div className='admin-console__content'>
|
||||
{banner}
|
||||
<TrueUpReview/>
|
||||
<div className='grid-statistics'>
|
||||
{systemCards}
|
||||
{dailyActiveUsers}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {messages as activatedUsersCardsMessages} from 'components/analytics/acti
|
||||
import LineChart from 'components/analytics/line_chart';
|
||||
import StatisticCount from 'components/analytics/statistic_count';
|
||||
import TableChart from 'components/analytics/table_chart';
|
||||
import TrueUpReview from 'components/analytics/true_up_review';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import LoadingScreen from 'components/loading_screen';
|
||||
import AdminHeader from 'components/widgets/admin_console/admin_header';
|
||||
@@ -316,7 +315,6 @@ export default class TeamAnalytics extends React.PureComponent<Props, State> {
|
||||
|
||||
<div className='admin-console__wrapper'>
|
||||
<div className='admin-console__content'>
|
||||
<TrueUpReview/>
|
||||
{banner}
|
||||
<div className='grid-statistics'>
|
||||
<ActivatedUserCard
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
.TrueUpReview {
|
||||
&__card {
|
||||
width: 100%;
|
||||
height: '463px';
|
||||
border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
|
||||
border-radius: 4px;
|
||||
background-color: var(--sys-center-channel-bg);
|
||||
box-shadow: var(--elevation-1);
|
||||
color: var(--sys-center-channel-color);
|
||||
}
|
||||
|
||||
&__cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28px 32px 24px 32px;
|
||||
border-bottom: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
|
||||
}
|
||||
|
||||
&__cardHeaderText-top {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
&__cardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
&__cardBody > * {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__cardBody > svg {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
&__dueDate {
|
||||
:first-child {
|
||||
color: rgba(var(--sys-center-channel-color-rgb), 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
&__warning {
|
||||
color: var(--warning-text);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&__submit {
|
||||
font-weight: 600;
|
||||
|
||||
&--error {
|
||||
background: rgba(var(--button-bg-rgb), 0.16) !important;
|
||||
color: var(--button-bg) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import * as useCWSAvailabilityCheckAll from 'components/common/hooks/useCWSAvailabilityCheck';
|
||||
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
import {LicenseSkus} from 'utils/constants';
|
||||
import {TestHelper as TH} from 'utils/test_helper';
|
||||
|
||||
import TrueUpReview from './true_up_review';
|
||||
|
||||
describe('TrueUpReview', () => {
|
||||
const showsTrueUpReviewState: DeepPartial<GlobalState> = {
|
||||
entities: {
|
||||
general: {
|
||||
license: TH.getLicenseMock({
|
||||
IsGovSku: 'false',
|
||||
Cloud: 'false',
|
||||
SkuShortName: LicenseSkus.Enterprise,
|
||||
IsLicensed: 'true',
|
||||
}),
|
||||
config: {
|
||||
EnableDiagnostics: 'true',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'userId',
|
||||
profiles: {
|
||||
userId: TH.getUserMock({
|
||||
id: 'userId',
|
||||
roles: 'system_admin',
|
||||
}),
|
||||
},
|
||||
},
|
||||
hostedCustomer: {
|
||||
trueUpReviewStatus: {
|
||||
|
||||
// one day in future so we're sure it will display,
|
||||
// regardless of future changes to "do we show it if it already passed"
|
||||
due_date: Date.now() + (1000 * 60 * 60 * 24),
|
||||
complete: false,
|
||||
getRequestState: 'IDLE',
|
||||
},
|
||||
trueUpReviewProfile: {
|
||||
getRequestState: 'IDLE',
|
||||
content: '',
|
||||
},
|
||||
errors: {},
|
||||
},
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
it('regular self hosted license (NOT air-gapped) in the true up window sees content', () => {
|
||||
jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Available);
|
||||
|
||||
renderWithContext(<TrueUpReview/>, showsTrueUpReviewState);
|
||||
screen.getByText('Share to Mattermost');
|
||||
});
|
||||
|
||||
it('regular self hosted license thats air gapped sees download button only', () => {
|
||||
jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Unavailable);
|
||||
|
||||
renderWithContext(<TrueUpReview/>, showsTrueUpReviewState);
|
||||
screen.getByText('Download Data');
|
||||
expect(screen.queryByText('Share to Mattermost')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the panel regardless of the config value for EnableDiagnostic', () => {
|
||||
const store = JSON.parse(JSON.stringify(showsTrueUpReviewState));
|
||||
store.entities.general.config.EnableDiagnostics = 'false';
|
||||
jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Available);
|
||||
|
||||
renderWithContext(<TrueUpReview/>, store);
|
||||
screen.getByText('Share to Mattermost');
|
||||
});
|
||||
|
||||
it('gov sku self-hosted license does not see true up content', () => {
|
||||
const store = JSON.parse(JSON.stringify(showsTrueUpReviewState));
|
||||
store.entities.general.license.IsGovSku = 'true';
|
||||
jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Available);
|
||||
|
||||
renderWithContext(<TrueUpReview/>, store);
|
||||
expect(screen.queryByText('Share to Mattermost')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, {useEffect} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {
|
||||
getSelfHostedErrors,
|
||||
getTrueUpReviewProfile as trueUpReviewProfileSelector,
|
||||
getTrueUpReviewStatus as trueUpReviewStatusSelector,
|
||||
} from 'mattermost-redux/selectors/entities/hosted_customer';
|
||||
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {submitTrueUpReview, getTrueUpReviewStatus} from 'actions/hosted_customer';
|
||||
import {pageVisited} from 'actions/telemetry_actions';
|
||||
|
||||
import useCWSAvailabilityCheck, {CSWAvailabilityCheckTypes} from 'components/common/hooks/useCWSAvailabilityCheck';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import CheckMarkSvg from 'components/widgets/icons/check_mark_icon';
|
||||
import WarningIcon from 'components/widgets/icons/fa_warning_icon';
|
||||
|
||||
import {DocLinks, TELEMETRY_CATEGORIES} from 'utils/constants';
|
||||
import {getIsStarterLicense, getIsGovSku} from 'utils/license_utils';
|
||||
|
||||
import './true_up_review.scss';
|
||||
|
||||
const TrueUpReview: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const isCloud = useSelector(isCurrentLicenseCloud);
|
||||
const cwsAvailability = useCWSAvailabilityCheck();
|
||||
const isAirGapped = cwsAvailability !== CSWAvailabilityCheckTypes.Available;
|
||||
const reviewProfile = useSelector(trueUpReviewProfileSelector);
|
||||
const reviewStatus = useSelector(trueUpReviewStatusSelector);
|
||||
const isSystemAdmin = useSelector(isCurrentUserSystemAdmin);
|
||||
const license = useSelector(getLicense);
|
||||
const isLicensed = license.IsLicensed === 'true';
|
||||
const isStarter = getIsStarterLicense(license);
|
||||
const isGovSku = getIsGovSku(license);
|
||||
|
||||
// A license is eligible for true up if:
|
||||
// * a license exists for the customer
|
||||
// * are self-hosted (not cloud)
|
||||
// * are not on starter/free
|
||||
// * are not a government sku
|
||||
const licenseIsTrueUpEligible = isLicensed && !isCloud && !isStarter && !isGovSku;
|
||||
const trueUpReviewError = useSelector((state: GlobalState) => {
|
||||
const errors = getSelfHostedErrors(state);
|
||||
return Boolean(errors.trueUpReview);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (reviewStatus.getRequestState !== 'IDLE' || !licenseIsTrueUpEligible) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(getTrueUpReviewStatus());
|
||||
}, [dispatch, reviewStatus.getRequestState, licenseIsTrueUpEligible]);
|
||||
|
||||
// Download the review profile as a base64 encoded json file when the review request is submitted.
|
||||
useEffect(() => {
|
||||
if (reviewProfile.getRequestState === 'LOADING') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reviewProfile.getRequestState === 'OK' && !reviewStatus.complete && isAirGapped && !trueUpReviewError && reviewProfile.content.length > 0) {
|
||||
// Create the bundle as a blob containing base64 encoded json data and assign it to a link element.
|
||||
const blob = new Blob([reviewProfile.content], {type: 'application/text'});
|
||||
const href = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
const date = moment().format('MM-DD-YYYY');
|
||||
link.href = href;
|
||||
link.download = `True Up-${license.Id}-${date}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Remove link and revoke object url to avoid memory leaks.
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(href);
|
||||
dispatch(getTrueUpReviewStatus());
|
||||
}
|
||||
}, [isAirGapped, reviewProfile, reviewProfile.getRequestState, trueUpReviewError]);
|
||||
|
||||
const formattedDueDate = (): string => {
|
||||
if (!reviewStatus.due_date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Convert from milliseconds
|
||||
const date = new Date(reviewStatus.due_date);
|
||||
return moment(date).format('MMMM DD, YYYY');
|
||||
};
|
||||
|
||||
const handleSubmitReview = () => {
|
||||
dispatch(submitTrueUpReview());
|
||||
};
|
||||
|
||||
const dueDate = (
|
||||
<div className='TrueUpReview__dueDate'>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.due_date'
|
||||
defaultMessage='Due '
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
{formattedDueDate()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const submitButton = (
|
||||
<button
|
||||
className={classNames('btn btn-primary TrueUpReview__submit', {'TrueUpReview__submit--error': trueUpReviewError})}
|
||||
onClick={handleSubmitReview}
|
||||
>
|
||||
{isAirGapped ? (
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.button_download'
|
||||
defaultMessage='Download Data'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.button_share'
|
||||
defaultMessage='Share to Mattermost'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const errorStatus = (
|
||||
<>
|
||||
<WarningIcon additionalClassName={'TrueUpReview__warning'}/>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.submit_error'
|
||||
defaultMessage='There was an issue sending your True Up Review. Please try again.'
|
||||
/>
|
||||
{submitButton}
|
||||
</>
|
||||
);
|
||||
|
||||
const successStatus = (
|
||||
<>
|
||||
<CheckMarkSvg/>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.submit_success'
|
||||
defaultMessage='Success!'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.submit.thanks_for_sharing'
|
||||
defaultMessage='Thanks for sharing data needed for your true-up review.'
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const trueUpDocsLink = (
|
||||
<ExternalLink
|
||||
href={DocLinks.TRUE_UP_REVIEW}
|
||||
location='true_up_review'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.docsLinkCTA'
|
||||
defaultMessage='Learn more about true-up.'
|
||||
/>
|
||||
</ExternalLink>
|
||||
);
|
||||
|
||||
const reviewDetails = (
|
||||
<>
|
||||
{dueDate}
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.share_data_for_review'
|
||||
defaultMessage='Share your system statistics with Mattermost for your quarterly true-up Review. {link}'
|
||||
values={{
|
||||
link: trueUpDocsLink,
|
||||
}}
|
||||
/>
|
||||
{submitButton}
|
||||
</>
|
||||
);
|
||||
|
||||
const cardContent = () => {
|
||||
if (reviewProfile.getRequestState !== 'OK' && trueUpReviewError) {
|
||||
return errorStatus;
|
||||
}
|
||||
|
||||
// If we just submitted and the review status is set as complete, show the success
|
||||
// status details.
|
||||
if (reviewProfile.getRequestState === 'OK') {
|
||||
return successStatus;
|
||||
}
|
||||
|
||||
// If the due date is empty we still have the default state.
|
||||
if (!reviewStatus.due_date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return reviewDetails;
|
||||
};
|
||||
|
||||
// Only show the true up review section if the user is an admin and we're not using a cloud instance.
|
||||
if (!licenseIsTrueUpEligible || !isSystemAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only display the review details if we are within 2 weeks of the review due date.
|
||||
const visibilityStart = moment(reviewStatus.due_date).startOf('day').subtract(30, 'days');
|
||||
if (moment().isSameOrBefore(visibilityStart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the review has already been submitted, don't show anything.
|
||||
if (reviewStatus.complete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pageVisited(TELEMETRY_CATEGORIES.TRUE_UP_REVIEW, 'pageview_true_up_review');
|
||||
|
||||
return (
|
||||
<div className='TrueUpReview__card'>
|
||||
<div className='TrueUpReview__cardHeader'>
|
||||
<div className='TrueUpReview__cardHeaderText'>
|
||||
<div className='TrueUpReview__cardHeaderText-top'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.title'
|
||||
defaultMessage='True Up Review'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='TrueUpReview__cardBody'>
|
||||
{cardContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrueUpReview;
|
||||
|
||||
@@ -8,20 +8,17 @@ import {useDispatch, useSelector} from 'react-redux';
|
||||
import type {PreferenceType} from '@mattermost/types/preferences';
|
||||
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
|
||||
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
|
||||
import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
|
||||
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 {calculateOverageUserActivated} from 'utils/overage_team';
|
||||
import {getSiteURL} from 'utils/url';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
@@ -60,9 +57,6 @@ const OverageUsersBanner = () => {
|
||||
activeUsers,
|
||||
seatsPurchased,
|
||||
});
|
||||
const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
|
||||
const canSelfHostedExpand = useCanSelfHostedExpand() && isSelfHostedExpansionEnabled;
|
||||
const siteURL = getSiteURL();
|
||||
const prefixPreferences = isOver10PercerntPurchasedSeats ? 'error' : 'warn';
|
||||
const prefixLicenseId = (license.Id || '').substring(0, 8);
|
||||
const preferenceName = `${prefixPreferences}_overage_seats_${prefixLicenseId}`;
|
||||
@@ -73,16 +67,10 @@ const OverageUsersBanner = () => {
|
||||
const hasPermission = isAdmin && isOverageState && !isCloud;
|
||||
const {
|
||||
cta,
|
||||
expandableLink,
|
||||
trackEventFn,
|
||||
getRequestState,
|
||||
isExpandable,
|
||||
} = useExpandOverageUsersCheck({
|
||||
shouldRequest: hasPermission && !adminHasDismissed({isWarningBanner: isBetween5PercerntAnd10PercentPurchasedSeats, overagePreferences, preferenceName}),
|
||||
licenseId: license.Id,
|
||||
isWarningState: isBetween5PercerntAnd10PercentPurchasedSeats,
|
||||
banner: 'global banner',
|
||||
canSelfHostedExpand: canSelfHostedExpand || false,
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -94,31 +82,19 @@ const OverageUsersBanner = () => {
|
||||
}]));
|
||||
};
|
||||
|
||||
const handleUpdateSeatsSelfServeClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
trackEventFn('Self Serve');
|
||||
|
||||
if (canSelfHostedExpand) {
|
||||
window.open(`${siteURL}/${ConsolePages.LICENSE}?action=show_expansion_modal`);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(expandableLink(license.Id), '_blank');
|
||||
};
|
||||
|
||||
const handleContactSalesClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
trackEventFn('Contact Sales');
|
||||
openContactSales();
|
||||
};
|
||||
|
||||
const handleClick = isExpandable ? handleUpdateSeatsSelfServeClick : handleContactSalesClick;
|
||||
const handleClick = handleContactSalesClick;
|
||||
|
||||
if (!hasPermission || adminHasDismissed({isWarningBanner: isBetween5PercerntAnd10PercentPurchasedSeats, overagePreferences, preferenceName})) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let message = (
|
||||
const message = (
|
||||
<FormattedMessage
|
||||
id='licensingPage.overageUsersBanner.text'
|
||||
defaultMessage='(Only visible to admins) Your workspace user count has exceeded your paid license seat count by {seats, number} {seats, plural, one {seat} other {seats}}. Purchase additional seats to remain compliant.'
|
||||
@@ -127,17 +103,6 @@ const OverageUsersBanner = () => {
|
||||
}}
|
||||
/>);
|
||||
|
||||
if (canSelfHostedExpand) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='licensingPage.overageUsersBanner.textSelfHostedExpand'
|
||||
defaultMessage='(Only visible to admins) Your workspace user count has exceeded your paid license seat count. Update your seat count to stay compliant.'
|
||||
values={{
|
||||
seats: overageByUsers,
|
||||
}}
|
||||
/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnnouncementBar
|
||||
type={isBetween5PercerntAnd10PercentPurchasedSeats ? AnnouncementBarTypes.ADVISOR : AnnouncementBarTypes.CRITICAL}
|
||||
@@ -150,7 +115,6 @@ const OverageUsersBanner = () => {
|
||||
isTallBanner={true}
|
||||
icon={<i className='icon icon-alert-outline'/>}
|
||||
handleClose={handleClose}
|
||||
showCTA={getRequestState !== 'IDLE' && getRequestState !== 'LOADING'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import React from 'react';
|
||||
|
||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import {getLicenseSelfServeStatus} from 'mattermost-redux/actions/cloud';
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
|
||||
@@ -48,7 +47,6 @@ const text5PercentageState = `(Only visible to admins) Your workspace user count
|
||||
const text10PercentageState = `(Only visible to admins) Your workspace user count has exceeded your paid license seat count by ${seatsMinimumFor10PercentageState - seatsPurchased} seats. Purchase additional seats to remain compliant.`;
|
||||
|
||||
const contactSalesTextLink = 'Contact Sales';
|
||||
const expandSeatsTextLink = 'Purchase additional seats';
|
||||
|
||||
const licenseId = generateId();
|
||||
|
||||
@@ -98,10 +96,6 @@ describe('components/overage_users_banner', () => {
|
||||
myPreferences: {},
|
||||
},
|
||||
cloud: {
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'IDLE',
|
||||
},
|
||||
},
|
||||
hostedCustomer: {
|
||||
products: {
|
||||
@@ -134,7 +128,6 @@ describe('components/overage_users_banner', () => {
|
||||
renderWithContext(<OverageUsersBanner/>);
|
||||
|
||||
expect(screen.queryByText('(Only visible to admins) Your workspace user count has exceeded your paid license seat count by', {exact: false})).not.toBeInTheDocument();
|
||||
expect(getLicenseSelfServeStatus).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not render the banner because we are not admins', () => {
|
||||
@@ -154,7 +147,6 @@ describe('components/overage_users_banner', () => {
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
expect(screen.queryByText('Your workspace user count has exceeded your paid license seat count by', {exact: false})).not.toBeInTheDocument();
|
||||
expect(getLicenseSelfServeStatus).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not render the banner because it\'s cloud licenese', () => {
|
||||
@@ -168,7 +160,6 @@ describe('components/overage_users_banner', () => {
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
expect(screen.queryByText('Your workspace user count has exceeded your paid license seat count by', {exact: false})).not.toBeInTheDocument();
|
||||
expect(getLicenseSelfServeStatus).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not render the 5% banner because we have dissmised it', () => {
|
||||
@@ -194,7 +185,6 @@ describe('components/overage_users_banner', () => {
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
expect(screen.queryByText(text5PercentageState)).not.toBeInTheDocument();
|
||||
expect(getLicenseSelfServeStatus).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should render the banner because we are over 5% and we don\'t have any preferences', () => {
|
||||
@@ -202,10 +192,6 @@ describe('components/overage_users_banner', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
@@ -226,10 +212,6 @@ describe('components/overage_users_banner', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
@@ -259,10 +241,6 @@ describe('components/overage_users_banner', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.preferences.myPreferences = TestHelper.getPreferencesMock(
|
||||
@@ -316,10 +294,6 @@ describe('components/overage_users_banner', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
@@ -340,10 +314,6 @@ describe('components/overage_users_banner', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
@@ -367,114 +337,4 @@ describe('components/overage_users_banner', () => {
|
||||
banner: 'global banner',
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the warning banner with expansion seats CTA if the license is expandable', () => {
|
||||
const store = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
...store.entities.cloud.subscriptionStats,
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor5PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
expect(screen.getByText(expandSeatsTextLink)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should track if the admin click expansion seats CTA in a 5% overage state', () => {
|
||||
const store = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
...store.entities.cloud.subscriptionStats,
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor5PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
fireEvent.click(screen.getByText(expandSeatsTextLink));
|
||||
expect(windowSpy).toBeCalledTimes(1);
|
||||
expect(windowSpy).toBeCalledWith(`http://testing/subscribe/expand?licenseId=${licenseId}`, '_blank');
|
||||
expect(trackEvent).toBeCalledTimes(1);
|
||||
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_warning', {
|
||||
cta: 'Self Serve',
|
||||
banner: 'global banner',
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the error banner with expansion seats CTA if the license is be expandable', () => {
|
||||
const store = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
...store.entities.cloud.subscriptionStats,
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor10PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
expect(screen.getByText(expandSeatsTextLink)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should track if the admin click expansion seats CTA in a 10% overage state', () => {
|
||||
const store = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
...store.entities.cloud.subscriptionStats,
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor10PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
fireEvent.click(screen.getByText(expandSeatsTextLink));
|
||||
expect(windowSpy).toBeCalledTimes(1);
|
||||
expect(windowSpy).toBeCalledWith(`http://testing/subscribe/expand?licenseId=${licenseId}`, '_blank');
|
||||
expect(trackEvent).toBeCalledTimes(1);
|
||||
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_error', {
|
||||
cta: 'Self Serve',
|
||||
banner: 'global banner',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,6 @@ import React from 'react';
|
||||
import {act} from 'react-dom/test-utils';
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import mockStore from 'tests/test_store';
|
||||
|
||||
@@ -66,29 +64,7 @@ describe('components/RenewalLink', () => {
|
||||
},
|
||||
};
|
||||
|
||||
test('should show Renew now when a renewal link is successfully returned', async () => {
|
||||
const getRenewalLinkSpy = jest.spyOn(Client4, 'getRenewalLink');
|
||||
const promise = new Promise<{renewal_link: string}>((resolve) => {
|
||||
resolve({
|
||||
renewal_link: 'https://testrenewallink',
|
||||
});
|
||||
});
|
||||
getRenewalLinkSpy.mockImplementation(() => promise);
|
||||
const store = mockStore(initialState);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><RenewalLink {...props}/></Provider>);
|
||||
|
||||
// wait for the promise to resolve and component to update
|
||||
await actImmediate(wrapper);
|
||||
|
||||
expect(wrapper.find('.btn').text().includes('Renew license now')).toBe(true);
|
||||
});
|
||||
|
||||
test('should show Contact sales when a renewal link is not returned', async () => {
|
||||
const getRenewalLinkSpy = jest.spyOn(Client4, 'getRenewalLink');
|
||||
const promise = new Promise<{renewal_link: string}>((resolve, reject) => {
|
||||
reject(new Error('License cannot be renewed from portal'));
|
||||
});
|
||||
getRenewalLinkSpy.mockImplementation(() => promise);
|
||||
test('should show Contact sales button', async () => {
|
||||
const store = mockStore(initialState);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><RenewalLink {...props}/></Provider>);
|
||||
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
|
||||
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
|
||||
|
||||
import {
|
||||
ModalIdentifiers,
|
||||
} from 'utils/constants';
|
||||
|
||||
import type {ModalData} from 'types/actions';
|
||||
|
||||
import NoInternetConnection from '../no_internet_connection/no_internet_connection';
|
||||
|
||||
import './renew_link.scss';
|
||||
|
||||
export interface RenewalLinkProps {
|
||||
telemetryInfo?: {success: string; error: string};
|
||||
telemetryInfo?: { success: string; error: string };
|
||||
actions: {
|
||||
openModal: <P>(modalData: ModalData<P>) => void;
|
||||
};
|
||||
@@ -30,70 +20,20 @@ export interface RenewalLinkProps {
|
||||
}
|
||||
|
||||
const RenewalLink = (props: RenewalLinkProps) => {
|
||||
const [renewalLink, setRenewalLink] = useState('');
|
||||
const [manualInterventionRequired, setManualInterventionRequired] = useState(false);
|
||||
|
||||
const [openContactSales] = useOpenSalesLink();
|
||||
|
||||
useEffect(() => {
|
||||
Client4.getRenewalLink().then(({renewal_link: renewalLinkParam}) => {
|
||||
try {
|
||||
if (renewalLinkParam && (/^http[s]?:\/\//).test(renewalLinkParam)) {
|
||||
setRenewalLink(renewalLinkParam);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('No link returned', error); // eslint-disable-line no-console
|
||||
}
|
||||
}).catch(() => {
|
||||
setManualInterventionRequired(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLinkClick = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const {status} = await Client4.ping(false);
|
||||
if (status === 'OK' && renewalLink !== '') {
|
||||
if (props.telemetryInfo?.success) {
|
||||
trackEvent('renew_license', props.telemetryInfo.success);
|
||||
}
|
||||
window.open(renewalLink, '_blank');
|
||||
} else if (manualInterventionRequired) {
|
||||
openContactSales();
|
||||
} else {
|
||||
showConnectionErrorModal();
|
||||
}
|
||||
} catch (error) {
|
||||
showConnectionErrorModal();
|
||||
}
|
||||
openContactSales();
|
||||
};
|
||||
|
||||
const showConnectionErrorModal = () => {
|
||||
if (props.telemetryInfo?.error) {
|
||||
trackEvent('renew_license', props.telemetryInfo.error);
|
||||
}
|
||||
props.actions.openModal({
|
||||
modalId: ModalIdentifiers.NO_INTERNET_CONNECTION,
|
||||
dialogType: NoInternetConnection,
|
||||
});
|
||||
};
|
||||
|
||||
let btnText = props.customBtnText ? props.customBtnText : (
|
||||
const btnText = (
|
||||
<FormattedMessage
|
||||
id='announcement_bar.warn.renew_license_now'
|
||||
defaultMessage='Renew license now'
|
||||
id='announcement_bar.warn.renew_license_contact_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
);
|
||||
|
||||
if (manualInterventionRequired) {
|
||||
btnText = (
|
||||
<FormattedMessage
|
||||
id='announcement_bar.warn.renew_license_contact_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className='btn btn-primary annnouncementBar__renewLicense'
|
||||
|
||||
@@ -10,13 +10,16 @@ import {useDispatch, useSelector} from 'react-redux';
|
||||
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
|
||||
import {retryFailedCloudFetches} from 'actions/cloud';
|
||||
import {retryFailedHostedCustomerFetches} from 'actions/hosted_customer';
|
||||
|
||||
import './cloud_fetch_error.scss';
|
||||
|
||||
export default function CloudFetchError() {
|
||||
const dispatch = useDispatch();
|
||||
const isCloud = useSelector(isCurrentLicenseCloud);
|
||||
if (!isCloud) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (<div className='CloudFetchError '>
|
||||
<div className='CloudFetchError__header '>
|
||||
<FormattedMessage
|
||||
@@ -27,7 +30,7 @@ export default function CloudFetchError() {
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
onClick={() => {
|
||||
dispatch(isCloud ? retryFailedCloudFetches() : retryFailedHostedCustomerFetches());
|
||||
dispatch(retryFailedCloudFetches());
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/cloud_start_trial_btn/cloud_start_trial_btn should match snapshot 1`] = `
|
||||
<ContextProvider
|
||||
value={
|
||||
Object {
|
||||
"store": Object {
|
||||
"clearActions": [Function],
|
||||
"dispatch": [Function],
|
||||
"getActions": [Function],
|
||||
"getState": [Function],
|
||||
"replaceReducer": [Function],
|
||||
"subscribe": [Function],
|
||||
},
|
||||
"subscription": Subscription {
|
||||
"handleChangeWrapper": [Function],
|
||||
"listeners": Object {
|
||||
"notify": [Function],
|
||||
},
|
||||
"onStateChange": [Function],
|
||||
"parentSub": undefined,
|
||||
"store": Object {
|
||||
"clearActions": [Function],
|
||||
"dispatch": [Function],
|
||||
"getActions": [Function],
|
||||
"getState": [Function],
|
||||
"replaceReducer": [Function],
|
||||
"subscribe": [Function],
|
||||
},
|
||||
"unsubscribe": null,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<CloudStartTrialButton
|
||||
message="Cloud Start trial"
|
||||
onClick={[MockFunction]}
|
||||
telemetryId="test_telemetry_id"
|
||||
/>
|
||||
</ContextProvider>
|
||||
`;
|
||||
@@ -1,39 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/request_business_email_modal/request_business_email_modal should match snapshot 1`] = `
|
||||
<ContextProvider
|
||||
value={
|
||||
Object {
|
||||
"store": Object {
|
||||
"clearActions": [Function],
|
||||
"dispatch": [Function],
|
||||
"getActions": [Function],
|
||||
"getState": [Function],
|
||||
"replaceReducer": [Function],
|
||||
"subscribe": [Function],
|
||||
},
|
||||
"subscription": Subscription {
|
||||
"handleChangeWrapper": [Function],
|
||||
"listeners": Object {
|
||||
"notify": [Function],
|
||||
},
|
||||
"onStateChange": [Function],
|
||||
"parentSub": undefined,
|
||||
"store": Object {
|
||||
"clearActions": [Function],
|
||||
"dispatch": [Function],
|
||||
"getActions": [Function],
|
||||
"getState": [Function],
|
||||
"replaceReducer": [Function],
|
||||
"subscribe": [Function],
|
||||
},
|
||||
"unsubscribe": null,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<RequestBusinessEmailModal
|
||||
onExited={[MockFunction]}
|
||||
/>
|
||||
</ContextProvider>
|
||||
`;
|
||||
@@ -1,14 +0,0 @@
|
||||
.CloudStartTrialButton {
|
||||
&:not(.style-link) {
|
||||
width: fit-content;
|
||||
padding: 13px 20px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
&.style-link {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ReactWrapper} from 'enzyme';
|
||||
import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
import {act} from 'react-dom/test-utils';
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
import * as cloudActions from 'actions/cloud';
|
||||
import {trackEvent} from 'actions/telemetry_actions.jsx';
|
||||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import mockStore from 'tests/test_store';
|
||||
import {TELEMETRY_CATEGORIES} from 'utils/constants';
|
||||
|
||||
import CloudStartTrialButton from './cloud_start_trial_btn';
|
||||
|
||||
jest.mock('actions/telemetry_actions.jsx', () => {
|
||||
const original = jest.requireActual('actions/telemetry_actions.jsx');
|
||||
return {
|
||||
...original,
|
||||
trackEvent: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('mattermost-redux/actions/general', () => ({
|
||||
...jest.requireActual('mattermost-redux/actions/general'),
|
||||
getLicenseConfig: () => ({type: 'adsf'}),
|
||||
getClientConfig: () => ({type: 'adsf'}),
|
||||
}));
|
||||
|
||||
jest.mock('mattermost-redux/actions/cloud', () => ({
|
||||
...jest.requireActual('mattermost-redux/actions/cloud'),
|
||||
getCloudSubscription: () => ({type: 'adsf'}),
|
||||
getCloudProducts: () => ({type: 'adsf'}),
|
||||
getCloudLimits: () => ({}),
|
||||
}));
|
||||
|
||||
describe('components/cloud_start_trial_btn/cloud_start_trial_btn', () => {
|
||||
const state = {
|
||||
entities: {
|
||||
admin: {},
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
modals: {
|
||||
modalState: {
|
||||
learn_more_trial_modal: {
|
||||
open: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const store = mockStore(state);
|
||||
|
||||
const props = {
|
||||
onClick: jest.fn(),
|
||||
message: 'Cloud Start trial',
|
||||
telemetryId: 'test_telemetry_id',
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<Provider store={store}>
|
||||
<CloudStartTrialButton {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should handle on click and change button text on SUCCESSFUL trial request', async () => {
|
||||
const mockOnClick = jest.fn();
|
||||
const requestTrialFn: () => () => Promise<any> = () => () => Promise.resolve(true);
|
||||
jest.spyOn(cloudActions, 'requestCloudTrial').mockImplementation(requestTrialFn);
|
||||
|
||||
let wrapper: ReactWrapper<any>;
|
||||
|
||||
// Mount the component
|
||||
await act(async () => {
|
||||
wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<CloudStartTrialButton
|
||||
{...props}
|
||||
onClick={mockOnClick}
|
||||
email='fakeemail@topreventbusinessemailvalidation'
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
expect(wrapper.find('.CloudStartTrialButton').text().includes('Cloud Start trial')).toBe(true);
|
||||
wrapper.find('.CloudStartTrialButton').simulate('click');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
expect(wrapper.find('.CloudStartTrialButton').text().includes('Loaded!')).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalled();
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith(TELEMETRY_CATEGORIES.CLOUD_START_TRIAL_BUTTON, 'test_telemetry_id');
|
||||
});
|
||||
|
||||
test('should handle on click and change button text on FAILED trial request', async () => {
|
||||
const mockOnClick = jest.fn();
|
||||
const requestTrialFn: () => () => Promise<any> = () => () => Promise.resolve(true);
|
||||
jest.spyOn(cloudActions, 'requestCloudTrial').mockImplementation(requestTrialFn);
|
||||
|
||||
let wrapper: ReactWrapper<any>;
|
||||
|
||||
// Mount the component
|
||||
await act(async () => {
|
||||
wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<CloudStartTrialButton
|
||||
{...props}
|
||||
onClick={mockOnClick}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
expect(wrapper.find('.CloudStartTrialButton').text().includes('Cloud Start trial')).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('.CloudStartTrialButton').simulate('click');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
expect(wrapper.find('.CloudStartTrialButton').text().includes('Failed')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import type {ReactNode} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import {requestCloudTrial, validateWorkspaceBusinessEmail, getCloudLimits} from 'actions/cloud';
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {openModal, closeModal} from 'actions/views/modals';
|
||||
|
||||
import useGetSubscription from 'components/common/hooks/useGetSubscription';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import TrialBenefitsModal from 'components/trial_benefits_modal/trial_benefits_modal';
|
||||
|
||||
import {ModalIdentifiers, TELEMETRY_CATEGORIES, LicenseLinks} from 'utils/constants';
|
||||
|
||||
import RequestBusinessEmailModal from './request_business_email_modal';
|
||||
|
||||
import './cloud_start_trial_btn.scss';
|
||||
|
||||
export type CloudStartTrialBtnProps = {
|
||||
message: string;
|
||||
telemetryId: string;
|
||||
onClick?: () => void;
|
||||
extraClass?: string;
|
||||
afterTrialRequest?: () => void;
|
||||
email?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
enum TrialLoadStatus {
|
||||
NotStarted = 'NOT_STARTED',
|
||||
Started = 'STARTED',
|
||||
Success = 'SUCCESS',
|
||||
Failed = 'FAILED',
|
||||
Embargoed = 'EMBARGOED',
|
||||
}
|
||||
|
||||
const TIME_UNTIL_CACHE_PURGE_GUESS = 5000;
|
||||
|
||||
const CloudStartTrialButton = ({
|
||||
message,
|
||||
telemetryId,
|
||||
extraClass,
|
||||
onClick,
|
||||
afterTrialRequest,
|
||||
email,
|
||||
disabled = false,
|
||||
}: CloudStartTrialBtnProps) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const subscription = useGetSubscription();
|
||||
const [openBusinessEmailModal, setOpenBusinessEmailModal] = useState(false);
|
||||
const [status, setLoadStatus] = useState(TrialLoadStatus.NotStarted);
|
||||
|
||||
const validateBusinessEmailOnLoad = async () => {
|
||||
const isValidBusinessEmail = await validateWorkspaceBusinessEmail()();
|
||||
if (!isValidBusinessEmail) {
|
||||
setOpenBusinessEmailModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
validateBusinessEmailOnLoad();
|
||||
}, []);
|
||||
|
||||
const requestStartTrial = async (): Promise<TrialLoadStatus> => {
|
||||
setLoadStatus(TrialLoadStatus.Started);
|
||||
|
||||
// email is set ONLY from the instance of this component created in the requestBusinessEmail modal.
|
||||
// So the flow is the following: If the email of the admin and the
|
||||
// email of the CWS customer are not valid, the requestBusinessModal is shown and that component will
|
||||
// create this StartCloudTrialBtn passing the email as Truthy, so the requetTrial flow continues normally
|
||||
if (openBusinessEmailModal && !email) {
|
||||
trackEvent(
|
||||
TELEMETRY_CATEGORIES.CLOUD_START_TRIAL_BUTTON,
|
||||
'trial_request_attempt_with_no_valid_business_email',
|
||||
);
|
||||
await dispatch(closeModal(ModalIdentifiers.LEARN_MORE_TRIAL_MODAL));
|
||||
openRequestBusinessEmailModal();
|
||||
setLoadStatus(TrialLoadStatus.Failed);
|
||||
return TrialLoadStatus.Failed;
|
||||
}
|
||||
|
||||
const subscriptionUpdated = await dispatch(requestCloudTrial('start_cloud_trial_btn', subscription?.id as string, (email || '')));
|
||||
if (!subscriptionUpdated) {
|
||||
setLoadStatus(TrialLoadStatus.Failed);
|
||||
return TrialLoadStatus.Failed;
|
||||
}
|
||||
|
||||
function ensureUpdatedData() {
|
||||
// Depending on timing of pods rolling, the webhook may still not get sent.
|
||||
// Re-request limits as a just-in-case, but only well after any
|
||||
// pods still alive should have either purged cache,
|
||||
// updated limits, or be brand new pods that won't be holding onto stale limits
|
||||
// We don't need to re-request subscription: the updated value is sent in the
|
||||
// request cloud trial response.
|
||||
// We don't need to request license: its update process is independent
|
||||
// from subscription/limit changes and always happens after pods roll.
|
||||
dispatch(getCloudLimits());
|
||||
}
|
||||
|
||||
setTimeout(ensureUpdatedData, TIME_UNTIL_CACHE_PURGE_GUESS);
|
||||
if (afterTrialRequest) {
|
||||
afterTrialRequest();
|
||||
}
|
||||
setLoadStatus(TrialLoadStatus.Success);
|
||||
return TrialLoadStatus.Success;
|
||||
};
|
||||
|
||||
const openTrialBenefitsModal = async (status: TrialLoadStatus) => {
|
||||
// Only open the benefits modal if the trial request succeeded
|
||||
if (status !== TrialLoadStatus.Success) {
|
||||
return;
|
||||
}
|
||||
await dispatch(openModal({
|
||||
modalId: ModalIdentifiers.TRIAL_BENEFITS_MODAL,
|
||||
dialogType: TrialBenefitsModal,
|
||||
dialogProps: {trialJustStarted: true},
|
||||
}));
|
||||
};
|
||||
|
||||
const openRequestBusinessEmailModal = () => {
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.REQUEST_BUSINESS_EMAIL_MODAL,
|
||||
dialogType: RequestBusinessEmailModal,
|
||||
}));
|
||||
};
|
||||
|
||||
const btnText = (status: TrialLoadStatus) => {
|
||||
switch (status) {
|
||||
case TrialLoadStatus.Started:
|
||||
return formatMessage({id: 'start_cloud_trial.modal.gettingTrial', defaultMessage: 'Getting Trial...'});
|
||||
case TrialLoadStatus.Success:
|
||||
return formatMessage({id: 'start_cloud_trial.modal.loaded', defaultMessage: 'Loaded!'});
|
||||
case TrialLoadStatus.Failed:
|
||||
return formatMessage({id: 'start_cloud_trial.modal.failed', defaultMessage: 'Failed'});
|
||||
case TrialLoadStatus.Embargoed:
|
||||
return formatMessage<ReactNode>(
|
||||
{
|
||||
id: 'admin.license.trial-request.embargoed',
|
||||
defaultMessage: 'We were unable to process the request due to limitations for embargoed countries. <link>Learn more in our documentation</link>, or reach out to legal@mattermost.com for questions around export limitations.',
|
||||
},
|
||||
{
|
||||
link: (text: string) => (
|
||||
<ExternalLink
|
||||
location='trial_banner'
|
||||
href={LicenseLinks.EMBARGOED_COUNTRIES}
|
||||
>
|
||||
{text}
|
||||
</ExternalLink>
|
||||
),
|
||||
},
|
||||
);
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
};
|
||||
const startCloudTrial = async () => {
|
||||
if (status !== TrialLoadStatus.NotStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedStatus = await requestStartTrial();
|
||||
|
||||
if (updatedStatus !== TrialLoadStatus.Success) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent(
|
||||
TELEMETRY_CATEGORIES.CLOUD_START_TRIAL_BUTTON,
|
||||
telemetryId,
|
||||
);
|
||||
|
||||
// on click will execute whatever action is sent from the invoking place, if nothing is sent, open the trial benefits modal
|
||||
if (onClick) {
|
||||
onClick();
|
||||
return;
|
||||
}
|
||||
|
||||
await openTrialBenefitsModal(updatedStatus);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
id='start_cloud_trial_btn'
|
||||
className={`CloudStartTrialButton ${extraClass}`}
|
||||
onClick={startCloudTrial}
|
||||
disabled={disabled || status === TrialLoadStatus.Failed}
|
||||
>
|
||||
{btnText(status)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloudStartTrialButton;
|
||||
@@ -1,115 +0,0 @@
|
||||
@import 'utils/variables';
|
||||
@import 'utils/mixins';
|
||||
|
||||
.RequestBusinessEmailModal {
|
||||
height: 320px;
|
||||
|
||||
&.modal-dialog {
|
||||
margin-top: calc(50vh - 350px) !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 0 !important;
|
||||
border-color: rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 8px;
|
||||
background: var(--center-channel-bg);
|
||||
color: var(--center-channel-color);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
.close {
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus,
|
||||
&:active:focus {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
top: 6px;
|
||||
right: 4px;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 4px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75) !important;
|
||||
font-family:
|
||||
'Open Sans',
|
||||
sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--center-channel-bg) !important;
|
||||
color: var(--center-channel-color);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: calc(100% - 38px);
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
|
||||
.GenericModal__body {
|
||||
height: 100%;
|
||||
padding: 0 24px 24px 24px;
|
||||
|
||||
.container-footer {
|
||||
bottom: 0;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.request-business-email-input {
|
||||
height: 34px !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.start-trial-email-title {
|
||||
margin-bottom: 22px;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.start-trial-email-description {
|
||||
margin-bottom: 16px;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.start-trial-email-disclaimer {
|
||||
margin-top: 56px;
|
||||
}
|
||||
|
||||
.start-trial-button {
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
@include primary-button;
|
||||
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-centered {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 24px 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
import {act} from 'react-dom/test-utils';
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
|
||||
import * as cloudActions from 'actions/cloud';
|
||||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import mockStore from 'tests/test_store';
|
||||
|
||||
import RequestBusinessEmailModal from './request_business_email_modal';
|
||||
|
||||
jest.useFakeTimers();
|
||||
jest.mock('lodash/debounce', () => jest.fn((fn) => fn));
|
||||
|
||||
describe('components/request_business_email_modal/request_business_email_modal', () => {
|
||||
const state = {
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'current_user_id',
|
||||
},
|
||||
admin: {},
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
},
|
||||
config: {},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {id: 'subscriptionID'},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
modals: {
|
||||
modalState: {
|
||||
request_business_email_modal: {
|
||||
open: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const props = {
|
||||
onExited: jest.fn(),
|
||||
};
|
||||
|
||||
const store = mockStore(state);
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should show the Start Cloud Trial Button', async () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const startTrialBtn = wrapper.find('CloudStartTrialButton');
|
||||
expect(startTrialBtn).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('should call on close', async () => {
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal
|
||||
{...props}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find(GenericModal).props().onExited();
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should call on exited', async () => {
|
||||
const mockOnExited = jest.fn();
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal
|
||||
{...props}
|
||||
onExited={mockOnExited}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find(GenericModal).props().onExited();
|
||||
expect(mockOnExited).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should show the Input to enter the valid Business Email', async () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(wrapper.find('InputBusinessEmail')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('should start with Start Cloud Trial Button disabled', async () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const startTrialBtn = wrapper.find('CloudStartTrialButton');
|
||||
expect(startTrialBtn.props().disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should ENABLE the trial button if email is VALID', async () => {
|
||||
// mock validation response to TRUE meaning the email is a valid email
|
||||
const validateBusinessEmail = () => () => Promise.resolve(true);
|
||||
jest.spyOn(cloudActions, 'validateBusinessEmail').mockImplementation(validateBusinessEmail);
|
||||
|
||||
const event = {
|
||||
target: {value: 'valid-email@domain.com'},
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const inputBusinessEmail = wrapper.find('InputBusinessEmail');
|
||||
const input = inputBusinessEmail.find('input');
|
||||
input.find('input').at(0).simulate('change', event);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.update();
|
||||
const startTrialBtn = wrapper.find('CloudStartTrialButton');
|
||||
expect(startTrialBtn.props().disabled).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show the success custom message if the email is valid', async () => {
|
||||
// mock validation response to TRUE meaning the email is a valid email
|
||||
const validateBusinessEmail = () => () => Promise.resolve(true);
|
||||
|
||||
jest.spyOn(cloudActions, 'validateBusinessEmail').mockImplementation(validateBusinessEmail);
|
||||
|
||||
const event = {
|
||||
target: {value: 'valid-email@domain.com'},
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const inputBusinessEmail = wrapper.find('InputBusinessEmail');
|
||||
const input = inputBusinessEmail.find('input');
|
||||
input.find('input').at(0).simulate('change', event);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.update();
|
||||
const customMessageElement = wrapper.find('.Input___customMessage.Input___success');
|
||||
expect(customMessageElement.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('should DISABLE the trial button if email is INVALID', async () => {
|
||||
// mock validation response to FALSE meaning the email is an invalid email
|
||||
const validateBusinessEmail = () => () => Promise.resolve(false);
|
||||
jest.spyOn(cloudActions, 'validateBusinessEmail').mockImplementation(validateBusinessEmail);
|
||||
|
||||
const event = {
|
||||
target: {value: 'INvalid-email@domain.com'},
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const inputBusinessEmail = wrapper.find('InputBusinessEmail');
|
||||
const input = inputBusinessEmail.find('input');
|
||||
input.find('input').at(0).simulate('change', event);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.update();
|
||||
const startTrialBtn = wrapper.find('CloudStartTrialButton');
|
||||
expect(startTrialBtn.props().disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show the error custom message if the email is invalid', async () => {
|
||||
// mock validation response to FALSE meaning the email is an invalid email
|
||||
const validateBusinessEmail = () => () => Promise.resolve(false);
|
||||
jest.spyOn(cloudActions, 'validateBusinessEmail').mockImplementation(validateBusinessEmail);
|
||||
|
||||
const event = {
|
||||
target: {value: 'INvalid-email@domain.com'},
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const inputBusinessEmail = wrapper.find('InputBusinessEmail');
|
||||
const input = inputBusinessEmail.find('input');
|
||||
input.find('input').at(0).simulate('change', event);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.update();
|
||||
const customMessageElement = wrapper.find('.Input___customMessage.Input___error');
|
||||
expect(customMessageElement.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
|
||||
import {isEmail} from 'mattermost-redux/utils/helpers';
|
||||
|
||||
import {validateBusinessEmail} from 'actions/cloud';
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {closeModal} from 'actions/views/modals';
|
||||
|
||||
import ExternalLink from 'components/external_link';
|
||||
import type {CustomMessageInputType} from 'components/widgets/inputs/input/input';
|
||||
|
||||
import {ItemStatus, TELEMETRY_CATEGORIES, ModalIdentifiers, LicenseLinks, AboutLinks} from 'utils/constants';
|
||||
|
||||
import StartCloudTrialBtn from './cloud_start_trial_btn';
|
||||
import InputBusinessEmail from './input_business_email';
|
||||
|
||||
import './request_business_email_modal.scss';
|
||||
|
||||
type Props = {
|
||||
onClose?: () => void;
|
||||
onExited: () => void;
|
||||
}
|
||||
|
||||
const RequestBusinessEmailModal = (
|
||||
{
|
||||
onClose,
|
||||
onExited,
|
||||
}: Props): JSX.Element | null => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [customInputLabel, setCustomInputLabel] = useState<CustomMessageInputType>(null);
|
||||
const [trialBtnDisabled, setTrialBtnDisabled] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent(
|
||||
TELEMETRY_CATEGORIES.REQUEST_BUSINESS_EMAIL,
|
||||
'request_business_email',
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleOnClose = useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
onExited();
|
||||
}, [onClose, onExited]);
|
||||
|
||||
const handleEmailValues = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const email = e.target.value;
|
||||
setEmail(email.trim().toLowerCase());
|
||||
|
||||
validateEmail(email);
|
||||
}, []);
|
||||
|
||||
const validateEmail = useCallback(debounce(async (email: string) => {
|
||||
// no value set, no validation and clean the custom input label
|
||||
if (!email) {
|
||||
setTrialBtnDisabled(true);
|
||||
setCustomInputLabel(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// function isEmail aready handle empty / null value
|
||||
if (!isEmail(email)) {
|
||||
const errMsg = formatMessage({id: 'request_business_email_modal.invalidEmail', defaultMessage: 'This doesn\'t look like a valid email'});
|
||||
setCustomInputLabel({type: ItemStatus.WARNING, value: errMsg});
|
||||
setTrialBtnDisabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// go and validate the email against the validateBusinessEmail endpoint
|
||||
const isValidBusinessEmail = await validateBusinessEmail(email)();
|
||||
if (!isValidBusinessEmail) {
|
||||
const errMsg = formatMessage({id: 'request_business_email_modal.not_business_email', defaultMessage: 'This doesn\'t look like a business email'});
|
||||
setCustomInputLabel({type: ItemStatus.ERROR, value: errMsg});
|
||||
setTrialBtnDisabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// if it is a valid business email, proceed, enable the start trial button and notify the user about the email is valid
|
||||
const okMsg = formatMessage({id: 'request_business_email_modal.valid_business_email', defaultMessage: 'This is a valid email'});
|
||||
setCustomInputLabel({type: ItemStatus.SUCCESS, value: okMsg});
|
||||
setTrialBtnDisabled(false);
|
||||
}, 250), []);
|
||||
|
||||
// this function will be executed after successfull trial request, closing this request business email modal
|
||||
const closeMeAfterSuccessTrialReq = async () => {
|
||||
await dispatch(closeModal(ModalIdentifiers.REQUEST_BUSINESS_EMAIL_MODAL));
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
className='RequestBusinessEmailModal'
|
||||
compassDesign={true}
|
||||
id='RequestBusinessEmailModal'
|
||||
onExited={handleOnClose}
|
||||
>
|
||||
<div className='start-trial-email-title'>
|
||||
<FormattedMessage
|
||||
id='start_cloud_trial.modal.enter_trial_email.title'
|
||||
defaultMessage='Enter an email to start your trial'
|
||||
/>
|
||||
</div>
|
||||
<div className='start-trial-email-description'>
|
||||
<FormattedMessage
|
||||
id='start_cloud_trial.modal.enter_trial_email.description'
|
||||
defaultMessage='Start a trial and enter a business email to get started. '
|
||||
/>
|
||||
</div>
|
||||
<div className='start-trial-email-input'>
|
||||
<InputBusinessEmail
|
||||
email={email}
|
||||
handleEmailValues={handleEmailValues}
|
||||
customInputLabel={customInputLabel}
|
||||
/>
|
||||
</div>
|
||||
<div className='start-trial-email-disclaimer'>
|
||||
<FormattedMessage
|
||||
id='request_business_email.start_trial.modal.disclaimer'
|
||||
defaultMessage='By selecting <highlight>“Start trial”</highlight>, I agree to the <linkEvaluation>Mattermost Software and Services License Agreement</linkEvaluation>, <linkPrivacy>privacy policy</linkPrivacy> and receiving product emails.'
|
||||
values={{
|
||||
highlight: (msg: React.ReactNode) => (
|
||||
<strong>
|
||||
{msg}
|
||||
</strong>
|
||||
),
|
||||
linkEvaluation: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
href={LicenseLinks.SOFTWARE_SERVICES_LICENSE_AGREEMENT}
|
||||
location='request_business_email_modal'
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
linkPrivacy: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
href={AboutLinks.PRIVACY_POLICY}
|
||||
location='request_business_email_modal'
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='start-trial-button'>
|
||||
<StartCloudTrialBtn
|
||||
message={formatMessage({id: 'cloud.startTrial.modal.btn', defaultMessage: 'Start trial'})}
|
||||
telemetryId='request_business_email_modal'
|
||||
disabled={trialBtnDisabled}
|
||||
email={email}
|
||||
afterTrialRequest={closeMeAfterSuccessTrialReq}
|
||||
/>
|
||||
</div>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestBusinessEmailModal;
|
||||
@@ -1,46 +0,0 @@
|
||||
// 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 {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {BillingSchemes, SelfHostedProducts} from 'utils/constants';
|
||||
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
|
||||
import {isCloudLicense} from 'utils/license_utils';
|
||||
|
||||
import useGetSelfHostedProducts from './useGetSelfHostedProducts';
|
||||
|
||||
export default function useCanSelfHostedExpand() {
|
||||
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);
|
||||
const isAdmin = useSelector(isCurrentUserSystemAdmin);
|
||||
|
||||
// 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 || !isAdmin) {
|
||||
return;
|
||||
}
|
||||
Client4.getLicenseSelfServeStatus().
|
||||
then((res) => {
|
||||
setExpansionAvailable(res.is_expandable ?? false);
|
||||
}).
|
||||
catch(() => {
|
||||
setExpansionAvailable(false);
|
||||
});
|
||||
}, [isEnterpriseReady, isAdmin]);
|
||||
|
||||
return !isCloud && !isSelfHostedStarter && !isSalesServeOnly && expansionAvailable;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import useLoadStripe from './useLoadStripe';
|
||||
|
||||
interface CWSSignupAvailability {
|
||||
cwsContacted: boolean;
|
||||
cwsServiceOn: boolean;
|
||||
screeningInProgress: boolean;
|
||||
}
|
||||
|
||||
const cwsAvailable: CWSSignupAvailability = {
|
||||
cwsContacted: true,
|
||||
cwsServiceOn: true,
|
||||
screeningInProgress: false,
|
||||
};
|
||||
const cwsAvailableEmptyState: CWSSignupAvailability = {
|
||||
cwsContacted: false,
|
||||
cwsServiceOn: false,
|
||||
screeningInProgress: false,
|
||||
};
|
||||
|
||||
type SignupAvailability = CWSSignupAvailability & {
|
||||
stripeAvailable: boolean;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
export default function useCanSelfHostedSignup(): SignupAvailability {
|
||||
const [cwsAvailability, setCwsAvailability] = useState(cwsAvailableEmptyState);
|
||||
const config = useSelector(getConfig);
|
||||
const isEnterpriseReady = config.BuildEnterpriseReady === 'true';
|
||||
const stripeAvailable = Boolean(useLoadStripe().current);
|
||||
useEffect(() => {
|
||||
if (!isEnterpriseReady) {
|
||||
return;
|
||||
}
|
||||
Client4.getAvailabilitySelfHostedSignup().
|
||||
then(() => {
|
||||
setCwsAvailability(cwsAvailable);
|
||||
}).
|
||||
catch((err) => {
|
||||
let errorValue = {...cwsAvailableEmptyState};
|
||||
switch (err.status_code) {
|
||||
case 503: {
|
||||
errorValue = {
|
||||
cwsServiceOn: false,
|
||||
cwsContacted: true,
|
||||
screeningInProgress: false,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 425: {
|
||||
errorValue = {
|
||||
cwsServiceOn: true,
|
||||
cwsContacted: true,
|
||||
screeningInProgress: true,
|
||||
};
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
errorValue = {...cwsAvailableEmptyState};
|
||||
break;
|
||||
}
|
||||
}
|
||||
setCwsAvailability(errorValue);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
...cwsAvailability,
|
||||
stripeAvailable,
|
||||
ok: stripeAvailable && cwsAvailability.cwsContacted && cwsAvailability.cwsServiceOn && !cwsAvailability.screeningInProgress,
|
||||
};
|
||||
}, [stripeAvailable, cwsAvailability]);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import useGetSubscription from './useGetSubscription';
|
||||
|
||||
export const useDelinquencySubscription = () => {
|
||||
const subscription = useGetSubscription();
|
||||
|
||||
const isDelinquencySubscription = (): boolean => {
|
||||
if (!subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!subscription.delinquent_since) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const isDelinquencySubscriptionHigherThan90Days = (): boolean => {
|
||||
if (!isDelinquencySubscription()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const delinquencyDate = new Date((subscription.delinquent_since || 0) * 1000);
|
||||
|
||||
const oneDay = 24 * 60 * 60 * 1000; // hours*minutes*seconds*milliseconds
|
||||
const today = new Date();
|
||||
const diffDays = Math.round(
|
||||
Math.abs((today.valueOf() - delinquencyDate.valueOf()) / oneDay),
|
||||
);
|
||||
|
||||
return diffDays > 90;
|
||||
};
|
||||
|
||||
return {isDelinquencySubscription, isDelinquencySubscriptionHigherThan90Days, subscription};
|
||||
};
|
||||
@@ -1,56 +1,28 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import type {LicenseSelfServeStatusReducer} from '@mattermost/types/cloud';
|
||||
|
||||
import {getLicenseSelfServeStatus} from 'mattermost-redux/actions/cloud';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions.jsx';
|
||||
import {getExpandSeatsLink} from 'selectors/cloud';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
type UseExpandOverageUsersCheckArgs = {
|
||||
isWarningState: boolean;
|
||||
shouldRequest: boolean;
|
||||
licenseId?: string;
|
||||
banner: 'global banner' | 'invite modal';
|
||||
canSelfHostedExpand: boolean;
|
||||
}
|
||||
|
||||
export const useExpandOverageUsersCheck = ({
|
||||
shouldRequest,
|
||||
isWarningState,
|
||||
licenseId,
|
||||
banner,
|
||||
canSelfHostedExpand,
|
||||
}: UseExpandOverageUsersCheckArgs) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const {getRequestState, is_expandable: isExpandable}: LicenseSelfServeStatusReducer = useSelector((state: GlobalState) => state.entities.cloud.subscriptionStats || {is_expandable: false, getRequestState: 'IDLE'});
|
||||
const expandableLink = useSelector(getExpandSeatsLink);
|
||||
|
||||
const cta = useMemo(() => {
|
||||
if (isExpandable && !canSelfHostedExpand) {
|
||||
return formatMessage({
|
||||
id: 'licensingPage.overageUsersBanner.ctaExpandSeats',
|
||||
defaultMessage: 'Purchase additional seats',
|
||||
});
|
||||
} else if (isExpandable && canSelfHostedExpand) {
|
||||
return formatMessage({
|
||||
id: 'licensingPage.overageUsersBanner.ctaUpdateSeats',
|
||||
defaultMessage: 'Update seat count',
|
||||
});
|
||||
}
|
||||
return formatMessage({
|
||||
id: 'licensingPage.overageUsersBanner.cta',
|
||||
defaultMessage: 'Contact Sales',
|
||||
});
|
||||
}, [isExpandable]);
|
||||
const cta = formatMessage({
|
||||
id: 'licensingPage.overageUsersBanner.cta',
|
||||
defaultMessage: 'Contact Sales',
|
||||
});
|
||||
|
||||
const trackEventFn = (cta: 'Contact Sales' | 'Self Serve') => {
|
||||
trackEvent('insights', isWarningState ? 'click_true_up_warning' : 'click_true_up_error', {
|
||||
@@ -59,17 +31,9 @@ export const useExpandOverageUsersCheck = ({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRequest && licenseId && getRequestState === 'IDLE') {
|
||||
dispatch(getLicenseSelfServeStatus());
|
||||
}
|
||||
}, [dispatch, getRequestState, licenseId, shouldRequest]);
|
||||
|
||||
return {
|
||||
cta,
|
||||
expandableLink,
|
||||
trackEventFn,
|
||||
getRequestState,
|
||||
isExpandable,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Stripe} from '@stripe/stripe-js';
|
||||
import {loadStripe} from '@stripe/stripe-js/pure'; // https://github.com/stripe/stripe-js#importing-loadstripe-without-side-effects
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {getStripePublicKey} from 'components/payment_form/stripe';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
// reloadHint
|
||||
export default function useLoadStripe(reloadHint?: number) {
|
||||
const stripeRef = useRef<Stripe | null>(null);
|
||||
const [, setDone] = useState(false);
|
||||
const stripePublicKey = useSelector((state: GlobalState) => getStripePublicKey(state));
|
||||
|
||||
useEffect(() => {
|
||||
if (stripeRef.current) {
|
||||
return;
|
||||
}
|
||||
loadStripe(stripePublicKey).then((stripe: Stripe | null) => {
|
||||
stripeRef.current = stripe;
|
||||
|
||||
// deliberately cause a rerender so that the input can render.
|
||||
// otherwise, the input does not show up.
|
||||
setDone(true);
|
||||
});
|
||||
}, [reloadHint]);
|
||||
return stripeRef;
|
||||
}
|
||||
|
||||
@@ -110,32 +110,4 @@ describe('components/global/product_switcher_menu', () => {
|
||||
expect(wrapper.find('.button-plans').length).toEqual(1);
|
||||
expect(wrapper.find('StartTrialBtn').length).toEqual(1);
|
||||
});
|
||||
|
||||
test('should show with system admin pre trial for cloud', () => {
|
||||
mockState.entities.users.profiles.user1.roles = 'system_admin';
|
||||
mockState.entities.general.license = {
|
||||
Cloud: 'true',
|
||||
};
|
||||
|
||||
const wrapper = shallow(<FeatureRestrictedModal {...defaultProps}/>);
|
||||
|
||||
expect(wrapper.find('.FeatureRestrictedModal__description').text()).toEqual(defaultProps.messageAdminPreTrial);
|
||||
expect(wrapper.find('.FeatureRestrictedModal__terms').length).toEqual(1);
|
||||
expect(wrapper.find('.FeatureRestrictedModal__buttons').hasClass('single')).toEqual(false);
|
||||
expect(wrapper.find('.button-plans').length).toEqual(1);
|
||||
expect(wrapper.find('CloudStartTrialButton').length).toEqual(1);
|
||||
});
|
||||
|
||||
test('should match snapshot with system admin post trial', () => {
|
||||
mockState.entities.users.profiles.user1.roles = 'system_admin';
|
||||
mockState.entities.cloud.subscription.is_free_trial = 'false';
|
||||
mockState.entities.cloud.subscription.trial_end_at = 1;
|
||||
|
||||
const wrapper = shallow(<FeatureRestrictedModal {...defaultProps}/>);
|
||||
|
||||
expect(wrapper.find('.FeatureRestrictedModal__description').text()).toEqual(defaultProps.messageAdminPostTrial);
|
||||
expect(wrapper.find('.FeatureRestrictedModal__terms').length).toEqual(0);
|
||||
expect(wrapper.find('.button-plans').length).toEqual(1);
|
||||
expect(wrapper.find('CloudStartTrialButton').length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,15 +9,12 @@ import {useSelector, useDispatch} from 'react-redux';
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
|
||||
import {getPrevTrialLicense} from 'mattermost-redux/actions/admin';
|
||||
import {checkHadPriorTrial} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {deprecateCloudFree} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {closeModal} from 'actions/views/modals';
|
||||
import {isModalOpen} from 'selectors/views/modals';
|
||||
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import {NotifyStatus} from 'components/common/hooks/useGetNotifyAdmin';
|
||||
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
|
||||
import ExternalLink from 'components/external_link';
|
||||
@@ -38,7 +35,7 @@ type FeatureRestrictedModalProps = {
|
||||
messageAdminPostTrial?: string;
|
||||
titleEndUser?: string;
|
||||
messageEndUser?: string;
|
||||
customSecondaryButton?: {msg: string; action: () => void};
|
||||
customSecondaryButton?: { msg: string; action: () => void };
|
||||
feature?: string;
|
||||
minimumPlanRequiredForFeature?: string;
|
||||
}
|
||||
@@ -61,12 +58,10 @@ const FeatureRestrictedModal = ({
|
||||
dispatch(getPrevTrialLicense());
|
||||
}, []);
|
||||
|
||||
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
|
||||
const hasCloudPriorTrial = useSelector(checkHadPriorTrial);
|
||||
const prevTrialLicense = useSelector((state: GlobalState) => state.entities.admin.prevTrialLicense);
|
||||
const hasSelfHostedPriorTrial = prevTrialLicense.IsLicensed === 'true';
|
||||
|
||||
const hasPriorTrial = hasCloudPriorTrial || hasSelfHostedPriorTrial;
|
||||
const hasPriorTrial = hasSelfHostedPriorTrial;
|
||||
const isSystemAdmin = useSelector(isCurrentUserSystemAdmin);
|
||||
const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.FEATURE_RESTRICTED_MODAL));
|
||||
const license = useSelector(getLicense);
|
||||
@@ -103,7 +98,7 @@ const FeatureRestrictedModal = ({
|
||||
|
||||
const getTitle = () => {
|
||||
if (isSystemAdmin) {
|
||||
return (hasPriorTrial || cloudFreeDeprecated) ? titleAdminPostTrial : titleAdminPreTrial;
|
||||
return (hasPriorTrial) ? titleAdminPostTrial : titleAdminPreTrial;
|
||||
}
|
||||
|
||||
return titleEndUser;
|
||||
@@ -111,13 +106,13 @@ const FeatureRestrictedModal = ({
|
||||
|
||||
const getMessage = () => {
|
||||
if (isSystemAdmin) {
|
||||
return (hasPriorTrial || cloudFreeDeprecated) ? messageAdminPostTrial : messageAdminPreTrial;
|
||||
return (hasPriorTrial) ? messageAdminPostTrial : messageAdminPreTrial;
|
||||
}
|
||||
|
||||
return messageEndUser;
|
||||
};
|
||||
|
||||
const showStartTrial = isSystemAdmin && !hasPriorTrial && !cloudFreeDeprecated;
|
||||
const showStartTrial = isSystemAdmin && !hasPriorTrial && !isCloud;
|
||||
|
||||
// define what is the secondary button text and action, by default will be the View Plan button
|
||||
let secondaryBtnMsg = formatMessage({id: 'feature_restricted_modal.button.plans', defaultMessage: 'View plans'});
|
||||
@@ -130,26 +125,15 @@ const FeatureRestrictedModal = ({
|
||||
secondaryBtnAction = customSecondaryButton.action;
|
||||
}
|
||||
|
||||
let trialBtn;
|
||||
if (isCloud) {
|
||||
trialBtn = (
|
||||
<CloudStartTrialButton
|
||||
extraClass='button-trial'
|
||||
message={formatMessage({id: 'trial_btn.free.tryFreeFor30Days', defaultMessage: 'Start trial'})}
|
||||
telemetryId={'start_cloud_trial_after_team_creation_restricted'}
|
||||
onClick={dismissAction}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
trialBtn = (
|
||||
<StartTrialBtn
|
||||
message={formatMessage({id: 'trial_btn.free.tryFreeFor30Days', defaultMessage: 'Start trial'})}
|
||||
onClick={dismissAction}
|
||||
telemetryId='start_self_hosted_trial_after_team_creation_restricted'
|
||||
btnClass='btn btn-primary'
|
||||
renderAsButton={true}
|
||||
/>);
|
||||
}
|
||||
const trialBtn = (
|
||||
<StartTrialBtn
|
||||
message={formatMessage({id: 'trial_btn.free.tryFreeFor30Days', defaultMessage: 'Start trial'})}
|
||||
onClick={dismissAction}
|
||||
telemetryId='start_self_hosted_trial_after_team_creation_restricted'
|
||||
btnClass='btn btn-primary'
|
||||
renderAsButton={true}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
|
||||
@@ -8,21 +8,18 @@ import {useDispatch, useSelector} from 'react-redux';
|
||||
import type {PreferenceType} from '@mattermost/types/preferences';
|
||||
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
|
||||
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import AlertBanner from 'components/alert_banner';
|
||||
import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
|
||||
import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck';
|
||||
import ExternalLink from 'components/external_link';
|
||||
|
||||
import {LicenseLinks, StatTypes, Preferences, ConsolePages} from 'utils/constants';
|
||||
import {LicenseLinks, StatTypes, Preferences} from 'utils/constants';
|
||||
import {getIsGovSku} from 'utils/license_utils';
|
||||
import {calculateOverageUserActivated} from 'utils/overage_team';
|
||||
import {getSiteURL} from 'utils/url';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
@@ -49,9 +46,6 @@ const OverageUsersBannerNotice = () => {
|
||||
const currentUser = useSelector((state: GlobalState) => getCurrentUser(state));
|
||||
const overagePreferences = useSelector((state: GlobalState) => getPreferencesCategory(state, Preferences.OVERAGE_USERS_BANNER));
|
||||
const activeUsers = ((stats || {})[StatTypes.TOTAL_USERS]) as number || 0;
|
||||
const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
|
||||
const canSelfHostedExpand = useCanSelfHostedExpand() && isSelfHostedPurchaseEnabled;
|
||||
const siteURL = getSiteURL();
|
||||
|
||||
const {
|
||||
isBetween5PercerntAnd10PercentPurchasedSeats,
|
||||
@@ -69,16 +63,10 @@ const OverageUsersBannerNotice = () => {
|
||||
const hasPermission = isAdmin && isOverageState && !isCloud;
|
||||
const {
|
||||
cta,
|
||||
expandableLink,
|
||||
trackEventFn,
|
||||
getRequestState,
|
||||
isExpandable,
|
||||
} = useExpandOverageUsersCheck({
|
||||
shouldRequest: hasPermission && !adminHasDismissed({overagePreferences, preferenceName}),
|
||||
licenseId: license.Id,
|
||||
isWarningState: isBetween5PercerntAnd10PercentPurchasedSeats,
|
||||
banner: 'invite modal',
|
||||
canSelfHostedExpand: canSelfHostedExpand || false,
|
||||
});
|
||||
|
||||
if (!hasPermission || adminHasDismissed({overagePreferences, preferenceName})) {
|
||||
@@ -96,44 +84,21 @@ const OverageUsersBannerNotice = () => {
|
||||
|
||||
let message;
|
||||
|
||||
if (canSelfHostedExpand) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='licensingPage.overageUsersBanner.selfHostedNoticeDescription'
|
||||
defaultMessage={'<a>Purchase additional seats</a> to remain compliant.'}
|
||||
values={{
|
||||
a: (chunks: React.ReactNode) => {
|
||||
return (
|
||||
<ExternalLink
|
||||
className='overage_users_banner__button'
|
||||
href={`${siteURL}/${ConsolePages.LICENSE}?action=show_expansion_modal`}
|
||||
>
|
||||
{chunks}
|
||||
</ExternalLink>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (!isGovSku) {
|
||||
if (!isGovSku) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='licensingPage.overageUsersBanner.noticeDescription'
|
||||
defaultMessage='Notify your Customer Success Manager on your next true-up check. <a></a>'
|
||||
values={{
|
||||
a: () => {
|
||||
if (getRequestState === 'IDLE' || getRequestState === 'LOADING') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
trackEventFn(isExpandable ? 'Self Serve' : 'Contact Sales');
|
||||
trackEventFn('Contact Sales');
|
||||
};
|
||||
|
||||
return (
|
||||
<ExternalLink
|
||||
className='overage_users_banner__button'
|
||||
href={isExpandable ? expandableLink(license.Id) : LicenseLinks.CONTACT_SALES}
|
||||
href={LicenseLinks.CONTACT_SALES}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{cta}
|
||||
|
||||
@@ -50,7 +50,6 @@ const text10PercentageState = `Your workspace user count has exceeded your paid
|
||||
const notifyText = 'Notify your Customer Success Manager on your next true-up check';
|
||||
|
||||
const contactSalesTextLink = 'Contact Sales';
|
||||
const expandSeatsTextLink = 'Purchase additional seats';
|
||||
|
||||
const licenseId = generateId();
|
||||
|
||||
@@ -90,12 +89,7 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
|
||||
preferences: {
|
||||
myPreferences: {},
|
||||
},
|
||||
cloud: {
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'IDLE',
|
||||
},
|
||||
},
|
||||
cloud: {},
|
||||
hostedCustomer: {
|
||||
products: {
|
||||
productsLoaded: true,
|
||||
@@ -226,10 +220,6 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
@@ -336,10 +326,6 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
@@ -444,70 +430,6 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
it('should track if the admin click expansion seats CTA in a 5% overage state', () => {
|
||||
const store: GlobalState = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor5PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<OverageUsersBannerNotice/>,
|
||||
store,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(expandSeatsTextLink));
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', `http://testing/subscribe/expand?licenseId=${licenseId}`);
|
||||
expect(trackEvent).toBeCalledTimes(2);
|
||||
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_warning', {
|
||||
cta: 'Self Serve',
|
||||
banner: 'invite modal',
|
||||
});
|
||||
});
|
||||
|
||||
it('should track if the admin click expansion seats CTA in a 10% overage state', () => {
|
||||
const store: GlobalState = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor10PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<OverageUsersBannerNotice/>,
|
||||
store,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(expandSeatsTextLink));
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', `http://testing/subscribe/expand?licenseId=${licenseId}`);
|
||||
expect(trackEvent).toBeCalledTimes(2);
|
||||
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_error', {
|
||||
cta: 'Self Serve',
|
||||
banner: 'invite modal',
|
||||
});
|
||||
});
|
||||
|
||||
it('gov sku sees overage notice but not a call to do true up', async () => {
|
||||
const store: GlobalState = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
@@ -520,10 +442,6 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
store.entities.general.license.IsGovSku = 'true';
|
||||
|
||||
|
||||
@@ -21,11 +21,6 @@ jest.mock('actions/telemetry_actions.jsx', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const CloudStartTrialButton = () => {
|
||||
return (<button>{'Start Cloud Trial'}</button>);
|
||||
};
|
||||
|
||||
jest.mock('components/cloud_start_trial/cloud_start_trial_btn', () => CloudStartTrialButton);
|
||||
describe('components/learn_more_trial_modal/learn_more_trial_modal', () => {
|
||||
// required state to mount using the provider
|
||||
const state = {
|
||||
@@ -50,15 +45,12 @@ describe('components/learn_more_trial_modal/learn_more_trial_modal', () => {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'false',
|
||||
Cloud: 'true',
|
||||
Cloud: 'false',
|
||||
},
|
||||
config: {
|
||||
DiagnosticsEnabled: 'false',
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {id: 'subscription'},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
modals: {
|
||||
@@ -172,20 +164,6 @@ describe('components/learn_more_trial_modal/learn_more_trial_modal', () => {
|
||||
expect(activeSlideId).toBe('ldap');
|
||||
});
|
||||
|
||||
test('should have the start cloud trial button when is cloud workspace and cloud free is enabled', () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<LearnMoreTrialModal
|
||||
{...props}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const trialButton = wrapper.find('CloudStartTrialButton');
|
||||
|
||||
expect(trialButton).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should have the self hosted request trial button cloud free is disabled', () => {
|
||||
const nonCloudState = {
|
||||
...state,
|
||||
@@ -210,10 +188,6 @@ describe('components/learn_more_trial_modal/learn_more_trial_modal', () => {
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// validate the cloud start trial button is not present
|
||||
const trialButton = wrapper.find('CloudStartTrialButton');
|
||||
expect(trialButton).toHaveLength(0);
|
||||
|
||||
// validate the cloud start trial button is not present
|
||||
const selfHostedRequestTrialButton = wrapper.find('StartTrialBtn');
|
||||
expect(selfHostedRequestTrialButton).toHaveLength(1);
|
||||
|
||||
@@ -2,25 +2,21 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {deprecateCloudFree} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {closeModal} from 'actions/views/modals';
|
||||
|
||||
import SystemRolesSVG from 'components/admin_console/feature_discovery/features/images/system_roles_svg';
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import Carousel from 'components/common/carousel/carousel';
|
||||
import {BtnStyle} from 'components/common/carousel/carousel_button';
|
||||
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
|
||||
import GuestAccessSvg from 'components/common/svg_images_components/guest_access_svg';
|
||||
import MonitorImacLikeSVG from 'components/common/svg_images_components/monitor_imaclike_svg';
|
||||
import ExternalLink from 'components/external_link';
|
||||
|
||||
import {ConsolePages, DocLinks, ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
|
||||
|
||||
@@ -44,25 +40,22 @@ const LearnMoreTrialModal = (
|
||||
const [embargoed, setEmbargoed] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [, salesLink] = useOpenSalesLink();
|
||||
|
||||
// Cloud conditions
|
||||
const license = useSelector(getLicense);
|
||||
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
|
||||
const isCloud = license?.Cloud === 'true';
|
||||
|
||||
const handleEmbargoError = useCallback(() => {
|
||||
setEmbargoed(true);
|
||||
}, []);
|
||||
|
||||
let startTrialBtnMsg = formatMessage({id: 'start_trial.modal_btn.start_free_trial', defaultMessage: 'Start free 30-day trial'});
|
||||
const startTrialBtnMsg = formatMessage({id: 'start_trial.modal_btn.start_free_trial', defaultMessage: 'Start free 30-day trial'});
|
||||
|
||||
// close this modal once start trial btn is clicked and trial has started successfully
|
||||
const dismissAction = useCallback(() => {
|
||||
dispatch(closeModal(ModalIdentifiers.LEARN_MORE_TRIAL_MODAL));
|
||||
}, []);
|
||||
|
||||
let startTrialBtn = (
|
||||
const startTrialBtn = (
|
||||
<StartTrialBtn
|
||||
message={startTrialBtnMsg}
|
||||
handleEmbargoError={handleEmbargoError}
|
||||
@@ -71,33 +64,6 @@ const LearnMoreTrialModal = (
|
||||
/>
|
||||
);
|
||||
|
||||
// no need to check if is cloud trial or if it have had prev cloud trial because the button that show this modal takes care of that
|
||||
if (isCloud) {
|
||||
startTrialBtnMsg = formatMessage({id: 'trial_btn.free.tryFreeFor30Days', defaultMessage: 'Start trial'});
|
||||
startTrialBtn = (
|
||||
<CloudStartTrialButton
|
||||
message={startTrialBtnMsg}
|
||||
telemetryId={`start_cloud_trial__learn_more_modal__${launchedBy}`}
|
||||
onClick={dismissAction}
|
||||
extraClass={'btn btn-primary start-cloud-trial-btn'}
|
||||
/>
|
||||
);
|
||||
if (cloudFreeDeprecated) {
|
||||
startTrialBtn = (
|
||||
<ExternalLink
|
||||
location='learn_more_trial_modal'
|
||||
href={salesLink}
|
||||
className='btn btn-primary start-cloud-trial-btn'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='learn_more_trial_modal.contact_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
</ExternalLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnClose = useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
@@ -185,6 +151,11 @@ const LearnMoreTrialModal = (
|
||||
|
||||
const headerText = formatMessage({id: 'learn_more_trial_modal.pretitle', defaultMessage: 'With Enterprise, you can...'});
|
||||
|
||||
if (isCloud) {
|
||||
// Cloud users shouldn't be able to reach this modal, but in case they do, return nothing.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
compassDesign={true}
|
||||
|
||||
@@ -12,12 +12,11 @@ import type {GlobalState} from '@mattermost/types/store';
|
||||
import {getPrevTrialLicense} from 'mattermost-redux/actions/admin';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn';
|
||||
|
||||
import completedImg from 'images/completed.svg';
|
||||
import {AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants';
|
||||
import {AboutLinks, LicenseLinks} from 'utils/constants';
|
||||
|
||||
const CompletedWrapper = styled.div`
|
||||
display: flex;
|
||||
@@ -141,21 +140,15 @@ const Completed = (props: Props): JSX.Element => {
|
||||
const isCurrentLicensed = license?.IsLicensed;
|
||||
|
||||
// Cloud conditions
|
||||
const subscription = useSelector((state: GlobalState) => state.entities.cloud.subscription);
|
||||
const isCloud = license?.Cloud === 'true';
|
||||
const isFreeTrial = subscription?.is_free_trial === 'true';
|
||||
const hadPrevCloudTrial = subscription?.is_free_trial === 'false' && subscription?.trial_end_at > 0;
|
||||
const isPaidSubscription = isCloud && license?.SkuShortName !== LicenseSkus.Starter && !isFreeTrial;
|
||||
|
||||
// Show this CTA if the instance is currently not licensed and has never had a trial license loaded before
|
||||
// also check that the user is a system admin (this after the onboarding task list is shown to all users)
|
||||
const selfHostedTrialCondition = (isCurrentLicensed === 'false' && isPrevLicensed === 'false') &&
|
||||
(props.isCurrentUserSystemAdmin || props.isFirstAdmin);
|
||||
(props.isCurrentUserSystemAdmin || props.isFirstAdmin);
|
||||
|
||||
// if Cloud, show if not in trial and had never been on trial
|
||||
const cloudTrialCondition = isCloud && !isFreeTrial && !hadPrevCloudTrial && !isPaidSubscription;
|
||||
|
||||
const showStartTrialBtn = selfHostedTrialCondition || cloudTrialCondition;
|
||||
// if Cloud, don't show
|
||||
const showStartTrialBtn = selfHostedTrialCondition && !isCloud;
|
||||
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
@@ -196,20 +189,11 @@ const Completed = (props: Props): JSX.Element => {
|
||||
defaultMessage='Start your free Enterprise trial now!'
|
||||
/>
|
||||
</span>
|
||||
{isCloud ? (
|
||||
<CloudStartTrialButton
|
||||
message={formatMessage({id: 'trial_btn.free.tryFreeFor30Days', defaultMessage: 'Start trial'})}
|
||||
telemetryId={'start_cloud_trial_after_completing_steps'}
|
||||
extraClass={'btn btn-primary'}
|
||||
afterTrialRequest={dismissAction}
|
||||
/>
|
||||
) : (
|
||||
<StartTrialBtn
|
||||
message={formatMessage({id: 'start_trial.modal_btn.start_free_trial', defaultMessage: 'Start free 30-day trial'})}
|
||||
telemetryId='start_trial_from_onboarding_completed_task'
|
||||
onClick={dismissAction}
|
||||
/>
|
||||
)}
|
||||
<StartTrialBtn
|
||||
message={formatMessage({id: 'start_trial.modal_btn.start_free_trial', defaultMessage: 'Start free 30-day trial'})}
|
||||
telemetryId='start_trial_from_onboarding_completed_task'
|
||||
onClick={dismissAction}
|
||||
/>
|
||||
<button
|
||||
onClick={dismissAction}
|
||||
className={'no-thanks-link style-link'}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.StripeElement {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 2px !important;
|
||||
padding-left: 12px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--center-channel-bg);
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
color: var(--center-channel-color);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 14px;
|
||||
line-height: 23px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.StripeElement--invalid {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.StripeElement::placeholder {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.StripeElement:focus::placeholder {
|
||||
color: 'transparent';
|
||||
}
|
||||
|
||||
.StripeElement:focus {
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 2px var(--button-bg);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ElementsConsumer, CardElement} from '@stripe/react-stripe-js';
|
||||
import type {StripeElements, StripeCardElement, StripeCardElementChangeEvent} from '@stripe/stripe-js';
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {toRgbValues} from 'utils/utils';
|
||||
|
||||
import 'components/widgets/inputs/input/input.scss';
|
||||
|
||||
import './card_input.css';
|
||||
|
||||
type OwnProps = {
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
forwardedRef?: any;
|
||||
theme: Theme;
|
||||
onBlur?: () => void;
|
||||
onFocus?: () => void;
|
||||
className?: string;
|
||||
|
||||
// Stripe doesn't give type exports
|
||||
[propName: string]: any;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
elements: StripeElements | null | undefined;
|
||||
onCardInputChange?: (event: StripeCardElementChangeEvent) => void;
|
||||
} & OwnProps;
|
||||
|
||||
type State = {
|
||||
focused: boolean;
|
||||
error: string;
|
||||
empty: boolean;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
const REQUIRED_FIELD_TEXT = 'This field is required';
|
||||
const VALID_CARD_TEXT = 'Please enter a valid credit card';
|
||||
|
||||
export interface CardInputType extends React.PureComponent {
|
||||
getCard(): StripeCardElement | undefined;
|
||||
}
|
||||
|
||||
class CardInput extends React.PureComponent<Props, State> {
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
focused: false,
|
||||
error: '',
|
||||
empty: true,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onFocus = () => {
|
||||
const {onFocus} = this.props;
|
||||
|
||||
this.setState({focused: true});
|
||||
|
||||
if (onFocus) {
|
||||
onFocus();
|
||||
}
|
||||
};
|
||||
|
||||
private onBlur = () => {
|
||||
const {onBlur} = this.props;
|
||||
|
||||
this.setState({focused: false});
|
||||
this.validateInput();
|
||||
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
};
|
||||
|
||||
private onChange = (event: StripeCardElementChangeEvent) => {
|
||||
this.setState({error: '', empty: event.empty, complete: event.complete});
|
||||
if (this.props.onCardInputChange) {
|
||||
this.props.onCardInputChange(event);
|
||||
}
|
||||
};
|
||||
|
||||
private validateInput = () => {
|
||||
const {required} = this.props;
|
||||
const {empty, complete} = this.state;
|
||||
let error = '';
|
||||
|
||||
this.setState({error: ''});
|
||||
if (required && empty) {
|
||||
error = REQUIRED_FIELD_TEXT;
|
||||
} else if (!complete) {
|
||||
error = VALID_CARD_TEXT;
|
||||
}
|
||||
|
||||
this.setState({error});
|
||||
};
|
||||
|
||||
private renderError(error: string) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let errorMessage;
|
||||
if (error === REQUIRED_FIELD_TEXT) {
|
||||
errorMessage = (
|
||||
<FormattedMessage
|
||||
id='payment.field_required'
|
||||
defaultMessage='This field is required'
|
||||
/>);
|
||||
} else if (error === VALID_CARD_TEXT) {
|
||||
errorMessage = (
|
||||
<FormattedMessage
|
||||
id='payment.invalid_card_number'
|
||||
defaultMessage='Please enter a valid credit card'
|
||||
/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='Input___error'>
|
||||
<i className='icon icon-alert-outline'/>
|
||||
{errorMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public getCard(): StripeCardElement | null | undefined {
|
||||
return this.props.elements?.getElement(CardElement);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {className, error: propError, theme, ...otherProps} = this.props;
|
||||
const CARD_ELEMENT_OPTIONS = {
|
||||
hidePostalCode: true,
|
||||
style: {
|
||||
base: {
|
||||
fontFamily: "'Open Sans', sans-serif",
|
||||
fontSize: '14px',
|
||||
fontSmoothing: 'antialiased',
|
||||
color: theme.centerChannelColor,
|
||||
'::placeholder': {
|
||||
color: `rgba(${toRgbValues(theme.centerChannelColor)}, 0.75)`,
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: theme.errorTextColor,
|
||||
iconColor: theme.errorTextColor,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const {empty, focused, error: stateError} = this.state;
|
||||
let fieldsetClass = className ? `Input_fieldset ${className}` : 'Input_fieldset';
|
||||
let fieldsetErrorClass = className ? `Input_fieldset Input_fieldset___error ${className}` : 'Input_fieldset Input_fieldset___error';
|
||||
const showLegend = Boolean(focused || !empty);
|
||||
|
||||
fieldsetClass = showLegend ? fieldsetClass + ' Input_fieldset___legend' : fieldsetClass;
|
||||
fieldsetErrorClass = showLegend ? fieldsetErrorClass + ' Input_fieldset___legend' : fieldsetErrorClass;
|
||||
|
||||
const error = propError || stateError;
|
||||
|
||||
return (
|
||||
<div className='Input_container'>
|
||||
<fieldset className={error ? fieldsetErrorClass : fieldsetClass}>
|
||||
<legend className={showLegend ? 'Input_legend Input_legend___focus' : 'Input_legend'}>
|
||||
<FormattedMessage
|
||||
id='payment.card_number'
|
||||
defaultMessage='Card Number'
|
||||
/>
|
||||
</legend>
|
||||
<CardElement
|
||||
{...otherProps}
|
||||
options={CARD_ELEMENT_OPTIONS}
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</fieldset>
|
||||
{this.renderError(error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const InjectedCardInput = (props: OwnProps) => {
|
||||
return (
|
||||
<ElementsConsumer>
|
||||
{({elements}) => (
|
||||
<CardInput
|
||||
ref={props.forwardedRef}
|
||||
elements={elements}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</ElementsConsumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default InjectedCardInput;
|
||||
@@ -1,186 +0,0 @@
|
||||
@import 'utils/mixins';
|
||||
|
||||
.gatherIntent {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&__title {
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 24px !important;
|
||||
}
|
||||
|
||||
&__button {
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--button-bg) !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 400 !important;
|
||||
line-height: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.savedFeedback__text {
|
||||
align-self: center;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.AltPaymentsModal {
|
||||
.modal-content {
|
||||
max-width: 512px;
|
||||
max-height: 465px;
|
||||
border-radius: 12px;
|
||||
background: var(--center-channel-bg);
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
min-width: 532px;
|
||||
margin: auto;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
padding-right: 32px;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.AltPaymentsModal__header {
|
||||
align-items: baseline;
|
||||
border: none;
|
||||
|
||||
&.modal-header {
|
||||
background: var(--center-channel-bg);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.icon-close {
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin-left: auto;
|
||||
background: var(--center-channel-bg);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 0 32px;
|
||||
margin-bottom: 24px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
}
|
||||
|
||||
&__submitted-icon-container {
|
||||
margin-bottom: 24px;
|
||||
|
||||
> svg {
|
||||
width: 51px;
|
||||
height: 51px;
|
||||
align-self: center;
|
||||
color: rgba(61, 184, 135, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
text-align: center;
|
||||
|
||||
&__question {
|
||||
margin-bottom: 10px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__option {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__label {
|
||||
padding-left: 12px;
|
||||
cursor: default;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
&__checkbox {
|
||||
height: 28px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
&__text {
|
||||
color: var(--error-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-grow: 0;
|
||||
align-items: center;
|
||||
filter: invert(54%) sepia(68%) saturate(314%) hue-rotate(309deg) brightness(83%) contrast(117%);
|
||||
}
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
width: 100%;
|
||||
border: solid 1px rgba(63, 67, 80, 0.16);
|
||||
border-radius: 4px;
|
||||
background: rgb(var(--center-channel-bg-rgb));
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
&--secondary {
|
||||
@include tertiary-button;
|
||||
@include button-medium;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
@include primary-button;
|
||||
@include button-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import * as reactRedux from 'react-redux';
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
renderWithContext,
|
||||
screen,
|
||||
} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import type {GatherIntentProps} from './gather_intent';
|
||||
import {GatherIntent} from './gather_intent';
|
||||
import type {GatherIntentModalProps} from './gather_intent_modal';
|
||||
|
||||
const DummyModal = ({onClose, onSave}: GatherIntentModalProps) => {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
id='closeIcon'
|
||||
className='icon icon-close'
|
||||
aria-label='Close'
|
||||
title='Close'
|
||||
onClick={onClose}
|
||||
/>
|
||||
<p>{'Body'}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSave({ach: true, other: false, wire: true});
|
||||
}}
|
||||
type='button'
|
||||
>
|
||||
{'Test'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
describe('components/gather_intent/gather_intent.tsx', () => {
|
||||
const gatherIntentText = 'gatherIntentText';
|
||||
const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch');
|
||||
|
||||
const initialState = {
|
||||
entities: {
|
||||
cloud: {
|
||||
customer: TestHelper.getCloudCustomerMock(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const baseProps: GatherIntentProps = {
|
||||
modalComponent: DummyModal as any,
|
||||
gatherIntentText,
|
||||
typeGatherIntent: 'monthlySubscription',
|
||||
};
|
||||
|
||||
it('should display modal if the user click on the modal opener', () => {
|
||||
renderWithContext(
|
||||
<GatherIntent {...baseProps}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
expect(screen.getByText('Body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the modal opener after close the modal', () => {
|
||||
renderWithContext(
|
||||
<GatherIntent {...baseProps}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
fireEvent.click(screen.getByLabelText('Close'));
|
||||
|
||||
expect(screen.queryByText('Body')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the submitted modal after save the configuration', async () => {
|
||||
useDispatchMock.mockReturnValue(jest.fn().mockImplementation(() => new Promise((resolve) => {
|
||||
resolve({});
|
||||
})));
|
||||
renderWithContext(
|
||||
<GatherIntent {...baseProps}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Test'));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Thanks for sharing feedback!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the submitted modal after save the configuration and reopening the modal', async () => {
|
||||
useDispatchMock.mockReturnValue(jest.fn().mockImplementation(() => new Promise((resolve) => {
|
||||
resolve({});
|
||||
})));
|
||||
renderWithContext(
|
||||
<GatherIntent {...baseProps}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Test'));
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Done'));
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
expect(screen.queryByText('Thanks for sharing feedback!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the submitted modal when the user has a feedback recorded', async () => {
|
||||
useDispatchMock.mockReturnValue(jest.fn().mockImplementation(() => new Promise((resolve) => {
|
||||
resolve({});
|
||||
})));
|
||||
const newState = JSON.parse(JSON.stringify(initialState));
|
||||
newState.entities.cloud.customer = {
|
||||
...newState.entities.cloud.customer,
|
||||
monthly_subscription_alt_payment_method: 'Dummy feedback',
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<GatherIntent {...baseProps}/>,
|
||||
newState,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
expect(screen.queryByText('Thanks for sharing feedback!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import type {JSXElementConstructor} from 'react';
|
||||
import {Modal} from 'react-bootstrap';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {TypePurchases} from '@mattermost/types/cloud';
|
||||
|
||||
import type {GatherIntentModalProps} from './gather_intent_modal';
|
||||
import {GatherIntentSubmittedModal} from './gather_intent_submitted_modal';
|
||||
import {useGatherIntent} from './useGatherIntent';
|
||||
|
||||
import './gather_intent.scss';
|
||||
|
||||
export interface GatherIntentProps {
|
||||
typeGatherIntent: keyof typeof TypePurchases;
|
||||
gatherIntentText: React.ReactNode;
|
||||
modalComponent: JSXElementConstructor<GatherIntentModalProps>;
|
||||
}
|
||||
|
||||
export const GatherIntent = ({gatherIntentText, typeGatherIntent, modalComponent: ModalComponent}: GatherIntentProps) => {
|
||||
const {
|
||||
feedbackSaved,
|
||||
handleSaveFeedback,
|
||||
showModal,
|
||||
handleOpenModal,
|
||||
handleCloseModal,
|
||||
submittingFeedback,
|
||||
showError,
|
||||
} = useGatherIntent({typeGatherIntent});
|
||||
|
||||
return (
|
||||
<div className='gatherIntent'>
|
||||
<FormattedMessage
|
||||
id={'payment_form.gather_wire_transfer_intent_title'}
|
||||
defaultMessage='Alternate Payment Options'
|
||||
>
|
||||
{(text) => (
|
||||
<h3 className='gatherIntent__title'>
|
||||
{text}
|
||||
</h3>)
|
||||
}
|
||||
</FormattedMessage>
|
||||
<button
|
||||
className={'gatherIntent__button'}
|
||||
id={typeGatherIntent}
|
||||
onClick={handleOpenModal}
|
||||
type='button'
|
||||
>
|
||||
{gatherIntentText}
|
||||
</button>
|
||||
{showModal &&
|
||||
<Modal
|
||||
className='AltPaymentsModal'
|
||||
dialogClassName='a11y__modal'
|
||||
show={showModal}
|
||||
onHide={handleCloseModal}
|
||||
onExited={handleCloseModal}
|
||||
role='dialog'
|
||||
id='AltPaymentsModal'
|
||||
aria-modal='true'
|
||||
>
|
||||
{!feedbackSaved &&
|
||||
<ModalComponent
|
||||
onSave={handleSaveFeedback}
|
||||
onClose={handleCloseModal}
|
||||
isSubmitting={submittingFeedback}
|
||||
showError={showError}
|
||||
/>}
|
||||
{feedbackSaved &&
|
||||
<GatherIntentSubmittedModal onClose={handleCloseModal}/>}
|
||||
</Modal>}
|
||||
</div>);
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
|
||||
import {GatherIntentModal} from './gather_intent_modal';
|
||||
import type {GatherIntentModalProps} from './gather_intent_modal';
|
||||
|
||||
describe('components/gather_intent/gather_intent_modal.tsx', () => {
|
||||
const baseProps: GatherIntentModalProps = {
|
||||
onClose: jest.fn(),
|
||||
onSave: jest.fn(),
|
||||
isSubmitting: false,
|
||||
showError: false,
|
||||
};
|
||||
|
||||
it('shouldn\'t be able to save the feedback if the user don\'t click on any option', () => {
|
||||
renderWithContext(<GatherIntentModal {...baseProps}/>);
|
||||
|
||||
expect(screen.queryByText('Save')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shouldn\'t be able to save the feedback if the user only click in other and leave the input empty', () => {
|
||||
renderWithContext(<GatherIntentModal {...baseProps}/>);
|
||||
|
||||
fireEvent.click(screen.getByText('Other'));
|
||||
|
||||
expect(screen.queryByText('Save')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shouldn\'t be able to save the feedback if the user only click in other and write only white spaces in the input', () => {
|
||||
renderWithContext(<GatherIntentModal {...baseProps}/>);
|
||||
|
||||
fireEvent.click(screen.getByText('Other'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter payment option here'), {target: {value: ' \n\t'}});
|
||||
|
||||
expect(screen.queryByText('Save')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should be able to save the feedback if the user only click in other, leave the input empty and press other option', () => {
|
||||
renderWithContext(<GatherIntentModal {...baseProps}/>);
|
||||
|
||||
fireEvent.click(screen.getByText('Other'));
|
||||
fireEvent.click(screen.getByText('Wire'));
|
||||
|
||||
expect(screen.queryByText('Save')).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('should be able save the feedback if the user click in Wire option', () => {
|
||||
renderWithContext(<GatherIntentModal {...baseProps}/>);
|
||||
|
||||
fireEvent.click(screen.getByText('Wire'));
|
||||
|
||||
expect(screen.queryByText('Save')).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('should be able save the feedback if the user click in ACH option', () => {
|
||||
renderWithContext(<GatherIntentModal {...baseProps}/>);
|
||||
|
||||
fireEvent.click(screen.getByText('ACH'));
|
||||
|
||||
expect(screen.queryByText('Save')).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
it('should be able save the feedback if the user click in other option and fill the option', () => {
|
||||
renderWithContext(<GatherIntentModal {...baseProps}/>);
|
||||
|
||||
fireEvent.click(screen.getByText('Other'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter payment option here'), {target: {value: 'Test'}});
|
||||
|
||||
expect(screen.queryByText('Save')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useState} from 'react';
|
||||
import {Modal} from 'react-bootstrap';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import warningIcon from 'images/icons/warning-icon.svg';
|
||||
|
||||
import './gather_intent.scss';
|
||||
import type {FormDataState} from './useGatherIntent';
|
||||
|
||||
export interface GatherIntentModalProps {
|
||||
onClose: () => void;
|
||||
onSave: (formData: FormDataState) => void;
|
||||
isSubmitting: boolean;
|
||||
showError: boolean;
|
||||
}
|
||||
|
||||
const isOtherUnchecked = (name: string, value: boolean): boolean => {
|
||||
return name === 'other' && value === false;
|
||||
};
|
||||
|
||||
const isOtherChecked = (name: string, value: boolean): boolean => {
|
||||
return name === 'other' && value === true;
|
||||
};
|
||||
|
||||
const isEmptyInput = (value: undefined | string) => {
|
||||
return value == null || value.trim() === '';
|
||||
};
|
||||
|
||||
const isFormEmpty = (formDataState: FormDataState) => {
|
||||
if (formDataState.other) {
|
||||
return isEmptyInput(formDataState.otherPaymentOption) && !formDataState.wire && !formDataState.ach;
|
||||
}
|
||||
|
||||
return Object.values(formDataState).every((value) => value === false || value == null);
|
||||
};
|
||||
|
||||
export const GatherIntentModal = ({onClose, onSave, isSubmitting, showError}: GatherIntentModalProps) => {
|
||||
const [formState, setFormState] = useState<FormDataState>({
|
||||
ach: false,
|
||||
wire: false,
|
||||
other: false,
|
||||
otherPaymentOption: undefined,
|
||||
});
|
||||
const intl = useIntl();
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
onSave(formState);
|
||||
};
|
||||
|
||||
const handleTextAreaChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const {name, value} = event.target;
|
||||
|
||||
setFormState((formDataState) => ({
|
||||
...formDataState,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
const handleCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const {name, checked} = event.target;
|
||||
|
||||
if (isOtherUnchecked(name, checked)) {
|
||||
setFormState((formDataState) => ({
|
||||
...formDataState,
|
||||
other: false,
|
||||
otherPaymentOption: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isOtherChecked(name, checked)) {
|
||||
setFormState((formDataState) => ({
|
||||
...formDataState,
|
||||
other: true,
|
||||
otherPaymentOption: '',
|
||||
}));
|
||||
}
|
||||
|
||||
setFormState((formDataState) => ({
|
||||
...formDataState,
|
||||
[name]: checked,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className='AltPaymentsModal__header '>
|
||||
<FormattedMessage
|
||||
id={'payment_form.gather_wire_transfer_intent_title'}
|
||||
defaultMessage='Alternate Payment Options'
|
||||
>
|
||||
{(text) => (
|
||||
<h3 className='Form-section-title'>
|
||||
{text}
|
||||
</h3>)
|
||||
}
|
||||
</FormattedMessage>
|
||||
<button
|
||||
id='closeIcon'
|
||||
className='icon icon-close'
|
||||
aria-label='Close'
|
||||
title='Close'
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<form
|
||||
id='gather_intent_wire_transfer'
|
||||
className='Form'
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.question'
|
||||
defaultMessage='Which payment options are you interested in using?'
|
||||
>
|
||||
{(text) => <p className='AltPaymentsModal__body__question'>{text}</p>}
|
||||
</FormattedMessage>
|
||||
<div className='Form-checkbox AltPaymentsModal__body__option'>
|
||||
<input
|
||||
className='AltPaymentsModal__body__checkbox'
|
||||
id='wire'
|
||||
name='wire'
|
||||
type='checkbox'
|
||||
checked={formState.wire}
|
||||
onChange={handleCheckboxChange}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.wire'
|
||||
defaultMessage='Wire'
|
||||
>
|
||||
{(text) => (
|
||||
<label
|
||||
className='AltPaymentsModal__body__label'
|
||||
htmlFor='wire'
|
||||
>
|
||||
{text}
|
||||
</label>)
|
||||
}
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
<div className='AltPaymentsModal__body__option'>
|
||||
<input
|
||||
className='AltPaymentsModal__body__checkbox'
|
||||
id='ach'
|
||||
name='ach'
|
||||
type='checkbox'
|
||||
checked={formState.ach}
|
||||
onChange={handleCheckboxChange}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.ach'
|
||||
defaultMessage='ACH'
|
||||
>
|
||||
{(text) => (
|
||||
<label
|
||||
className='AltPaymentsModal__body__label'
|
||||
htmlFor='ach'
|
||||
>
|
||||
{text}
|
||||
</label>)
|
||||
}
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
<div className='AltPaymentsModal__body__option'>
|
||||
<input
|
||||
className='AltPaymentsModal__body__checkbox'
|
||||
id='other'
|
||||
name='other'
|
||||
type='checkbox'
|
||||
checked={formState.other}
|
||||
onChange={handleCheckboxChange}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.other'
|
||||
defaultMessage='Other'
|
||||
>
|
||||
{(text) => (
|
||||
<label
|
||||
className='AltPaymentsModal__body__label'
|
||||
htmlFor='other'
|
||||
>
|
||||
{text}
|
||||
</label>)
|
||||
}
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
{formState.other && <div className='AltPaymentsModal__body__option'>
|
||||
<textarea
|
||||
id='other-payment-option'
|
||||
name='otherPaymentOption'
|
||||
className='AltPaymentsModal__body__textarea'
|
||||
value={formState.otherPaymentOption}
|
||||
onChange={handleTextAreaChange}
|
||||
placeholder={intl.formatMessage({id: 'payment_form.gather_wire_transfer_intent_modal.otherPaymentOptionPlaceholder', defaultMessage: 'Enter payment option here'})}
|
||||
rows={2}
|
||||
maxLength={400}
|
||||
/>
|
||||
</div>}
|
||||
{showError &&
|
||||
<div className='AltPaymentsModal__body__error'>
|
||||
<div>
|
||||
<img
|
||||
className='AltPaymentsModal__body__error__icon'
|
||||
alt=''
|
||||
src={warningIcon}
|
||||
/>
|
||||
</div>
|
||||
<FormattedMessage
|
||||
id='gather_intent.error_feedback'
|
||||
defaultMessage='Sorry, there was an error sending feedback. Please try again.'
|
||||
>
|
||||
{(text) => <span className='AltPaymentsModal__body__error__text'>{text}</span>}
|
||||
</FormattedMessage>
|
||||
</div>}
|
||||
|
||||
</form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className={'AltPaymentsModal__footer '}>
|
||||
<button
|
||||
className={'AltPaymentsModal__footer--secondary'}
|
||||
id={'cancelFeedback'}
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={'AltPaymentsModal__footer--primary'}
|
||||
id={'submitFeedback'}
|
||||
type='submit'
|
||||
form='gather_intent_wire_transfer'
|
||||
disabled={isFormEmpty(formState) || isSubmitting}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent_modal.save'
|
||||
defaultMessage='Save'
|
||||
/>
|
||||
</button>
|
||||
</Modal.Footer>
|
||||
</>);
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Modal} from 'react-bootstrap';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {CheckCircleIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
import './gather_intent.scss';
|
||||
|
||||
export interface GatherIntentModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const GatherIntentSubmittedModal = ({onClose}: GatherIntentModalProps) => {
|
||||
return (
|
||||
<>
|
||||
<Modal.Header className='AltPaymentsModal__header '>
|
||||
<button
|
||||
id='closeIcon'
|
||||
className='icon icon-close'
|
||||
aria-label='Close'
|
||||
title='Close'
|
||||
onClick={onClose}
|
||||
/>
|
||||
</Modal.Header>
|
||||
<Modal.Body className='AltPaymentsModal__body'>
|
||||
<div className='AltPaymentsModal__submitted-icon-container'>
|
||||
<CheckCircleIcon/>
|
||||
</div>
|
||||
<FormattedMessage
|
||||
id='gather_intent.feedback_saved'
|
||||
defaultMessage='Thanks for sharing feedback!'
|
||||
>
|
||||
{(text) => <span className='savedFeedback__text'>{text}</span>}
|
||||
</FormattedMessage>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className={'AltPaymentsModal__footer '}>
|
||||
<button
|
||||
className={'AltPaymentsModal__footer--primary'}
|
||||
id={'feedbackSubmitedDone'}
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='generic.done'
|
||||
defaultMessage='Done'
|
||||
/>
|
||||
</button>
|
||||
</Modal.Footer>
|
||||
</>);
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {GatherIntent} from './gather_intent';
|
||||
export {GatherIntentModal} from './gather_intent_modal';
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useState, useEffect} from 'react';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import type {MetadataGatherWireTransferKeys} from '@mattermost/types/cloud';
|
||||
import {TypePurchases} from '@mattermost/types/cloud';
|
||||
|
||||
import {updateCloudCustomer} from 'mattermost-redux/actions/cloud';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
interface UseGatherIntentArgs {
|
||||
typeGatherIntent: keyof typeof TypePurchases;
|
||||
}
|
||||
|
||||
export type FormDataState = FormDateStateWithoutOtherPayment | FormDateStateWithOtherPayment;
|
||||
|
||||
interface FormDateStateWithOtherPayment {
|
||||
wire: boolean;
|
||||
ach: boolean;
|
||||
other: true;
|
||||
otherPaymentOption: string;
|
||||
}
|
||||
|
||||
interface FormDateStateWithoutOtherPayment {
|
||||
wire: boolean;
|
||||
ach: boolean;
|
||||
other: false;
|
||||
otherPaymentOption?: never;
|
||||
}
|
||||
|
||||
export const useGatherIntent = ({typeGatherIntent}: UseGatherIntentArgs) => {
|
||||
const dispatch = useDispatch();
|
||||
const [feedbackSaved, setFeedbackSave] = useState(false);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [submittingFeedback, setSubmittingFeedback] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const customer = useSelector((state: GlobalState) => state.entities.cloud.customer);
|
||||
|
||||
const handleSaveFeedback = async (formData: FormDataState) => {
|
||||
setSubmittingFeedback(() => true);
|
||||
|
||||
const gatherIntentKey: MetadataGatherWireTransferKeys = `${TypePurchases[typeGatherIntent]}_alt_payment_method`;
|
||||
|
||||
const {error} = await dispatch(updateCloudCustomer({
|
||||
[gatherIntentKey]: JSON.stringify(formData),
|
||||
}));
|
||||
|
||||
if (error == null) {
|
||||
setFeedbackSave(() => true);
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
setShowError(() => true);
|
||||
}
|
||||
|
||||
setSubmittingFeedback(() => false);
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
trackEvent('click_open_payment_feedback_form_modal', {
|
||||
location: `${TypePurchases[typeGatherIntent]}_form`,
|
||||
});
|
||||
setShowModal(() => true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(() => false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (customer != null) {
|
||||
const gatherIntentKey: MetadataGatherWireTransferKeys = `${TypePurchases[typeGatherIntent]}_alt_payment_method`;
|
||||
setFeedbackSave(Boolean(customer[gatherIntentKey]));
|
||||
}
|
||||
}, [customer, typeGatherIntent]);
|
||||
|
||||
return {feedbackSaved, handleSaveFeedback, handleOpenModal, showModal, handleCloseModal, submittingFeedback, showError};
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
@import './mixins';
|
||||
|
||||
.PaymentForm {
|
||||
@include payment-form-padding;
|
||||
|
||||
margin: 0 auto;
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.form-row-third-1 {
|
||||
.DropdownInput {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
width: 66%;
|
||||
max-width: 288px;
|
||||
margin-right: 16px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.form-row-third-2 {
|
||||
width: 34%;
|
||||
max-width: 144px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 24px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.DropdownInput {
|
||||
position: relative;
|
||||
height: 36px;
|
||||
margin-bottom: 24px;
|
||||
font-weight: normal;
|
||||
|
||||
.DropDown__control {
|
||||
min-height: 0;
|
||||
background-color: var(--center-channel-bg) !important;
|
||||
}
|
||||
|
||||
.DropDown__menu {
|
||||
background-color: var(--center-channel-bg) !important;
|
||||
box-shadow: 0 0 0 1px var(--center-channel-color), 0 4px 11px var(--center-channel-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
-webkit-box-shadow: 0 0 0 30px var(--center-channel-bg) inset !important;
|
||||
-webkit-text-fill-color: var(--center-channel-color) !important;
|
||||
}
|
||||
|
||||
.Input_fieldset {
|
||||
height: 40px;
|
||||
padding: 2px 1px;
|
||||
background: var(--center-channel-bg);
|
||||
|
||||
.Input_wrapper {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.Input_fieldset___legend {
|
||||
>legend {
|
||||
margin-left: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
&.Input_fieldset:focus-within {
|
||||
padding-top: 2px;
|
||||
box-shadow: inset 0 0 0 2px var(--button-bg);
|
||||
color: var(--button-bg);
|
||||
}
|
||||
|
||||
&.Input_fieldset___error {
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
box-shadow: inset 0 0 0 1px var(--error-text);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
&.Input_fieldset___error:focus-within {
|
||||
box-shadow: inset 0 0 0 2px var(--error-text);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.Input {
|
||||
height: 32px;
|
||||
background: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--center-channel-color);
|
||||
opacity: 0.73;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.PaymentForm-saved {
|
||||
width: 442px;
|
||||
height: fit-content;
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.PaymentForm-saved-title {
|
||||
margin-bottom: 16px;
|
||||
color: var(--secondary-blue);
|
||||
font-family: Metropolis;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.5px;
|
||||
line-height: 18px;
|
||||
opacity: 0.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.PaymentForm-saved-card {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
color: var(--secondary-blue);
|
||||
font-family: Source Sans Pro;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.PaymentForm-saved-address {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--secondary-blue);
|
||||
font-family: Source Sans Pro;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.PaymentForm-change {
|
||||
color: #0058cc;
|
||||
font-family: Source Sans Pro;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {
|
||||
StripeCardElementChangeEvent,
|
||||
} from '@stripe/stripe-js';
|
||||
import React, {useRef} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import type {CloudCustomer, PaymentMethod} from '@mattermost/types/cloud';
|
||||
|
||||
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import Input from 'components/widgets/inputs/input/input';
|
||||
|
||||
import type {BillingDetails} from 'types/cloud/sku';
|
||||
|
||||
import CardImage from './card_image';
|
||||
import CardInput from './card_input';
|
||||
import type {CardInputType} from './card_input';
|
||||
import CountrySelector from './country_selector';
|
||||
import {GatherIntent, GatherIntentModal} from './gather_intent';
|
||||
import StateSelector from './state_selector';
|
||||
|
||||
import './payment_form.scss';
|
||||
|
||||
type Props = {
|
||||
className: string;
|
||||
initialBillingDetails?: BillingDetails;
|
||||
paymentMethod?: PaymentMethod;
|
||||
theme: Theme;
|
||||
onCardInputChange?: (change: StripeCardElementChangeEvent) => void;
|
||||
onInputChange?: (billing: BillingDetails) => void;
|
||||
onInputBlur?: (billing: BillingDetails) => void;
|
||||
buttonFooter?: JSX.Element;
|
||||
customer?: CloudCustomer | undefined;
|
||||
};
|
||||
|
||||
type State = {
|
||||
address: string;
|
||||
address2: string;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
postalCode: string;
|
||||
name: string;
|
||||
changePaymentMethod: boolean;
|
||||
company_name: string;
|
||||
}
|
||||
|
||||
const PaymentForm: React.FC<Props> = (props: Props) => {
|
||||
const {className, paymentMethod, buttonFooter, theme} = props;
|
||||
const {formatMessage} = useIntl();
|
||||
const cardRef = useRef<CardInputType>(null);
|
||||
|
||||
const [state, setState] = React.useState<State>({
|
||||
address: '',
|
||||
address2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
postalCode: '',
|
||||
name: '',
|
||||
changePaymentMethod: paymentMethod == null,
|
||||
company_name: props.customer?.name || '',
|
||||
});
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement> | React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const target = event.target;
|
||||
const name = target.name;
|
||||
const value = target.value;
|
||||
|
||||
const newStateValue = {
|
||||
[name]: value,
|
||||
} as unknown as Pick<State, keyof State>;
|
||||
|
||||
setState({...state, ...newStateValue});
|
||||
|
||||
const {onInputChange} = props;
|
||||
if (onInputChange) {
|
||||
onInputChange({...state, ...newStateValue, card: cardRef.current?.getCard()} as BillingDetails);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardInputChange = (event: StripeCardElementChangeEvent) => {
|
||||
if (props.onCardInputChange) {
|
||||
props.onCardInputChange(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStateChange = (stateValue: string) => {
|
||||
const newStateValue = {
|
||||
state: stateValue,
|
||||
} as unknown as Pick<State, keyof State>;
|
||||
setState({...state, ...newStateValue});
|
||||
|
||||
if (props.onInputChange) {
|
||||
props.onInputChange({...state, ...newStateValue, card: cardRef.current?.getCard()} as BillingDetails);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCountryChange = (option: any) => {
|
||||
const newStateValue = {
|
||||
country: option.value,
|
||||
} as unknown as Pick<State, keyof State>;
|
||||
setState({...state, ...newStateValue});
|
||||
|
||||
if (props.onInputChange) {
|
||||
props.onInputChange({...state, ...newStateValue, card: cardRef.current?.getCard()} as BillingDetails);
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
const {onInputBlur} = props;
|
||||
if (onInputBlur) {
|
||||
onInputBlur({...state, card: cardRef.current?.getCard()} as BillingDetails);
|
||||
}
|
||||
};
|
||||
|
||||
const changePaymentMethod = (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
setState({...state, changePaymentMethod: true});
|
||||
};
|
||||
|
||||
let paymentDetails: JSX.Element;
|
||||
if (state.changePaymentMethod) {
|
||||
paymentDetails = (
|
||||
<React.Fragment>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='company_name'
|
||||
type='text'
|
||||
value={state.company_name}
|
||||
onChange={handleInputChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={formatMessage({id: 'payment_form.company_name', defaultMessage: 'Company Name'})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<CardInput
|
||||
forwardedRef={cardRef}
|
||||
required={true}
|
||||
onBlur={onBlur}
|
||||
onCardInputChange={handleCardInputChange}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='name'
|
||||
type='text'
|
||||
value={state.name}
|
||||
onChange={handleInputChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={formatMessage({id: 'payment_form.name_on_card', defaultMessage: 'Name on Card'})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='section-title'>
|
||||
<FormattedMessage
|
||||
id='payment_form.billing_address'
|
||||
defaultMessage='Billing address'
|
||||
/>
|
||||
</div>
|
||||
<CountrySelector
|
||||
onChange={handleCountryChange}
|
||||
value={state.country}
|
||||
/>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='address'
|
||||
type='text'
|
||||
value={state.address}
|
||||
onChange={handleInputChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={formatMessage({id: 'payment_form.address', defaultMessage: 'Address'})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='address2'
|
||||
type='text'
|
||||
value={state.address2}
|
||||
onChange={handleInputChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={formatMessage({id: 'payment_form.address_2', defaultMessage: 'Address 2'})}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='city'
|
||||
type='text'
|
||||
value={state.city}
|
||||
onChange={handleInputChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={formatMessage({id: 'payment_form.city', defaultMessage: 'City'})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<div className='form-row-third-1 selector second-dropdown-sibling-wrapper'>
|
||||
<StateSelector
|
||||
country={state.country}
|
||||
state={state.state}
|
||||
onChange={handleStateChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row-third-2'>
|
||||
<Input
|
||||
name='postalCode'
|
||||
type='text'
|
||||
value={state.postalCode}
|
||||
onChange={handleInputChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={formatMessage({id: 'payment_form.zipcode', defaultMessage: 'Zip/Postal Code'})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{state.changePaymentMethod ? buttonFooter : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
let cardContent: JSX.Element | null = null;
|
||||
|
||||
if (paymentMethod) {
|
||||
let cardDetails = (
|
||||
<FormattedMessage
|
||||
id='payment_form.no_credit_card'
|
||||
defaultMessage='No credit card added'
|
||||
/>
|
||||
);
|
||||
if (paymentMethod.last_four) {
|
||||
cardDetails = (
|
||||
<React.Fragment>
|
||||
<CardImage brand={paymentMethod.card_brand}/>
|
||||
{`Card ending in ${paymentMethod.last_four}`}
|
||||
<br/>
|
||||
{`Expires ${paymentMethod.exp_month}/${paymentMethod.exp_year}`}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
let addressDetails = (
|
||||
<i>
|
||||
<FormattedMessage
|
||||
id='payment_form.no_billing_address'
|
||||
defaultMessage='No billing address added'
|
||||
/>
|
||||
</i>);
|
||||
if (state.state) {
|
||||
addressDetails = (
|
||||
<React.Fragment>
|
||||
{state.address}
|
||||
{state.address2}
|
||||
<br/>
|
||||
{`${state.city}, ${state.state}, ${state.country}`}
|
||||
<br/>
|
||||
{state.postalCode}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
cardContent = (
|
||||
<React.Fragment>
|
||||
<div className='PaymentForm-saved-card'>
|
||||
{cardDetails}
|
||||
</div>
|
||||
<div className='PaymentForm-saved-address'>
|
||||
{addressDetails}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
paymentDetails = (
|
||||
<div
|
||||
id='console_payment_saved'
|
||||
className='PaymentForm-saved'
|
||||
>
|
||||
<div className='PaymentForm-saved-title'>
|
||||
<FormattedMessage
|
||||
id='payment_form.saved_payment_method'
|
||||
defaultMessage='Saved Payment Method'
|
||||
/>
|
||||
</div>
|
||||
{cardContent}
|
||||
<button
|
||||
className='Form-btn-link PaymentForm-change'
|
||||
onClick={changePaymentMethod}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='payment_form.change_payment_method'
|
||||
defaultMessage='Change Payment Method'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id='payment_form'
|
||||
className={`PaymentForm ${className}`}
|
||||
>
|
||||
<GatherIntent
|
||||
typeGatherIntent='monthlySubscription'
|
||||
modalComponent={GatherIntentModal}
|
||||
gatherIntentText={
|
||||
<FormattedMessage
|
||||
id='payment_form.gather_wire_transfer_intent'
|
||||
defaultMessage='Looking for other payment options?'
|
||||
/>}
|
||||
/>
|
||||
<div className='section-title'>
|
||||
<FormattedMessage
|
||||
id='payment_form.credit_card'
|
||||
defaultMessage='Credit Card'
|
||||
/>
|
||||
</div>
|
||||
{paymentDetails}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
PaymentForm.defaultProps = {
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default PaymentForm;
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import type {
|
||||
StripeError,
|
||||
ConfirmCardSetupData,
|
||||
ConfirmCardSetupOptions,
|
||||
SetupIntent,
|
||||
} from '@stripe/stripe-js';
|
||||
|
||||
import {ServiceEnvironment} from '@mattermost/types/config';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
type ConfirmCardSetupType = (clientSecret: string, data?: ConfirmCardSetupData | undefined, options?: ConfirmCardSetupOptions | undefined) => Promise<{ setupIntent?: SetupIntent | undefined; error?: StripeError | undefined }> | undefined;
|
||||
|
||||
function prodConfirmCardSetup(confirmCardSetup: ConfirmCardSetupType): ConfirmCardSetupType {
|
||||
return confirmCardSetup;
|
||||
}
|
||||
|
||||
function devConfirmCardSetup(confirmCardSetup: ConfirmCardSetupType): ConfirmCardSetupType {
|
||||
return async (clientSecret: string, data?: ConfirmCardSetupData | undefined, options?: ConfirmCardSetupOptions | undefined) => {
|
||||
return {setupIntent: {id: 'testid', status: 'succeeded'} as SetupIntent};
|
||||
};
|
||||
}
|
||||
|
||||
export const getConfirmCardSetup = (isCwsMockMode?: boolean) => (isCwsMockMode ? devConfirmCardSetup : prodConfirmCardSetup);
|
||||
|
||||
export const STRIPE_CSS_SRC = 'https://fonts.googleapis.com/css?family=Open+Sans:400,400i,600,600i&display=swap';
|
||||
//eslint-disable-next-line no-process-env
|
||||
|
||||
export const getStripePublicKey = (state: GlobalState) => {
|
||||
switch (state.entities.general.config.ServiceEnvironment) {
|
||||
case ServiceEnvironment.PRODUCTION:
|
||||
return 'pk_live_cDF5gYLPf5vQjJ7jp71p7GRK';
|
||||
case ServiceEnvironment.TEST:
|
||||
case ServiceEnvironment.DEV:
|
||||
return 'pk_test_ttEpW6dCHksKyfAFzh6MvgBj';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
@@ -109,7 +109,7 @@ function Content(props: ContentProps) {
|
||||
openContactSales();
|
||||
},
|
||||
text: formatMessage({id: 'pricing_modal.btn.contactSales', defaultMessage: 'Contact Sales'}),
|
||||
customClass: ButtonCustomiserClasses.active,
|
||||
customClass: ButtonCustomiserClasses.special,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
.contact_sales_cta {
|
||||
height: 49px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ function SelfHostedContent(props: ContentProps) {
|
||||
action: () => {},
|
||||
text: formatMessage({id: 'pricing_modal.btn.downgrade', defaultMessage: 'Downgrade'}),
|
||||
disabled: true,
|
||||
customClass: ButtonCustomiserClasses.secondary,
|
||||
customClass: ButtonCustomiserClasses.active,
|
||||
}}
|
||||
briefing={{
|
||||
title: formatMessage({id: 'pricing_modal.briefing.title', defaultMessage: 'Top features'}),
|
||||
@@ -251,7 +251,7 @@ function SelfHostedContent(props: ContentProps) {
|
||||
openContactSales();
|
||||
},
|
||||
text: formatMessage({id: 'pricing_modal.btn.contactSales', defaultMessage: 'Contact Sales'}),
|
||||
customClass: ButtonCustomiserClasses.active,
|
||||
customClass: ButtonCustomiserClasses.special,
|
||||
} : undefined}
|
||||
customButtonDetails={(!isPostSelfHostedEnterpriseTrial && isAdmin) ? (
|
||||
trialButton()
|
||||
|
||||
@@ -75,42 +75,6 @@ describe('component/user_groups_modal/ad_ldap_upsell_banner', () => {
|
||||
expect(wrapper.find('.ad-ldap-banner-btn').text()).toEqual('Start trial');
|
||||
});
|
||||
|
||||
test('should display for admin users on professional with option to start trial if no cloud trial before', async () => {
|
||||
const state = JSON.parse(JSON.stringify(initState));
|
||||
state.entities.admin = {};
|
||||
state.entities.general.license = {
|
||||
Cloud: 'true',
|
||||
ExpiresAt: 100000000,
|
||||
};
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_professional',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 0,
|
||||
},
|
||||
products: {
|
||||
prod_professional: {
|
||||
id: 'prod_professional',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = mockStore(state);
|
||||
const dummyDispatch = jest.fn();
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
|
||||
const wrapper = mount(
|
||||
<reactRedux.Provider store={store}>
|
||||
<ADLDAPUpsellBanner/>
|
||||
</reactRedux.Provider>,
|
||||
);
|
||||
|
||||
await actImmediate(wrapper);
|
||||
|
||||
expect(wrapper.find('#ad_ldap_upsell_banner')).toHaveLength(1);
|
||||
expect(wrapper.find('.ad-ldap-banner-btn').text()).toEqual('Start trial');
|
||||
});
|
||||
|
||||
test('should display for admin users on professional with option to contact sales if self-hosted trialed before', () => {
|
||||
const state = JSON.parse(JSON.stringify(initState));
|
||||
state.entities.admin.prevTrialLicense.IsLicensed = 'true';
|
||||
|
||||
@@ -14,7 +14,6 @@ import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
|
||||
import {isAdmin} from 'mattermost-redux/utils/user_utils';
|
||||
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
|
||||
import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn';
|
||||
|
||||
@@ -89,7 +88,7 @@ function ADLDAPUpsellBanner() {
|
||||
return null;
|
||||
}
|
||||
|
||||
let btn = (
|
||||
let btn: JSX.Element | null = (
|
||||
<StartTrialBtn
|
||||
btnClass='ad-ldap-banner-btn'
|
||||
message={formatMessage({id: 'adldap_upsell_banner.trial_btn', defaultMessage: 'Start trial'})}
|
||||
@@ -98,18 +97,7 @@ function ADLDAPUpsellBanner() {
|
||||
onClick={() => setConfirmed(true)}
|
||||
/>);
|
||||
|
||||
if (isCloud) {
|
||||
btn = (
|
||||
<CloudStartTrialButton
|
||||
extraClass='ad-ldap-banner-btn'
|
||||
message={formatMessage({id: 'adldap_upsell_banner.trial_btn', defaultMessage: 'Start trial'})}
|
||||
telemetryId={'start_cloud_trial_from_adldap_upsell_banner'}
|
||||
onClick={() => setConfirmed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (prevTrialed) {
|
||||
if (prevTrialed || isCloud) {
|
||||
btn = (
|
||||
<button
|
||||
className='ad-ldap-banner-btn'
|
||||
|
||||
@@ -289,8 +289,6 @@
|
||||
"admin.billing.company_info.title": "Company Information",
|
||||
"admin.billing.company_info.zipcode": "Zip/Postal Code",
|
||||
"admin.billing.deleteWorkspace.failureModal.buttonText": "Try Again",
|
||||
"admin.billing.deleteWorkspace.failureModal.subtitle": "We ran into an issue deleting your workspace. Please try again or contact support.",
|
||||
"admin.billing.deleteWorkspace.failureModal.title": "Workspace deletion failed",
|
||||
"admin.billing.deleteWorkspace.progressModal.title": "Deleting your workspace",
|
||||
"admin.billing.deleteWorkspace.resultModal.ContactSupport": "Contact Support",
|
||||
"admin.billing.deleteWorkspace.successModal.subtitle": "Your workspace has now been deleted. Thank you for being a customer.",
|
||||
@@ -334,16 +332,6 @@
|
||||
"admin.billing.subscription.creditCardExpired": "Your credit card has expired. Update your payment information to avoid disruption.",
|
||||
"admin.billing.subscription.creditCardHasExpired": "Your credit card has expired",
|
||||
"admin.billing.subscription.creditCardHasExpired.description": "Please <link>update your payment information</link> to avoid any disruption.",
|
||||
"admin.billing.subscription.deleteWorkspaceModal.cancelButton": "Keep Subscription",
|
||||
"admin.billing.subscription.deleteWorkspaceModal.deleteButton": "Delete Workspace",
|
||||
"admin.billing.subscription.deleteWorkspaceModal.downgradeButton": "Downgrade To Free",
|
||||
"admin.billing.subscription.deleteWorkspaceModal.title": "Are you sure you want to delete?",
|
||||
"admin.billing.subscription.deleteWorkspaceModal.usage": "As part of your subscription to Mattermost {sku} you have created ",
|
||||
"admin.billing.subscription.deleteWorkspaceModal.usageDetails": "{messageCount} messages and {fileSize} of files",
|
||||
"admin.billing.subscription.deleteWorkspaceModal.warning": "Deleting your workspace is final. Upon deleting, you'll lose all of the above with no ability to recover. If you downgrade to Free, you will not lose this information.",
|
||||
"admin.billing.subscription.deleteWorkspaceSection.delete": "Delete Workspace",
|
||||
"admin.billing.subscription.deleteWorkspaceSection.description": "Deleting {workspaceLink} is final and cannot be reversed.",
|
||||
"admin.billing.subscription.deleteWorkspaceSection.title": "Delete your workspace",
|
||||
"admin.billing.subscription.downgrading": "Downgrading your workspace",
|
||||
"admin.billing.subscription.freeTrial.description": "Your free trial will expire in {daysLeftOnTrial} days. Add your payment information to continue after the trial ends.",
|
||||
"admin.billing.subscription.freeTrial.lastDay.description": "Your free trial has ended. Add payment information to continue enjoying the benefits of Cloud Professional.",
|
||||
@@ -403,8 +391,6 @@
|
||||
"admin.billing.subscription.updatePaymentInfo": "Update Payment Information",
|
||||
"admin.billing.subscription.userCount.tooltipText": "You must purchase at least the current number of active users.",
|
||||
"admin.billing.subscription.userCount.tooltipTitle": "Current User Count",
|
||||
"admin.billing.subscriptions.billing_summary.explore_enterprise": "Explore Enterprise features",
|
||||
"admin.billing.subscriptions.billing_summary.explore_enterprise.cta": "View all features",
|
||||
"admin.billing.subscriptions.billing_summary.lastInvoice.monthlyFlatFee": "Monthly Flat Fee",
|
||||
"admin.billing.subscriptions.billing_summary.lastInvoice.paid": "Paid",
|
||||
"admin.billing.subscriptions.billing_summary.lastInvoice.partialCharges": "Partial charges",
|
||||
@@ -420,18 +406,7 @@
|
||||
"admin.billing.subscriptions.billing_summary.noBillingHistory.description": "In the future, this is where your most recent bill summary will show.",
|
||||
"admin.billing.subscriptions.billing_summary.noBillingHistory.link": "See how billing works",
|
||||
"admin.billing.subscriptions.billing_summary.noBillingHistory.title": "No billing history yet",
|
||||
"admin.billing.subscriptions.billing_summary.try_enterprise": "Try Enterprise features for free",
|
||||
"admin.billing.subscriptions.billing_summary.try_enterprise.cta": "Try free for {trialLength} days",
|
||||
"admin.billing.subscriptions.billing_summary.upcomingInvoice.has_more_line_items": "And {count} more items",
|
||||
"admin.billing.trueUpReview.button_download": "Download Data",
|
||||
"admin.billing.trueUpReview.button_share": "Share to Mattermost",
|
||||
"admin.billing.trueUpReview.docsLinkCTA": "Learn more about true-up.",
|
||||
"admin.billing.trueUpReview.due_date": "Due ",
|
||||
"admin.billing.trueUpReview.share_data_for_review": "Share your system statistics with Mattermost for your quarterly true-up Review. {link}",
|
||||
"admin.billing.trueUpReview.submit_error": "There was an issue sending your True Up Review. Please try again.",
|
||||
"admin.billing.trueUpReview.submit_success": "Success!",
|
||||
"admin.billing.trueUpReview.submit.thanks_for_sharing": "Thanks for sharing data needed for your true-up review.",
|
||||
"admin.billing.trueUpReview.title": "True Up Review",
|
||||
"admin.bleve.bulkIndexingTitle": "Bulk Indexing:",
|
||||
"admin.bleve.createJob.help": "All users, channels and posts in the database will be indexed from oldest to newest. Bleve is available during indexing but search results may be incomplete until the indexing job is complete.",
|
||||
"admin.bleve.createJob.title": "Index Now",
|
||||
@@ -1007,7 +982,6 @@
|
||||
"admin.exportStorage.exportDriverName": "Export Storage Driver:",
|
||||
"admin.false": "False",
|
||||
"admin.feature_discovery.trial-request.accept-terms": "By clicking <highlight>Start trial</highlight>, I agree to the <linkEvaluation>Mattermost Software Evaluation Agreement</linkEvaluation>, <linkPrivacy>Privacy Policy</linkPrivacy> and receiving product emails.",
|
||||
"admin.feature_discovery.trial-request.accept-terms.cloudFree": "By selecting <highlight>Try free for {trialLength} days</highlight>, I agree to the <linkEvaluation>Mattermost Software Evaluation Agreement</linkEvaluation>, <linkPrivacy>Privacy Policy</linkPrivacy>, and receiving product emails.",
|
||||
"admin.feature_discovery.trial-request.error": "Trial license could not be retrieved. Visit <link>https://mattermost.com/trial</link> to request a license.",
|
||||
"admin.feature_flags.flag": "Flag",
|
||||
"admin.feature_flags.flag_value": "Value",
|
||||
@@ -1264,7 +1238,6 @@
|
||||
"admin.jobTable.statusWarning": "Warning",
|
||||
"admin.ldap_feature_discovery_cloud.call_to_action.primary_sales": "Contact sales",
|
||||
"admin.ldap_feature_discovery.call_to_action.primary": "Start trial",
|
||||
"admin.ldap_feature_discovery.call_to_action.primary.cloudFree": "Try free for {trialLength} days",
|
||||
"admin.ldap_feature_discovery.call_to_action.secondary": "Learn more",
|
||||
"admin.ldap_feature_discovery.copy": "When you connect Mattermost with your organization's Active Directory/LDAP, users can log in without having to create new usernames and passwords.",
|
||||
"admin.ldap_feature_discovery.title": "Integrate Active Directory/LDAP with Mattermost Professional",
|
||||
@@ -1399,7 +1372,7 @@
|
||||
"admin.license.purchaseEnterprisePlanTitle": "Purchase the Enterprise Plan",
|
||||
"admin.license.remove": "Remove",
|
||||
"admin.license.removing": "Removing License...",
|
||||
"admin.license.renewalCard.description": "Renew your {licenseSku} license through the Customer Portal to avoid any disruption.",
|
||||
"admin.license.renewalCard.description.contact_sales": "Renew your {licenseSku} license by contacting sales to avoid any disruption.",
|
||||
"admin.license.renewalCard.licensedUsersNum": "**Licensed Users:** {licensedUsersNum}",
|
||||
"admin.license.renewalCard.licenseExpired": "License expired on {date, date, long}.",
|
||||
"admin.license.renewalCard.licenseExpiring": "License expires in {days} days on {date, date, long}.",
|
||||
@@ -1431,7 +1404,6 @@
|
||||
"admin.license.upload-modal.successfulUpgradeText": "You have upgraded to the {skuName} plan for {licensedUsersNum, number} seats. This is effective from {startsAt} until {expiresAt}. ",
|
||||
"admin.license.upload-modal.title": "Upload a License Key",
|
||||
"admin.license.uploadFile": "Upload File",
|
||||
"admin.license.warn.renew": "Renew",
|
||||
"admin.lockTeammateNameDisplay": "Lock Teammate Name Display for all users: ",
|
||||
"admin.lockTeammateNameDisplayHelpText": "When true, disables users' ability to change settings under <strong>Settings > Display > Teammate Name Display</strong>.",
|
||||
"admin.log.AdvancedLoggingJSONDescription": "The JSON configuration for Advanced Logging. Please see <link>documentation</link> to learn more about Advanced Logging and the JSON format it uses.",
|
||||
@@ -2807,7 +2779,6 @@
|
||||
"announcement_bar.warn.email_support": "[Contact support](!{email}).",
|
||||
"announcement_bar.warn.no_internet_connection": "Looks like you do not have access to the internet.",
|
||||
"announcement_bar.warn.renew_license_contact_sales": "Contact sales",
|
||||
"announcement_bar.warn.renew_license_now": "Renew license now",
|
||||
"api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.",
|
||||
"api.channel.add_member.added": "{addedUsername} added to the channel by {username}.",
|
||||
"api.channel.delete_channel.archived": "{username} archived the channel.",
|
||||
@@ -3235,7 +3206,6 @@
|
||||
"cloud.fetch_error": "Error fetching billing data. Please try again later.",
|
||||
"cloud.fetch_error.retry": "Retry",
|
||||
"cloud.invoice_pdf_preview.download": "<downloadLink>Download</downloadLink> this page for your records",
|
||||
"cloud.startTrial.modal.btn": "Start trial",
|
||||
"collapsed_reply_threads_modal.confirm": "Got it",
|
||||
"collapsed_reply_threads_modal.description": "Threads have been revamped to help you create organized conversation around specific messages. Now, channels will appear less cluttered as replies are collapsed under the original message, and all the conversations you're following are available in your **Threads** view. Take the tour to see what's new.",
|
||||
"collapsed_reply_threads_modal.skip_tour": "Skip Tour",
|
||||
@@ -3590,13 +3560,6 @@
|
||||
"feature_restricted_modal.button.notify": "Notify admin",
|
||||
"feature_restricted_modal.button.plans": "View plans",
|
||||
"feedback.cancelButton.text": "Cancel",
|
||||
"feedback.deleteWorkspace.feedbackHosting": "Moving to hosting my own Mattermost instance (self-hosted)",
|
||||
"feedback.deleteWorkspace.feedbackMistake": "Created a workspace by mistake",
|
||||
"feedback.deleteWorkspace.feedbackMoving": "Moving to a different solution",
|
||||
"feedback.deleteWorkspace.feedbackNoValue": "No longer found value",
|
||||
"feedback.deleteWorkspace.feedbackPlaceholder": "Please tell us why you are deleting",
|
||||
"feedback.deleteWorkspace.feedbackTitle": "Please share your reason for deleting",
|
||||
"feedback.deleteWorkspace.submitText": "Delete Workspace",
|
||||
"feedback.downgradeWorkspace.downgrade": "Downgrade",
|
||||
"feedback.downgradeWorkspace.exploringOptions": "Exploring other solutions",
|
||||
"feedback.downgradeWorkspace.feedbackTitle": "Please share your reason for downgrading",
|
||||
@@ -3655,8 +3618,6 @@
|
||||
"free.professional_feature.upgrade": "Upgrade",
|
||||
"full_screen_modal.back": "Back",
|
||||
"full_screen_modal.close": "Close",
|
||||
"gather_intent.error_feedback": "Sorry, there was an error sending feedback. Please try again.",
|
||||
"gather_intent.feedback_saved": "Thanks for sharing feedback!",
|
||||
"general_button.close": "Close",
|
||||
"general_button.esc": "Esc",
|
||||
"general_tab.allowedDomains": "Allow only users with a specific email domain to join this team",
|
||||
@@ -4004,7 +3965,6 @@
|
||||
"learn_more_about_trial.modal.useSsoDescription": "Sign on quickly and easily with our SSO feature that works with OpenID, SAML, Google, and O365.",
|
||||
"learn_more_about_trial.modal.useSsoTitle": "Use SSO (with OpenID, SAML, Google, O365)",
|
||||
"learn_more_trial_modal_step.learnMoreAboutFeature": "Learn more about this feature.",
|
||||
"learn_more_trial_modal.contact_sales": "Contact Sales",
|
||||
"learn_more_trial_modal.pretitle": "With Enterprise, you can...",
|
||||
"leave_private_channel_modal.leave": "Yes, leave channel",
|
||||
"leave_private_channel_modal.message": "Are you sure you wish to leave the private channel {channel}? You must be re-invited in order to re-join this channel in the future.",
|
||||
@@ -4022,13 +3982,9 @@
|
||||
"leave_team_modal.yes": "Yes",
|
||||
"licensingPage.infoBanner.startTrialTitle": "Free 30 day trial!",
|
||||
"licensingPage.overageUsersBanner.cta": "Contact Sales",
|
||||
"licensingPage.overageUsersBanner.ctaExpandSeats": "Purchase additional seats",
|
||||
"licensingPage.overageUsersBanner.ctaUpdateSeats": "Update seat count",
|
||||
"licensingPage.overageUsersBanner.noticeDescription": "Notify your Customer Success Manager on your next true-up check. <a></a>",
|
||||
"licensingPage.overageUsersBanner.noticeTitle": "Your workspace user count has exceeded your paid license seat count by {seats, number} {seats, plural, one {seat} other {seats}}",
|
||||
"licensingPage.overageUsersBanner.selfHostedNoticeDescription": "<a>Purchase additional seats</a> to remain compliant.",
|
||||
"licensingPage.overageUsersBanner.text": "(Only visible to admins) Your workspace user count has exceeded your paid license seat count by {seats, number} {seats, plural, one {seat} other {seats}}. Purchase additional seats to remain compliant.",
|
||||
"licensingPage.overageUsersBanner.textSelfHostedExpand": "(Only visible to admins) Your workspace user count has exceeded your paid license seat count. Update your seat count to stay compliant.",
|
||||
"link_preview.image_preview": "Show Image preview",
|
||||
"link_preview.remove_link_preview": "Remove link preview",
|
||||
"list_modal.paginatorCount": "{startCount, number} - {endCount, number} of {total, number} total",
|
||||
@@ -4411,29 +4367,9 @@
|
||||
"passwordRequirements": "Password Requirements:",
|
||||
"payment_form.address": "Address",
|
||||
"payment_form.address_2": "Address 2",
|
||||
"payment_form.billing_address": "Billing address",
|
||||
"payment_form.change_payment_method": "Change Payment Method",
|
||||
"payment_form.city": "City",
|
||||
"payment_form.company_name": "Company Name",
|
||||
"payment_form.country": "Country/Region",
|
||||
"payment_form.credit_card": "Credit Card",
|
||||
"payment_form.gather_wire_transfer_intent": "Looking for other payment options?",
|
||||
"payment_form.gather_wire_transfer_intent_modal.ach": "ACH",
|
||||
"payment_form.gather_wire_transfer_intent_modal.cancel": "Cancel",
|
||||
"payment_form.gather_wire_transfer_intent_modal.other": "Other",
|
||||
"payment_form.gather_wire_transfer_intent_modal.otherPaymentOptionPlaceholder": "Enter payment option here",
|
||||
"payment_form.gather_wire_transfer_intent_modal.question": "Which payment options are you interested in using?",
|
||||
"payment_form.gather_wire_transfer_intent_modal.save": "Save",
|
||||
"payment_form.gather_wire_transfer_intent_modal.wire": "Wire",
|
||||
"payment_form.gather_wire_transfer_intent_title": "Alternate Payment Options",
|
||||
"payment_form.name_on_card": "Name on Card",
|
||||
"payment_form.no_billing_address": "No billing address added",
|
||||
"payment_form.no_credit_card": "No credit card added",
|
||||
"payment_form.saved_payment_method": "Saved Payment Method",
|
||||
"payment_form.zipcode": "Zip/Postal Code",
|
||||
"payment.card_number": "Card Number",
|
||||
"payment.field_required": "This field is required",
|
||||
"payment.invalid_card_number": "Please enter a valid credit card",
|
||||
"pending_post_actions.cancel": "Cancel",
|
||||
"pending_post_actions.retry": "Retry",
|
||||
"permalink.error.access": "Permalink belongs to a deleted message or to a channel to which you do not have access.",
|
||||
@@ -4684,10 +4620,6 @@
|
||||
"rename_channel.save": "Save",
|
||||
"rename_channel.title": "Rename Channel",
|
||||
"rename_channel.url": "URL",
|
||||
"request_business_email_modal.invalidEmail": "This doesn't look like a valid email",
|
||||
"request_business_email_modal.not_business_email": "This doesn't look like a business email",
|
||||
"request_business_email_modal.valid_business_email": "This is a valid email",
|
||||
"request_business_email.start_trial.modal.disclaimer": "By selecting <highlight>“Start trial”</highlight>, I agree to the <linkEvaluation>Mattermost Software Evaluation Agreement</linkEvaluation>, <linkPrivacy>privacy policy</linkPrivacy> and receiving product emails.",
|
||||
"restricted_indicator.tooltip.mesage": "During your trial you are able to use this feature.",
|
||||
"restricted_indicator.tooltip.message.blocked": "This is a paid feature, available with a free {trialLength}-day trial",
|
||||
"restricted_indicator.tooltip.title": "{minimumPlanRequiredForFeature} feature",
|
||||
@@ -5032,13 +4964,8 @@
|
||||
"single_image_view.download_tooltip": "Download",
|
||||
"slash_commands.header": "Slash Commands",
|
||||
"someting.string": "defaultString",
|
||||
"start_cloud_trial.modal.enter_trial_email.description": "Start a trial and enter a business email to get started. ",
|
||||
"start_cloud_trial.modal.enter_trial_email.input.label": "Enter business email",
|
||||
"start_cloud_trial.modal.enter_trial_email.input.placeholder": "name@companyname.com",
|
||||
"start_cloud_trial.modal.enter_trial_email.title": "Enter an email to start your trial",
|
||||
"start_cloud_trial.modal.failed": "Failed",
|
||||
"start_cloud_trial.modal.gettingTrial": "Getting Trial...",
|
||||
"start_cloud_trial.modal.loaded": "Loaded!",
|
||||
"start_trial_form_modal.failureModal.subtitle": "There was an issue processing your trial request.",
|
||||
"start_trial_form_modal.failureModal.subtitle2": "Please try again or contact support.",
|
||||
"start_trial_form_modal.failureModal.title": "Please try again",
|
||||
@@ -5267,10 +5194,6 @@
|
||||
"upgrade_export_data_modal.view_plans": "View Plans",
|
||||
"upgradeLink.warn.upgrade_now": "Upgrade now",
|
||||
"upload_overlay.info": "Drop a file to upload it.",
|
||||
"upsell_advantages.more": "And more...",
|
||||
"upsell_advantages.office365": "Office365 suite integration",
|
||||
"upsell_advantages.onelogin_saml": "OneLogin/ADFS SAML 2.0",
|
||||
"upsell_advantages.openid": "OpenID Connect",
|
||||
"url_input.buttonLabel.done": "Done",
|
||||
"url_input.buttonLabel.edit": "Edit",
|
||||
"url_input.label.url": "URL: ",
|
||||
|
||||
@@ -14,14 +14,12 @@ export default keyMirror({
|
||||
RECEIVED_BOARDS_USAGE: null,
|
||||
RECEIVED_TEAMS_USAGE: null,
|
||||
RECEIVED_LICENSE_SELF_SERVE_STATS: null,
|
||||
|
||||
CLOUD_CUSTOMER_FAILED: null,
|
||||
CLOUD_INVOICES_FAILED: null,
|
||||
CLOUD_LIMITS_FAILED: null,
|
||||
CLOUD_PRODUCTS_FAILED: null,
|
||||
CLOUD_SUBSCRIPTION_FAILED: null,
|
||||
LICENSE_SELF_SERVE_STATS_FAILED: null,
|
||||
|
||||
CLOUD_CUSTOMER_REQUEST: null,
|
||||
CLOUD_INVOICES_REQUEST: null,
|
||||
CLOUD_LIMITS_REQUEST: null,
|
||||
|
||||
@@ -6,15 +6,5 @@ import keyMirror from 'mattermost-redux/utils/key_mirror';
|
||||
export default keyMirror({
|
||||
SELF_HOSTED_PRODUCTS_REQUEST: null,
|
||||
SELF_HOSTED_PRODUCTS_FAILED: null,
|
||||
SELF_HOSTED_INVOICES_REQUEST: null,
|
||||
SELF_HOSTED_INVOICES_FAILED: null,
|
||||
RECEIVED_SELF_HOSTED_PRODUCTS: null,
|
||||
RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS: null,
|
||||
RECEIVED_SELF_HOSTED_INVOICES: null,
|
||||
RECEIVED_TRUE_UP_REVIEW_BUNDLE: null,
|
||||
TRUE_UP_REVIEW_PROFILE_FAILED: null,
|
||||
RECEIVED_TRUE_UP_REVIEW_STATUS: null,
|
||||
TRUE_UP_REVIEW_STATUS_FAILED: null,
|
||||
TRUE_UP_REVIEW_PROFILE_REQUEST: null,
|
||||
TRUE_UP_REVIEW_STATUS_REQUEST: null,
|
||||
});
|
||||
|
||||
@@ -36,15 +36,6 @@ export function getCloudCustomer() {
|
||||
});
|
||||
}
|
||||
|
||||
export function getLicenseSelfServeStatus() {
|
||||
return bindClientFunc({
|
||||
clientFunc: Client4.getLicenseSelfServeStatus,
|
||||
onRequest: CloudTypes.LICENSE_SELF_SERVE_STATS_REQUEST,
|
||||
onSuccess: [CloudTypes.RECEIVED_LICENSE_SELF_SERVE_STATS],
|
||||
onFailure: CloudTypes.LICENSE_SELF_SERVE_STATS_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
export function getInvoices() {
|
||||
return bindClientFunc({
|
||||
clientFunc: Client4.getInvoices,
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
import type {AnyAction} from 'redux';
|
||||
import {combineReducers} from 'redux';
|
||||
|
||||
import {SelfHostedSignupProgress} from '@mattermost/types/cloud';
|
||||
import type {Product, Subscription, CloudCustomer, Invoice, Limits, LicenseSelfServeStatusReducer} from '@mattermost/types/cloud';
|
||||
import type {ValueOf} from '@mattermost/types/utilities';
|
||||
import type {Product, Subscription, CloudCustomer, Invoice, Limits} from '@mattermost/types/cloud';
|
||||
|
||||
import {CloudTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
@@ -30,31 +28,6 @@ function customer(state: CloudCustomer | null = null, action: AnyAction) {
|
||||
}
|
||||
}
|
||||
|
||||
export function subscriptionStats(state: LicenseSelfServeStatusReducer | null = null, action: AnyAction): LicenseSelfServeStatusReducer | null {
|
||||
switch (action.type) {
|
||||
case CloudTypes.LICENSE_SELF_SERVE_STATS_REQUEST: {
|
||||
return {
|
||||
getRequestState: 'LOADING',
|
||||
...action.data,
|
||||
};
|
||||
}
|
||||
case CloudTypes.RECEIVED_LICENSE_SELF_SERVE_STATS: {
|
||||
return {
|
||||
getRequestState: 'OK',
|
||||
is_expandable: action.data,
|
||||
};
|
||||
}
|
||||
case CloudTypes.LICENSE_SELF_SERVE_STATS_FAILED: {
|
||||
return {
|
||||
getRequestState: 'ERROR',
|
||||
is_expandable: false,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function products(state: Record<string, Product> | null = null, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case CloudTypes.RECEIVED_CLOUD_PRODUCTS: {
|
||||
@@ -95,10 +68,12 @@ export interface LimitsReducer {
|
||||
limits: Limits;
|
||||
limitsLoaded: boolean;
|
||||
}
|
||||
|
||||
const emptyLimits = {
|
||||
limits: {},
|
||||
limitsLoaded: false,
|
||||
};
|
||||
|
||||
export function limits(state: LimitsReducer = emptyLimits, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case CloudTypes.RECEIVED_CLOUD_LIMITS: {
|
||||
@@ -114,6 +89,7 @@ export function limits(state: LimitsReducer = emptyLimits, action: AnyAction) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ErrorsReducer {
|
||||
subscription?: true;
|
||||
products?: true;
|
||||
@@ -198,25 +174,6 @@ export function errors(state: ErrorsReducer = emptyErrors, action: AnyAction) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SelfHostedSignupReducer {
|
||||
progress: ValueOf<typeof SelfHostedSignupProgress>;
|
||||
}
|
||||
const initialSelfHostedSignup = {
|
||||
progress: SelfHostedSignupProgress.START,
|
||||
};
|
||||
function selfHostedSignup(state: SelfHostedSignupReducer = initialSelfHostedSignup, action: AnyAction): SelfHostedSignupReducer {
|
||||
switch (action.type) {
|
||||
case CloudTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS:
|
||||
return {
|
||||
...state,
|
||||
progress: action.data,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
|
||||
// represents the current cloud customer
|
||||
@@ -236,10 +193,4 @@ export default combineReducers({
|
||||
|
||||
// network errors, used to show errors in ui instead of blowing up and showing nothing
|
||||
errors,
|
||||
|
||||
// Subscription expansion status
|
||||
subscriptionStats,
|
||||
|
||||
// state related to self-hosted workspaces purchasing a license not tied to a customer-web-server user.
|
||||
selfHostedSignup,
|
||||
});
|
||||
|
||||
@@ -4,14 +4,11 @@
|
||||
import type {AnyAction} from 'redux';
|
||||
import {combineReducers} from 'redux';
|
||||
|
||||
import type {Invoice, Product} from '@mattermost/types/cloud';
|
||||
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
|
||||
import type {TrueUpReviewProfileReducer, TrueUpReviewStatusReducer} from '@mattermost/types/hosted_customer';
|
||||
import type {ValueOf} from '@mattermost/types/utilities';
|
||||
import type {Product} from '@mattermost/types/cloud';
|
||||
|
||||
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
interface SelfHostedProducts {
|
||||
export interface SelfHostedProducts {
|
||||
products: Record<string, Product>;
|
||||
productsLoaded: boolean;
|
||||
}
|
||||
@@ -20,14 +17,6 @@ const initialProducts = {
|
||||
products: {},
|
||||
productsLoaded: false,
|
||||
};
|
||||
interface SelfHostedInvoices {
|
||||
invoices: Record<string, Invoice>;
|
||||
invoicesLoaded: boolean;
|
||||
}
|
||||
const initialInvoices = {
|
||||
invoices: {},
|
||||
invoicesLoaded: false,
|
||||
};
|
||||
|
||||
function products(state: SelfHostedProducts = initialProducts, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
@@ -51,42 +40,6 @@ function products(state: SelfHostedProducts = initialProducts, action: AnyAction
|
||||
}
|
||||
}
|
||||
|
||||
function invoices(state: SelfHostedInvoices = initialInvoices, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case HostedCustomerTypes.RECEIVED_SELF_HOSTED_INVOICES: {
|
||||
const invoiceList: Invoice[] = action.data;
|
||||
const invoiceDict = invoiceList.reduce((map, obj) => {
|
||||
map[obj.id] = obj;
|
||||
return map;
|
||||
}, {} as Record<string, Invoice>);
|
||||
return {
|
||||
...state,
|
||||
invoices: {
|
||||
...state.invoices,
|
||||
...invoiceDict,
|
||||
},
|
||||
productsLoaded: true,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
type SignupProgress = ValueOf<typeof SelfHostedSignupProgress>;
|
||||
function signupProgress(state = SelfHostedSignupProgress.START, action: AnyAction): SignupProgress {
|
||||
switch (action.type) {
|
||||
case HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS: {
|
||||
if (!action.data) {
|
||||
throw new Error(`uh ohh, expect action to have data but it dit not. Action: ${JSON.stringify(action, null, 2)}`);
|
||||
}
|
||||
return action.data;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ErrorsReducer {
|
||||
products?: true;
|
||||
invoices?: true;
|
||||
@@ -104,59 +57,6 @@ export function errors(state: ErrorsReducer = emptyErrors, action: AnyAction) {
|
||||
delete newState.products;
|
||||
return newState;
|
||||
}
|
||||
case HostedCustomerTypes.SELF_HOSTED_INVOICES_FAILED: {
|
||||
return {...state, products: true};
|
||||
}
|
||||
case HostedCustomerTypes.TRUE_UP_REVIEW_PROFILE_FAILED:
|
||||
case HostedCustomerTypes.TRUE_UP_REVIEW_STATUS_FAILED: {
|
||||
return {...state, trueUpReview: true};
|
||||
}
|
||||
case HostedCustomerTypes.SELF_HOSTED_INVOICES_REQUEST:
|
||||
case HostedCustomerTypes.RECEIVED_SELF_HOSTED_INVOICES: {
|
||||
const newState = Object.assign({}, state);
|
||||
delete newState.invoices;
|
||||
return newState;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function trueUpReviewProfile(state: TrueUpReviewProfileReducer | null = null, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case HostedCustomerTypes.RECEIVED_TRUE_UP_REVIEW_BUNDLE: {
|
||||
return {
|
||||
...state,
|
||||
getRequestState: 'OK',
|
||||
...action.data,
|
||||
};
|
||||
}
|
||||
case HostedCustomerTypes.TRUE_UP_REVIEW_PROFILE_REQUEST: {
|
||||
return {
|
||||
...state,
|
||||
getRequestState: 'LOADING',
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function trueUpReviewStatus(state: TrueUpReviewStatusReducer | null = null, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case HostedCustomerTypes.RECEIVED_TRUE_UP_REVIEW_STATUS: {
|
||||
return {
|
||||
...state,
|
||||
getRequestState: 'OK',
|
||||
...action.data,
|
||||
};
|
||||
}
|
||||
case HostedCustomerTypes.TRUE_UP_REVIEW_STATUS_REQUEST: {
|
||||
return {
|
||||
...state,
|
||||
getRequestState: 'LOADING',
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -164,9 +64,5 @@ function trueUpReviewStatus(state: TrueUpReviewStatusReducer | null = null, acti
|
||||
|
||||
export default combineReducers({
|
||||
products,
|
||||
invoices,
|
||||
signupProgress,
|
||||
errors,
|
||||
trueUpReviewProfile,
|
||||
trueUpReviewStatus,
|
||||
});
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Invoice, Product} from '@mattermost/types/cloud';
|
||||
import type {SelfHostedSignupProgress, HostedCustomerState, TrueUpReviewProfileReducer, TrueUpReviewStatusReducer} from '@mattermost/types/hosted_customer';
|
||||
import type {Product} from '@mattermost/types/cloud';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
import type {ValueOf} from '@mattermost/types/utilities';
|
||||
|
||||
export function getSelfHostedSignupProgress(state: GlobalState): ValueOf<typeof SelfHostedSignupProgress> {
|
||||
return state.entities.hostedCustomer.signupProgress;
|
||||
}
|
||||
|
||||
export function getSelfHostedProducts(state: GlobalState): Record<string, Product> {
|
||||
return state.entities.hostedCustomer.products.products;
|
||||
@@ -17,19 +11,3 @@ export function getSelfHostedProducts(state: GlobalState): Record<string, Produc
|
||||
export function getSelfHostedProductsLoaded(state: GlobalState): boolean {
|
||||
return state.entities.hostedCustomer.products.productsLoaded;
|
||||
}
|
||||
|
||||
export function getSelfHostedInvoices(state: GlobalState): Record<string, Invoice> {
|
||||
return state.entities.hostedCustomer.invoices.invoices;
|
||||
}
|
||||
|
||||
export function getSelfHostedErrors(state: GlobalState): HostedCustomerState['errors'] {
|
||||
return state.entities.hostedCustomer.errors;
|
||||
}
|
||||
|
||||
export function getTrueUpReviewProfile(state: GlobalState): TrueUpReviewProfileReducer {
|
||||
return state.entities.hostedCustomer.trueUpReviewProfile;
|
||||
}
|
||||
|
||||
export function getTrueUpReviewStatus(state: GlobalState): TrueUpReviewStatusReducer {
|
||||
return state.entities.hostedCustomer.trueUpReviewStatus;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
import {zeroStateLimitedViews} from '../reducers/entities/posts';
|
||||
@@ -187,30 +186,12 @@ const state: GlobalState = {
|
||||
limitsLoaded: false,
|
||||
},
|
||||
errors: {},
|
||||
selfHostedSignup: {
|
||||
progress: SelfHostedSignupProgress.START,
|
||||
},
|
||||
},
|
||||
hostedCustomer: {
|
||||
signupProgress: SelfHostedSignupProgress.START,
|
||||
products: {
|
||||
products: {},
|
||||
productsLoaded: false,
|
||||
},
|
||||
errors: {},
|
||||
invoices: {
|
||||
invoices: {},
|
||||
invoicesLoaded: false,
|
||||
},
|
||||
trueUpReviewProfile: {
|
||||
content: '',
|
||||
getRequestState: 'IDLE',
|
||||
},
|
||||
trueUpReviewStatus: {
|
||||
complete: false,
|
||||
due_date: 0,
|
||||
getRequestState: 'IDLE',
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
files: {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {StripeCardElement} from '@stripe/stripe-js';
|
||||
|
||||
export type StripeSetupIntent = {
|
||||
id: string;
|
||||
client_secret: string;
|
||||
};
|
||||
|
||||
export type BillingDetails = {
|
||||
address: string;
|
||||
address2: string;
|
||||
@@ -16,7 +9,6 @@ export type BillingDetails = {
|
||||
country: string;
|
||||
postalCode: string;
|
||||
name: string;
|
||||
card: StripeCardElement;
|
||||
agreedTerms?: boolean;
|
||||
company_name?: string;
|
||||
};
|
||||
|
||||
18
webapp/package-lock.json
сгенерированный
18
webapp/package-lock.json
сгенерированный
@@ -67,8 +67,6 @@
|
||||
"@mui/base": "5.0.0-alpha.127",
|
||||
"@mui/material": "5.11.16",
|
||||
"@mui/styled-engine-sc": "5.11.11",
|
||||
"@stripe/react-stripe-js": "1.13.0",
|
||||
"@stripe/stripe-js": "1.41.0",
|
||||
"@tanstack/react-table": "8.10.7",
|
||||
"@tippyjs/react": "4.2.6",
|
||||
"@types/color-hash": "1.0.2",
|
||||
@@ -4833,22 +4831,6 @@
|
||||
"@sinonjs/commons": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "1.13.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@stripe/stripe-js": "^1.41.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "1.41.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stylistic/stylelint-plugin": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@stylistic/stylelint-plugin/-/stylelint-plugin-2.1.0.tgz",
|
||||
|
||||
@@ -37,10 +37,6 @@ import type {
|
||||
NotifyAdminRequest,
|
||||
Subscription,
|
||||
ValidBusinessEmail,
|
||||
LicenseSelfServeStatus,
|
||||
CreateSubscriptionRequest,
|
||||
Feedback,
|
||||
WorkspaceDeletionRequest,
|
||||
NewsletterRequestBody,
|
||||
Installation,
|
||||
} from '@mattermost/types/cloud';
|
||||
@@ -83,13 +79,6 @@ import type {
|
||||
GetGroupsForUserParams,
|
||||
GroupStats,
|
||||
} from '@mattermost/types/groups';
|
||||
import type {
|
||||
SelfHostedSignupForm,
|
||||
SelfHostedSignupCustomerResponse,
|
||||
SelfHostedSignupSuccessResponse,
|
||||
SelfHostedSignupBootstrapResponse,
|
||||
SelfHostedExpansionRequest,
|
||||
} from '@mattermost/types/hosted_customer';
|
||||
import type {PostActionResponse} from '@mattermost/types/integration_actions';
|
||||
import type {
|
||||
Command,
|
||||
@@ -3905,53 +3894,12 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
bootstrapSelfHostedSignup = (reset?: boolean) => {
|
||||
let query = '';
|
||||
|
||||
// reset will drop the old token
|
||||
if (reset) {
|
||||
query = '?reset=true';
|
||||
}
|
||||
return this.doFetch<SelfHostedSignupBootstrapResponse>(
|
||||
`${this.getHostedCustomerRoute()}/bootstrap${query}`,
|
||||
{method: 'post'},
|
||||
);
|
||||
};
|
||||
|
||||
getAvailabilitySelfHostedSignup = () => {
|
||||
return this.doFetch<void>(
|
||||
`${this.getHostedCustomerRoute()}/signup_available`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
getSelfHostedProducts = () => {
|
||||
return this.doFetch<Product[]>(
|
||||
`${this.getCloudRoute()}/products/selfhosted`, {method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
createCustomerSelfHostedSignup = (form: SelfHostedSignupForm) => {
|
||||
return this.doFetch<SelfHostedSignupCustomerResponse>(
|
||||
`${this.getHostedCustomerRoute()}/customer`,
|
||||
{method: 'post', body: JSON.stringify(form)},
|
||||
);
|
||||
};
|
||||
|
||||
confirmSelfHostedSignup = (setupIntentId: string, createSubscriptionRequest: CreateSubscriptionRequest) => {
|
||||
return this.doFetch<SelfHostedSignupSuccessResponse>(
|
||||
`${this.getHostedCustomerRoute()}/confirm`,
|
||||
{method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, subscription: createSubscriptionRequest})},
|
||||
);
|
||||
};
|
||||
|
||||
confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => {
|
||||
return this.doFetch<SelfHostedSignupSuccessResponse>(
|
||||
`${this.getHostedCustomerRoute()}/confirm-expand`,
|
||||
{method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, expand_request: expandRequest})},
|
||||
);
|
||||
};
|
||||
|
||||
subscribeToNewsletter = (newletterRequestBody: NewsletterRequestBody) => {
|
||||
return this.doFetch<StatusOK>(
|
||||
`${this.getHostedCustomerRoute()}/subscribe-newsletter`,
|
||||
@@ -3959,10 +3907,10 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
createPaymentMethod = async () => {
|
||||
return this.doFetch(
|
||||
`${this.getCloudRoute()}/payment`,
|
||||
{method: 'post'},
|
||||
cwsAvailabilityCheck = () => {
|
||||
return this.doFetchWithResponse(
|
||||
`${this.getCloudRoute()}/check-cws-connection`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3972,12 +3920,6 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
getLicenseSelfServeStatus = () => {
|
||||
return this.doFetch<LicenseSelfServeStatus>(
|
||||
`${this.getCloudRoute()}/subscription/self-serve-status`, {method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
updateCloudCustomer = (customerPatch: CloudCustomerPatch) => {
|
||||
return this.doFetch<CloudCustomer>(
|
||||
`${this.getCloudRoute()}/customer`,
|
||||
@@ -3999,39 +3941,6 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
confirmPaymentMethod = async (stripeSetupIntentID: string) => {
|
||||
return this.doFetch(
|
||||
`${this.getCloudRoute()}/payment/confirm`,
|
||||
{method: 'post', body: JSON.stringify({stripe_setup_intent_id: stripeSetupIntentID})},
|
||||
);
|
||||
};
|
||||
|
||||
subscribeCloudProduct = (productId: string, shippingAddress?: Address, seats = 0, downgradeFeedback?: Feedback, customerPatch?: CloudCustomerPatch) => {
|
||||
const body = {
|
||||
product_id: productId,
|
||||
seats,
|
||||
downgrade_feedback: downgradeFeedback,
|
||||
} as any;
|
||||
if (shippingAddress) {
|
||||
body.shipping_address = shippingAddress;
|
||||
}
|
||||
|
||||
if (customerPatch) {
|
||||
body.customer = customerPatch;
|
||||
}
|
||||
return this.doFetch<Subscription>(
|
||||
`${this.getCloudRoute()}/subscription`,
|
||||
{method: 'put', body: JSON.stringify(body)},
|
||||
);
|
||||
};
|
||||
|
||||
requestCloudTrial = (subscriptionId: string, email = '') => {
|
||||
return this.doFetchWithResponse<Subscription>(
|
||||
`${this.getCloudRoute()}/request-trial`,
|
||||
{method: 'put', body: JSON.stringify({email, subscription_id: subscriptionId})},
|
||||
);
|
||||
};
|
||||
|
||||
validateBusinessEmail = (email = '') => {
|
||||
return this.doFetchWithResponse<ValidBusinessEmail>(
|
||||
`${this.getCloudRoute()}/validate-business-email`,
|
||||
@@ -4060,13 +3969,6 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
getRenewalLink = () => {
|
||||
return this.doFetch<{renewal_link: string}>(
|
||||
`${this.getBaseRoute()}/license/renewal`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
getInvoices = () => {
|
||||
return this.doFetch<Invoice[]>(
|
||||
`${this.getCloudRoute()}/subscription/invoices`,
|
||||
@@ -4078,17 +3980,6 @@ export default class Client4 {
|
||||
return `${this.getCloudRoute()}/subscription/invoices/${invoiceId}/pdf`;
|
||||
};
|
||||
|
||||
getSelfHostedInvoices = () => {
|
||||
return this.doFetch<Invoice[]>(
|
||||
`${this.getHostedCustomerRoute()}/invoices`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
getSelfHostedInvoicePdfUrl = (invoiceId: string) => {
|
||||
return `${this.getHostedCustomerRoute()}/invoices/${invoiceId}/pdf`;
|
||||
};
|
||||
|
||||
getCloudLimits = () => {
|
||||
return this.doFetch<Limits>(
|
||||
`${this.getCloudRoute()}/limits`,
|
||||
@@ -4329,34 +4220,6 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
submitTrueUpReview = () => {
|
||||
return this.doFetch(
|
||||
`${this.getBaseRoute()}/license/review`,
|
||||
{method: 'post'},
|
||||
);
|
||||
};
|
||||
|
||||
getTrueUpReviewStatus = () => {
|
||||
return this.doFetch(
|
||||
`${this.getBaseRoute()}/license/review/status`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
cwsAvailabilityCheck = () => {
|
||||
return this.doFetchWithResponse(
|
||||
`${this.getCloudRoute()}/check-cws-connection`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
deleteWorkspace = (deletionRequest: WorkspaceDeletionRequest) => {
|
||||
return this.doFetch<StatusOK>(
|
||||
`${this.getCloudRoute()}/delete-workspace`,
|
||||
{method: 'delete', body: JSON.stringify(deletionRequest)},
|
||||
);
|
||||
};
|
||||
|
||||
getGroupMessageMembersCommonTeams = (channelId: string) => {
|
||||
return this.doFetchWithResponse<Team[]>(
|
||||
`${this.getChannelRoute(channelId)}/common_teams`,
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {AllowedIPRange} from './config';
|
||||
import type {ValueOf} from './utilities';
|
||||
|
||||
export type CloudState = {
|
||||
subscription?: Subscription;
|
||||
products?: Record<string, Product>;
|
||||
customer?: CloudCustomer;
|
||||
invoices?: Record<string, Invoice>;
|
||||
subscriptionStats?: LicenseSelfServeStatusReducer;
|
||||
limits: {
|
||||
limitsLoaded: boolean;
|
||||
limits: Limits;
|
||||
@@ -22,9 +20,6 @@ export type CloudState = {
|
||||
limits?: true;
|
||||
trueUpReview?: true;
|
||||
};
|
||||
selfHostedSignup: {
|
||||
progress: ValueOf<typeof SelfHostedSignupProgress>;
|
||||
};
|
||||
}
|
||||
|
||||
export type Installation = {
|
||||
@@ -74,27 +69,6 @@ export type AddOn = {
|
||||
price_per_seat: number;
|
||||
};
|
||||
|
||||
export const TypePurchases = {
|
||||
firstSelfHostLicensePurchase: 'first_purchase',
|
||||
renewalSelfHost: 'renewal_self',
|
||||
monthlySubscription: 'monthly_subscription',
|
||||
annualSubscription: 'annual_subscription',
|
||||
} as const;
|
||||
|
||||
export const SelfHostedSignupProgress = {
|
||||
START: 'START',
|
||||
CREATED_CUSTOMER: 'CREATED_CUSTOMER',
|
||||
CREATED_INTENT: 'CREATED_INTENT',
|
||||
CONFIRMED_INTENT: 'CONFIRMED_INTENT',
|
||||
CREATED_SUBSCRIPTION: 'CREATED_SUBSCRIPTION',
|
||||
PAID: 'PAID',
|
||||
CREATED_LICENSE: 'CREATED_LICENSE',
|
||||
} as const;
|
||||
|
||||
export type MetadataGatherWireTransferKeys = `${ValueOf<typeof TypePurchases>}_alt_payment_method`
|
||||
|
||||
export type CustomerMetadataGatherWireTransfer = Partial<Record<MetadataGatherWireTransferKeys, string>>
|
||||
|
||||
// Customer model represents a customer on the system.
|
||||
export type CloudCustomer = {
|
||||
id: string;
|
||||
@@ -108,16 +82,6 @@ export type CloudCustomer = {
|
||||
billing_address: Address;
|
||||
company_address: Address;
|
||||
payment_method: PaymentMethod;
|
||||
} & CustomerMetadataGatherWireTransfer
|
||||
|
||||
export type LicenseSelfServeStatus = {
|
||||
is_expandable?: boolean;
|
||||
is_renewable?: boolean;
|
||||
}
|
||||
|
||||
type RequestState = 'IDLE' | 'LOADING' | 'ERROR' | 'OK'
|
||||
export interface LicenseSelfServeStatusReducer extends LicenseSelfServeStatus {
|
||||
getRequestState: RequestState;
|
||||
}
|
||||
|
||||
// CustomerPatch model represents a customer patch on the system.
|
||||
@@ -127,7 +91,7 @@ export type CloudCustomerPatch = {
|
||||
num_employees?: number;
|
||||
contact_first_name?: string;
|
||||
contact_last_name?: string;
|
||||
} & CustomerMetadataGatherWireTransfer
|
||||
}
|
||||
|
||||
// Address model represents a customer's address.
|
||||
export type Address = {
|
||||
@@ -226,13 +190,6 @@ export type ValidBusinessEmail = {
|
||||
is_valid: boolean;
|
||||
}
|
||||
|
||||
export interface CreateSubscriptionRequest {
|
||||
product_id: string;
|
||||
add_ons: string[];
|
||||
seats: number;
|
||||
internal_purchase_order?: string;
|
||||
}
|
||||
|
||||
export interface NewsletterRequestBody {
|
||||
email: string;
|
||||
subscribed_content: string;
|
||||
@@ -248,8 +205,3 @@ export type Feedback = {
|
||||
reason: string;
|
||||
comments: string;
|
||||
}
|
||||
|
||||
export type WorkspaceDeletionRequest = {
|
||||
subscription_id: string;
|
||||
delete_feedback: Feedback;
|
||||
}
|
||||
|
||||
@@ -1,82 +1,11 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Address, Product, Invoice} from './cloud';
|
||||
import type {ValueOf} from './utilities';
|
||||
|
||||
export const SelfHostedSignupProgress = {
|
||||
START: 'START',
|
||||
CREATED_CUSTOMER: 'CREATED_CUSTOMER',
|
||||
CREATED_INTENT: 'CREATED_INTENT',
|
||||
CONFIRMED_INTENT: 'CONFIRMED_INTENT',
|
||||
CREATED_SUBSCRIPTION: 'CREATED_SUBSCRIPTION',
|
||||
PAID: 'PAID',
|
||||
CREATED_LICENSE: 'CREATED_LICENSE',
|
||||
} as const;
|
||||
|
||||
export interface SelfHostedSignupForm {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
billing_address: Address;
|
||||
shipping_address: Address;
|
||||
organization: string;
|
||||
}
|
||||
|
||||
export interface SelfHostedSignupBootstrapResponse {
|
||||
progress: ValueOf<typeof SelfHostedSignupProgress>;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface SelfHostedSignupCustomerResponse {
|
||||
customer_id: string;
|
||||
setup_intent_id: string;
|
||||
setup_intent_secret: string;
|
||||
progress: ValueOf<typeof SelfHostedSignupProgress>;
|
||||
}
|
||||
|
||||
export interface SelfHostedSignupSuccessResponse {
|
||||
progress: ValueOf<typeof SelfHostedSignupProgress>;
|
||||
license: Record<string, string>;
|
||||
}
|
||||
import type {Product} from './cloud';
|
||||
|
||||
export type HostedCustomerState = {
|
||||
products: {
|
||||
products: Record<string, Product>;
|
||||
productsLoaded: boolean;
|
||||
};
|
||||
invoices: {
|
||||
invoices: Record<string, Invoice>;
|
||||
invoicesLoaded: boolean;
|
||||
};
|
||||
errors: {
|
||||
products?: true;
|
||||
invoices?: true;
|
||||
trueUpReview?: true;
|
||||
};
|
||||
signupProgress: ValueOf<typeof SelfHostedSignupProgress>;
|
||||
trueUpReviewStatus: TrueUpReviewStatusReducer;
|
||||
trueUpReviewProfile: TrueUpReviewProfileReducer;
|
||||
}
|
||||
|
||||
export type TrueUpReviewProfile = {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type TrueUpReviewStatus = {
|
||||
due_date: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
type RequestState = 'IDLE' | 'LOADING' | 'OK'
|
||||
export interface TrueUpReviewProfileReducer extends TrueUpReviewProfile {
|
||||
getRequestState: RequestState;
|
||||
}
|
||||
|
||||
export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus {
|
||||
getRequestState: RequestState;
|
||||
}
|
||||
|
||||
export type SelfHostedExpansionRequest = {
|
||||
seats: number;
|
||||
license_id: string;
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user