diff --git a/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx b/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx index d22fe6389f..4ad8d35acf 100644 --- a/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx +++ b/webapp/channels/src/components/announcement_bar/overage_users_banner/index.tsx @@ -16,9 +16,12 @@ import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences'; import {PreferenceType} from '@mattermost/types/preferences'; import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck'; import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; -import {StatTypes, Preferences, AnnouncementBarTypes} from 'utils/constants'; +import {StatTypes, Preferences, AnnouncementBarTypes, ConsolePages} from 'utils/constants'; 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 = { preferenceName: string; @@ -53,6 +56,9 @@ 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}`; @@ -72,6 +78,7 @@ const OverageUsersBanner = () => { licenseId: license.Id, isWarningState: isBetween5PercerntAnd10PercentPurchasedSeats, banner: 'global banner', + canSelfHostedExpand: canSelfHostedExpand || false, }); const handleClose = () => { @@ -86,6 +93,12 @@ const OverageUsersBanner = () => { const handleUpdateSeatsSelfServeClick = (e: React.MouseEvent) => { e.preventDefault(); trackEventFn('Self Serve'); + + if (canSelfHostedExpand) { + window.open(`${siteURL}/${ConsolePages.LICENSE}?action=show_expansion_modal`); + return; + } + window.open(expandableLink(license.Id), '_blank'); }; @@ -101,7 +114,7 @@ const OverageUsersBanner = () => { return null; } - const message = ( + let message = ( { }} />); + if (canSelfHostedExpand) { + message = ( + ); + } + return ( { getRequestState: 'IDLE', }, }, + hostedCustomer: { + products: { + productsLoaded: true, + products: { + prod_professional: TestHelper.getProductMock({ + id: 'prod_professional', + name: 'Professional', + sku: SelfHostedProducts.PROFESSIONAL, + price_per_seat: 7.5, + }), + }, + }, + }, }, }; diff --git a/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts b/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts index 93f2d2092e..1488713947 100644 --- a/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts +++ b/webapp/channels/src/components/common/hooks/useCanSelfHostedExpand.ts @@ -16,7 +16,6 @@ import {findSelfHostedProductBySku} from 'utils/hosted_customer'; import useGetSelfHostedProducts from './useGetSelfHostedProducts'; export default function useCanSelfHostedExpand() { - // NOTE: This is a basic implementation to get things up and running, more details to come later. const [expansionAvailable, setExpansionAvailable] = useState(false); const config = useSelector(getConfig); const isEnterpriseReady = config.BuildEnterpriseReady === 'true'; diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts index a310c4538e..b16eeb187f 100644 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts @@ -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 {useControlModal, ControlModal} from './useControlModal'; -import useCanSelfHostedExpand from './useCanSelfHostedExpand'; interface HookOptions{ trackingLocation?: string; @@ -25,7 +24,6 @@ interface HookOptions{ export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal { const dispatch = useDispatch(); const currentUser = useSelector(getCurrentUser); - const canExpand = useCanSelfHostedExpand(); const controlModal = useControlModal({ modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, dialogType: SelfHostedExpansionModal, @@ -35,10 +33,6 @@ export default function useControlSelfHostedExpansionModal(options: HookOptions) return { ...controlModal, open: async () => { - if (!canExpand) { - return; - } - const purchaseInProgress = localStorage.getItem(STORAGE_KEY_EXPANSION_IN_PROGRESS) === 'true'; // check if user already has an open purchase modal in current browser. diff --git a/webapp/channels/src/components/common/hooks/useExpandOverageUsersCheck.ts b/webapp/channels/src/components/common/hooks/useExpandOverageUsersCheck.ts index 7caf23f2bf..6ef7bf34b6 100644 --- a/webapp/channels/src/components/common/hooks/useExpandOverageUsersCheck.ts +++ b/webapp/channels/src/components/common/hooks/useExpandOverageUsersCheck.ts @@ -18,6 +18,7 @@ type UseExpandOverageUsersCheckArgs = { shouldRequest: boolean; licenseId?: string; banner: 'global banner' | 'invite modal'; + canSelfHostedExpand: boolean; } export const useExpandOverageUsersCheck = ({ @@ -25,20 +26,30 @@ export const useExpandOverageUsersCheck = ({ 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(() => (isExpandable ? formatMessage({ - id: 'licensingPage.overageUsersBanner.ctaExpandSeats', - defaultMessage: 'Purchase additional seats', - }) : formatMessage({ - id: 'licensingPage.overageUsersBanner.cta', - defaultMessage: 'Contact Sales', - }) - ), [isExpandable]); + 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 trackEventFn = (cta: 'Contact Sales' | 'Self Serve') => { trackEvent('insights', isWarningState ? 'click_true_up_warning' : 'click_true_up_error', { diff --git a/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx b/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx index 0db5a75825..80c5226eec 100644 --- a/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx +++ b/webapp/channels/src/components/invitation_modal/invitation_modal.test.tsx @@ -17,6 +17,8 @@ import ResultView from './result_view'; import InviteView from './invite_view'; import NoPermissionsView from './no_permissions_view'; 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({ actions: { @@ -87,6 +89,19 @@ describe('InvitationModal', () => { preferences: { myPreferences: {}, }, + hostedCustomer: { + products: { + productsLoaded: true, + products: { + prod_professional: TestHelper.getProductMock({ + id: 'prod_professional', + name: 'Professional', + sku: SelfHostedProducts.PROFESSIONAL, + price_per_seat: 7.5, + }), + }, + }, + }, }, }; diff --git a/webapp/channels/src/components/invitation_modal/invite_view.test.tsx b/webapp/channels/src/components/invitation_modal/invite_view.test.tsx index b90af49861..16e49e8f88 100644 --- a/webapp/channels/src/components/invitation_modal/invite_view.test.tsx +++ b/webapp/channels/src/components/invitation_modal/invite_view.test.tsx @@ -11,9 +11,12 @@ import {mountWithThemedIntl} from 'tests/helpers/themed-intl-test-helper'; import deepFreeze from 'mattermost-redux/utils/deep_freeze'; import {Team} from '@mattermost/types/teams'; import {generateId} from 'utils/utils'; +import {TestHelper as TH} from 'utils/test_helper'; import InviteAs, {InviteType} from './invite_as'; import InviteView, {Props} from './invite_view'; +import {SelfHostedProducts} from 'utils/constants'; +import {act} from 'react-dom/test-utils'; const defaultProps: Props = deepFreeze({ setInviteAs: jest.fn(), @@ -99,6 +102,19 @@ describe('InviteView', () => { preferences: { 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; }); - it('shows InviteAs component when user can choose to invite guests or users', () => { - const wrapper = mountWithThemedIntl( - - - , - ); - expect(wrapper.find(InviteAs).length).toBe(1); + it('shows InviteAs component when user can choose to invite guests or users', async () => { + await act(async () => { + const wrapper = mountWithThemedIntl( + + + , + ); + 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 = { ...defaultProps, canAddUsers: false, }; - const wrapper = mountWithThemedIntl( - - - , - ); - expect(wrapper.find(InviteAs).length).toBe(0); + await act(async () => { + const wrapper = mountWithThemedIntl( + + + , + ); + 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 = { ...defaultProps, canInviteGuests: false, }; - const wrapper = mountWithThemedIntl( - - - , - ); - expect(wrapper.find(InviteAs).length).toBe(0); + await act(async () => { + const wrapper = mountWithThemedIntl( + + + , + ); + expect(wrapper.find(InviteAs).length).toBe(0); + }); }); }); diff --git a/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx b/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx index ce1cd6a1c3..fb6c1968a7 100644 --- a/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx +++ b/webapp/channels/src/components/invitation_modal/overage_users_banner_notice/index.tsx @@ -16,10 +16,13 @@ import {savePreferences} from 'mattermost-redux/actions/preferences'; import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences'; import {PreferenceType} from '@mattermost/types/preferences'; import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck'; -import {LicenseLinks, StatTypes, Preferences} from 'utils/constants'; +import {LicenseLinks, StatTypes, Preferences, ConsolePages} from 'utils/constants'; import './overage_users_banner_notice.scss'; 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 = { preferenceName: string; @@ -42,6 +45,10 @@ 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, isOver10PercerntPurchasedSeats, @@ -67,6 +74,7 @@ const OverageUsersBannerNotice = () => { licenseId: license.Id, isWarningState: isBetween5PercerntAnd10PercentPurchasedSeats, banner: 'invite modal', + canSelfHostedExpand: canSelfHostedExpand || false, }); if (!hasPermission || adminHasDismissed({overagePreferences, preferenceName})) { @@ -83,7 +91,27 @@ const OverageUsersBannerNotice = () => { }; let message; - if (!isGovSku) { + + if (canSelfHostedExpand) { + message = ( + Purchase additional seats to remain compliant.'} + values={{ + a: (chunks: React.ReactNode) => { + return ( + + {chunks} + + ); + }, + }} + /> + ); + } else if (!isGovSku) { message = ( { 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)); store.entities.admin = { @@ -502,8 +515,10 @@ describe('components/invitation_modal/overage_users_banner_notice', () => { }; store.entities.general.license.IsGovSku = 'true'; - renderComponent({ - store, + await act(async () => { + renderComponent({ + store, + }); }); screen.getByText(text10PercentageState); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index f8591d75dd..ace9ab4b66 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4057,9 +4057,12 @@ "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. ", "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": "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.remove_link_preview": "Remove link preview", "list_modal.paginatorCount": "{startCount, number} - {endCount, number} of {total, number} total",