Merge pull request #22969 from mattermost/MM-51456-update-overage-notices

MM-51456 - Update Overage Notices
Этот коммит содержится в:
Conor Macpherson
2023-04-28 09:02:51 -04:00
коммит произвёл GitHub
родитель 3db6bb016e bd7752b961
Коммит 388645ea33
10 изменённых файлов: 170 добавлений и 46 удалений

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

@@ -16,9 +16,12 @@ import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences';
import {PreferenceType} from '@mattermost/types/preferences'; import {PreferenceType} from '@mattermost/types/preferences';
import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck'; import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {StatTypes, Preferences, AnnouncementBarTypes} from 'utils/constants'; import {StatTypes, Preferences, AnnouncementBarTypes, ConsolePages} from 'utils/constants';
import './overage_users_banner.scss'; import './overage_users_banner.scss';
import {getSiteURL} from 'utils/url';
import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
type AdminHasDismissedItArgs = { type AdminHasDismissedItArgs = {
preferenceName: string; preferenceName: string;
@@ -53,6 +56,9 @@ const OverageUsersBanner = () => {
activeUsers, activeUsers,
seatsPurchased, seatsPurchased,
}); });
const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
const canSelfHostedExpand = useCanSelfHostedExpand() && isSelfHostedExpansionEnabled;
const siteURL = getSiteURL();
const prefixPreferences = isOver10PercerntPurchasedSeats ? 'error' : 'warn'; const prefixPreferences = isOver10PercerntPurchasedSeats ? 'error' : 'warn';
const prefixLicenseId = (license.Id || '').substring(0, 8); const prefixLicenseId = (license.Id || '').substring(0, 8);
const preferenceName = `${prefixPreferences}_overage_seats_${prefixLicenseId}`; const preferenceName = `${prefixPreferences}_overage_seats_${prefixLicenseId}`;
@@ -72,6 +78,7 @@ const OverageUsersBanner = () => {
licenseId: license.Id, licenseId: license.Id,
isWarningState: isBetween5PercerntAnd10PercentPurchasedSeats, isWarningState: isBetween5PercerntAnd10PercentPurchasedSeats,
banner: 'global banner', banner: 'global banner',
canSelfHostedExpand: canSelfHostedExpand || false,
}); });
const handleClose = () => { const handleClose = () => {
@@ -86,6 +93,12 @@ const OverageUsersBanner = () => {
const handleUpdateSeatsSelfServeClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { const handleUpdateSeatsSelfServeClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault(); e.preventDefault();
trackEventFn('Self Serve'); trackEventFn('Self Serve');
if (canSelfHostedExpand) {
window.open(`${siteURL}/${ConsolePages.LICENSE}?action=show_expansion_modal`);
return;
}
window.open(expandableLink(license.Id), '_blank'); window.open(expandableLink(license.Id), '_blank');
}; };
@@ -101,7 +114,7 @@ const OverageUsersBanner = () => {
return null; return null;
} }
const message = ( let message = (
<FormattedMessage <FormattedMessage
id='licensingPage.overageUsersBanner.text' id='licensingPage.overageUsersBanner.text'
defaultMessage='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.' defaultMessage='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.'
@@ -110,6 +123,17 @@ const OverageUsersBanner = () => {
}} }}
/>); />);
if (canSelfHostedExpand) {
message = (
<FormattedMessage
id='licensingPage.overageUsersBanner.textSelfHostedExpand'
defaultMessage='Your workspace user count has exceeded your paid license seat count. Update your seat count to stay compliant.'
values={{
seats: overageByUsers,
}}
/>);
}
return ( return (
<AnnouncementBar <AnnouncementBar
type={isBetween5PercerntAnd10PercentPurchasedSeats ? AnnouncementBarTypes.ADVISOR : AnnouncementBarTypes.CRITICAL} type={isBetween5PercerntAnd10PercentPurchasedSeats ? AnnouncementBarTypes.ADVISOR : AnnouncementBarTypes.CRITICAL}

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

@@ -7,7 +7,7 @@ import {fireEvent, screen} from '@testing-library/react';
import {DeepPartial} from '@mattermost/types/utilities'; import {DeepPartial} from '@mattermost/types/utilities';
import {GlobalState} from 'types/store'; import {GlobalState} from 'types/store';
import {General} from 'mattermost-redux/constants'; import {General} from 'mattermost-redux/constants';
import {OverActiveUserLimits, Preferences, StatTypes} from 'utils/constants'; import {OverActiveUserLimits, Preferences, SelfHostedProducts, StatTypes} from 'utils/constants';
import {renderWithIntlAndStore} from 'tests/react_testing_utils'; import {renderWithIntlAndStore} from 'tests/react_testing_utils';
import {savePreferences} from 'mattermost-redux/actions/preferences'; import {savePreferences} from 'mattermost-redux/actions/preferences';
import {trackEvent} from 'actions/telemetry_actions'; import {trackEvent} from 'actions/telemetry_actions';
@@ -107,6 +107,19 @@ describe('components/overage_users_banner', () => {
getRequestState: 'IDLE', getRequestState: 'IDLE',
}, },
}, },
hostedCustomer: {
products: {
productsLoaded: true,
products: {
prod_professional: TestHelper.getProductMock({
id: 'prod_professional',
name: 'Professional',
sku: SelfHostedProducts.PROFESSIONAL,
price_per_seat: 7.5,
}),
},
},
},
}, },
}; };

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

@@ -16,7 +16,6 @@ import {findSelfHostedProductBySku} from 'utils/hosted_customer';
import useGetSelfHostedProducts from './useGetSelfHostedProducts'; import useGetSelfHostedProducts from './useGetSelfHostedProducts';
export default function useCanSelfHostedExpand() { export default function useCanSelfHostedExpand() {
// NOTE: This is a basic implementation to get things up and running, more details to come later.
const [expansionAvailable, setExpansionAvailable] = useState(false); const [expansionAvailable, setExpansionAvailable] = useState(false);
const config = useSelector(getConfig); const config = useSelector(getConfig);
const isEnterpriseReady = config.BuildEnterpriseReady === 'true'; const isEnterpriseReady = config.BuildEnterpriseReady === 'true';

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

@@ -16,7 +16,6 @@ import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_purchase
import SelfHostedExpansionModal from 'components/self_hosted_purchases/self_hosted_expansion_modal'; import SelfHostedExpansionModal from 'components/self_hosted_purchases/self_hosted_expansion_modal';
import {useControlModal, ControlModal} from './useControlModal'; import {useControlModal, ControlModal} from './useControlModal';
import useCanSelfHostedExpand from './useCanSelfHostedExpand';
interface HookOptions{ interface HookOptions{
trackingLocation?: string; trackingLocation?: string;
@@ -25,7 +24,6 @@ interface HookOptions{
export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal { export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal {
const dispatch = useDispatch(); const dispatch = useDispatch();
const currentUser = useSelector(getCurrentUser); const currentUser = useSelector(getCurrentUser);
const canExpand = useCanSelfHostedExpand();
const controlModal = useControlModal({ const controlModal = useControlModal({
modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION,
dialogType: SelfHostedExpansionModal, dialogType: SelfHostedExpansionModal,
@@ -35,10 +33,6 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions)
return { return {
...controlModal, ...controlModal,
open: async () => { open: async () => {
if (!canExpand) {
return;
}
const purchaseInProgress = localStorage.getItem(STORAGE_KEY_EXPANSION_IN_PROGRESS) === 'true'; const purchaseInProgress = localStorage.getItem(STORAGE_KEY_EXPANSION_IN_PROGRESS) === 'true';
// check if user already has an open purchase modal in current browser. // check if user already has an open purchase modal in current browser.

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

@@ -18,6 +18,7 @@ type UseExpandOverageUsersCheckArgs = {
shouldRequest: boolean; shouldRequest: boolean;
licenseId?: string; licenseId?: string;
banner: 'global banner' | 'invite modal'; banner: 'global banner' | 'invite modal';
canSelfHostedExpand: boolean;
} }
export const useExpandOverageUsersCheck = ({ export const useExpandOverageUsersCheck = ({
@@ -25,20 +26,30 @@ export const useExpandOverageUsersCheck = ({
isWarningState, isWarningState,
licenseId, licenseId,
banner, banner,
canSelfHostedExpand,
}: UseExpandOverageUsersCheckArgs) => { }: UseExpandOverageUsersCheckArgs) => {
const {formatMessage} = useIntl(); const {formatMessage} = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const {getRequestState, is_expandable: isExpandable}: LicenseSelfServeStatusReducer = useSelector((state: GlobalState) => state.entities.cloud.subscriptionStats || {is_expandable: false, getRequestState: 'IDLE'}); const {getRequestState, is_expandable: isExpandable}: LicenseSelfServeStatusReducer = useSelector((state: GlobalState) => state.entities.cloud.subscriptionStats || {is_expandable: false, getRequestState: 'IDLE'});
const expandableLink = useSelector(getExpandSeatsLink); const expandableLink = useSelector(getExpandSeatsLink);
const cta = useMemo(() => (isExpandable ? formatMessage({ const cta = useMemo(() => {
id: 'licensingPage.overageUsersBanner.ctaExpandSeats', if (isExpandable && !canSelfHostedExpand) {
defaultMessage: 'Purchase additional seats', return formatMessage({
}) : formatMessage({ id: 'licensingPage.overageUsersBanner.ctaExpandSeats',
id: 'licensingPage.overageUsersBanner.cta', defaultMessage: 'Purchase additional seats',
defaultMessage: 'Contact Sales', });
}) } else if (isExpandable && canSelfHostedExpand) {
), [isExpandable]); return formatMessage({
id: 'licensingPage.overageUsersBanner.ctaUpdateSeats',
defaultMessage: 'Update seat count',
});
}
return formatMessage({
id: 'licensingPage.overageUsersBanner.cta',
defaultMessage: 'Contact Sales',
});
}, [isExpandable]);
const trackEventFn = (cta: 'Contact Sales' | 'Self Serve') => { const trackEventFn = (cta: 'Contact Sales' | 'Self Serve') => {
trackEvent('insights', isWarningState ? 'click_true_up_warning' : 'click_true_up_error', { trackEvent('insights', isWarningState ? 'click_true_up_warning' : 'click_true_up_error', {

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

@@ -17,6 +17,8 @@ import ResultView from './result_view';
import InviteView from './invite_view'; import InviteView from './invite_view';
import NoPermissionsView from './no_permissions_view'; import NoPermissionsView from './no_permissions_view';
import InvitationModal, {Props, View, InvitationModal as BaseInvitationModal} from './invitation_modal'; import InvitationModal, {Props, View, InvitationModal as BaseInvitationModal} from './invitation_modal';
import {SelfHostedProducts} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
const defaultProps: Props = deepFreeze({ const defaultProps: Props = deepFreeze({
actions: { actions: {
@@ -87,6 +89,19 @@ describe('InvitationModal', () => {
preferences: { preferences: {
myPreferences: {}, myPreferences: {},
}, },
hostedCustomer: {
products: {
productsLoaded: true,
products: {
prod_professional: TestHelper.getProductMock({
id: 'prod_professional',
name: 'Professional',
sku: SelfHostedProducts.PROFESSIONAL,
price_per_seat: 7.5,
}),
},
},
},
}, },
}; };

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

@@ -11,9 +11,12 @@ import {mountWithThemedIntl} from 'tests/helpers/themed-intl-test-helper';
import deepFreeze from 'mattermost-redux/utils/deep_freeze'; import deepFreeze from 'mattermost-redux/utils/deep_freeze';
import {Team} from '@mattermost/types/teams'; import {Team} from '@mattermost/types/teams';
import {generateId} from 'utils/utils'; import {generateId} from 'utils/utils';
import {TestHelper as TH} from 'utils/test_helper';
import InviteAs, {InviteType} from './invite_as'; import InviteAs, {InviteType} from './invite_as';
import InviteView, {Props} from './invite_view'; import InviteView, {Props} from './invite_view';
import {SelfHostedProducts} from 'utils/constants';
import {act} from 'react-dom/test-utils';
const defaultProps: Props = deepFreeze({ const defaultProps: Props = deepFreeze({
setInviteAs: jest.fn(), setInviteAs: jest.fn(),
@@ -99,6 +102,19 @@ describe('InviteView', () => {
preferences: { preferences: {
myPreferences: {}, myPreferences: {},
}, },
hostedCustomer: {
products: {
productsLoaded: true,
products: {
prod_professional: TH.getProductMock({
id: 'prod_professional',
name: 'Professional',
sku: SelfHostedProducts.PROFESSIONAL,
price_per_seat: 7.5,
}),
},
},
},
}, },
}; };
@@ -108,40 +124,46 @@ describe('InviteView', () => {
props = defaultProps; props = defaultProps;
}); });
it('shows InviteAs component when user can choose to invite guests or users', () => { it('shows InviteAs component when user can choose to invite guests or users', async () => {
const wrapper = mountWithThemedIntl( await act(async () => {
<Provider store={store}> const wrapper = mountWithThemedIntl(
<InviteView {...props}/> <Provider store={store}>
</Provider>, <InviteView {...props}/>
); </Provider>,
expect(wrapper.find(InviteAs).length).toBe(1); );
expect(wrapper.find(InviteAs).length).toBe(1);
});
}); });
it('hides InviteAs component when user can not choose members option', () => { it('hides InviteAs component when user can not choose members option', async () => {
props = { props = {
...defaultProps, ...defaultProps,
canAddUsers: false, canAddUsers: false,
}; };
const wrapper = mountWithThemedIntl( await act(async () => {
<Provider store={store}> const wrapper = mountWithThemedIntl(
<InviteView {...props}/> <Provider store={store}>
</Provider>, <InviteView {...props}/>
); </Provider>,
expect(wrapper.find(InviteAs).length).toBe(0); );
expect(wrapper.find(InviteAs).length).toBe(0);
});
}); });
it('hides InviteAs component when user can not choose guests option', () => { it('hides InviteAs component when user can not choose guests option', async () => {
props = { props = {
...defaultProps, ...defaultProps,
canInviteGuests: false, canInviteGuests: false,
}; };
const wrapper = mountWithThemedIntl( await act(async () => {
<Provider store={store}> const wrapper = mountWithThemedIntl(
<InviteView {...props}/> <Provider store={store}>
</Provider>, <InviteView {...props}/>
); </Provider>,
expect(wrapper.find(InviteAs).length).toBe(0); );
expect(wrapper.find(InviteAs).length).toBe(0);
});
}); });
}); });

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

@@ -16,10 +16,13 @@ import {savePreferences} from 'mattermost-redux/actions/preferences';
import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences'; import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences';
import {PreferenceType} from '@mattermost/types/preferences'; import {PreferenceType} from '@mattermost/types/preferences';
import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck'; import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck';
import {LicenseLinks, StatTypes, Preferences} from 'utils/constants'; import {LicenseLinks, StatTypes, Preferences, ConsolePages} from 'utils/constants';
import './overage_users_banner_notice.scss'; import './overage_users_banner_notice.scss';
import ExternalLink from 'components/external_link'; import ExternalLink from 'components/external_link';
import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
import {getSiteURL} from 'utils/url';
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
type AdminHasDismissedArgs = { type AdminHasDismissedArgs = {
preferenceName: string; preferenceName: string;
@@ -42,6 +45,10 @@ const OverageUsersBannerNotice = () => {
const currentUser = useSelector((state: GlobalState) => getCurrentUser(state)); const currentUser = useSelector((state: GlobalState) => getCurrentUser(state));
const overagePreferences = useSelector((state: GlobalState) => getPreferencesCategory(state, Preferences.OVERAGE_USERS_BANNER)); const overagePreferences = useSelector((state: GlobalState) => getPreferencesCategory(state, Preferences.OVERAGE_USERS_BANNER));
const activeUsers = ((stats || {})[StatTypes.TOTAL_USERS]) as number || 0; const activeUsers = ((stats || {})[StatTypes.TOTAL_USERS]) as number || 0;
const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
const canSelfHostedExpand = useCanSelfHostedExpand() && isSelfHostedPurchaseEnabled;
const siteURL = getSiteURL();
const { const {
isBetween5PercerntAnd10PercentPurchasedSeats, isBetween5PercerntAnd10PercentPurchasedSeats,
isOver10PercerntPurchasedSeats, isOver10PercerntPurchasedSeats,
@@ -67,6 +74,7 @@ const OverageUsersBannerNotice = () => {
licenseId: license.Id, licenseId: license.Id,
isWarningState: isBetween5PercerntAnd10PercentPurchasedSeats, isWarningState: isBetween5PercerntAnd10PercentPurchasedSeats,
banner: 'invite modal', banner: 'invite modal',
canSelfHostedExpand: canSelfHostedExpand || false,
}); });
if (!hasPermission || adminHasDismissed({overagePreferences, preferenceName})) { if (!hasPermission || adminHasDismissed({overagePreferences, preferenceName})) {
@@ -83,7 +91,27 @@ const OverageUsersBannerNotice = () => {
}; };
let message; let message;
if (!isGovSku) {
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) {
message = ( message = (
<FormattedMessage <FormattedMessage
id='licensingPage.overageUsersBanner.noticeDescription' id='licensingPage.overageUsersBanner.noticeDescription'

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

@@ -2,12 +2,12 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import React from 'react';
import {fireEvent, screen} from '@testing-library/react'; import {act, fireEvent, screen} from '@testing-library/react';
import {DeepPartial} from '@mattermost/types/utilities'; import {DeepPartial} from '@mattermost/types/utilities';
import {GlobalState} from 'types/store'; import {GlobalState} from 'types/store';
import {General} from 'mattermost-redux/constants'; import {General} from 'mattermost-redux/constants';
import {LicenseLinks, OverActiveUserLimits, Preferences, StatTypes} from 'utils/constants'; import {LicenseLinks, OverActiveUserLimits, Preferences, SelfHostedProducts, StatTypes} from 'utils/constants';
import {renderWithIntlAndStore} from 'tests/react_testing_utils'; import {renderWithIntlAndStore} from 'tests/react_testing_utils';
import {savePreferences} from 'mattermost-redux/actions/preferences'; import {savePreferences} from 'mattermost-redux/actions/preferences';
import {trackEvent} from 'actions/telemetry_actions'; import {trackEvent} from 'actions/telemetry_actions';
@@ -93,6 +93,19 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
getRequestState: 'IDLE', getRequestState: 'IDLE',
}, },
}, },
hostedCustomer: {
products: {
productsLoaded: true,
products: {
prod_professional: TestHelper.getProductMock({
id: 'prod_professional',
name: 'Professional',
sku: SelfHostedProducts.PROFESSIONAL,
price_per_seat: 7.5,
}),
},
},
},
}, },
}; };
@@ -483,7 +496,7 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
}); });
}); });
it('gov sku sees overage notice but not a call to do true up', () => { it('gov sku sees overage notice but not a call to do true up', async () => {
const store: GlobalState = JSON.parse(JSON.stringify(initialState)); const store: GlobalState = JSON.parse(JSON.stringify(initialState));
store.entities.admin = { store.entities.admin = {
@@ -502,8 +515,10 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
}; };
store.entities.general.license.IsGovSku = 'true'; store.entities.general.license.IsGovSku = 'true';
renderComponent({ await act(async () => {
store, renderComponent({
store,
});
}); });
screen.getByText(text10PercentageState); screen.getByText(text10PercentageState);

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

@@ -4057,9 +4057,12 @@
"licensingPage.infoBanner.startTrialTitle": "Free 30 day trial!", "licensingPage.infoBanner.startTrialTitle": "Free 30 day trial!",
"licensingPage.overageUsersBanner.cta": "Contact Sales", "licensingPage.overageUsersBanner.cta": "Contact Sales",
"licensingPage.overageUsersBanner.ctaExpandSeats": "Purchase additional seats", "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.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.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": "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.text": "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": "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.image_preview": "Show Image preview",
"link_preview.remove_link_preview": "Remove link preview", "link_preview.remove_link_preview": "Remove link preview",
"list_modal.paginatorCount": "{startCount, number} - {endCount, number} of {total, number} total", "list_modal.paginatorCount": "{startCount, number} - {endCount, number} of {total, number} total",