From 91862811f503f522e2b676aa669f6a51a3bb02af Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 4 Jun 2025 16:24:58 -0400 Subject: [PATCH] [CLD-9186] Remove onboarding tasklist, add preview banner (#31203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove pricing modal. Adjust everywhere to instead open mattermost.com/pricing. When air gapped, don't show buttons to view plans. * Fix lint * Further clean up of unused code. Fixes for linter * Remove onboarding tasklist for previews, add Cloud previer banner * Fixes for linter, i18n * Revert dev lines * Fix lint * When below one minute, switch to seconds * fix linter * fixes for PR feedback * useExternalLink for opening pricing modal with enriched params * Fix i17n * Fix style, tests * Update webapp/channels/src/components/announcement_bar/cloud_preview_announcement_bar/index.tsx Co-authored-by: Guillermo Vayá * Fix linter --------- Co-authored-by: Guillermo Vayá --- server/channels/api4/cloud.go | 27 ++ server/public/model/cloud.go | 1 + server/public/model/license.go | 5 + .../announcement_bar_controller.tsx | 6 + .../cloud_preview_announcement_bar.test.tsx | 283 ++++++++++++++++++ .../cloud_preview_announcement_bar/index.tsx | 136 +++++++++ .../cloud_trial_announcement_bar.tsx | 4 +- .../onboarding_tasklist.tsx | 11 +- webapp/channels/src/i18n/en.json | 2 + webapp/platform/types/src/cloud.ts | 1 + 10 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 webapp/channels/src/components/announcement_bar/cloud_preview_announcement_bar/cloud_preview_announcement_bar.test.tsx create mode 100644 webapp/channels/src/components/announcement_bar/cloud_preview_announcement_bar/index.tsx diff --git a/server/channels/api4/cloud.go b/server/channels/api4/cloud.go index 02449b965c6..d5afca0dda9 100644 --- a/server/channels/api4/cloud.go +++ b/server/channels/api4/cloud.go @@ -62,7 +62,34 @@ func ensureCloudInterface(c *Context, where string) bool { return true } +func getPreviewSubscription(c *Context, w http.ResponseWriter, r *http.Request) { + license := c.App.Channels().License() + subscription := &model.Subscription{ + ID: "cloud-preview", + ProductID: license.SkuName, + StartAt: license.StartsAt, + TrialEndAt: license.ExpiresAt, + EndAt: license.ExpiresAt, + IsFreeTrial: "true", + IsCloudPreview: true, + } + + json, err := json.Marshal(subscription) + if err != nil { + c.Err = model.NewAppError("Api4.getSubscription", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(json) +} + func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) { + // Preview subscription is a special case for cloud preview licenses. + if c.App.Channels().License().IsCloudPreview() { + getPreviewSubscription(c, w, r) + return + } + ensured := ensureCloudInterface(c, "Api4.getSubscription") if !ensured { return diff --git a/server/public/model/cloud.go b/server/public/model/cloud.go index 0f05827b5e3..5c88a35b127 100644 --- a/server/public/model/cloud.go +++ b/server/public/model/cloud.go @@ -191,6 +191,7 @@ type Subscription struct { CancelAt *int64 `json:"cancel_at"` WillRenew string `json:"will_renew"` SimulatedCurrentTimeMs *int64 `json:"simulated_current_time_ms"` + IsCloudPreview bool `json:"is_cloud_preview"` } func (s *Subscription) DaysToExpiration() int64 { diff --git a/server/public/model/license.go b/server/public/model/license.go index 5bfc4309502..2d88c103856 100644 --- a/server/public/model/license.go +++ b/server/public/model/license.go @@ -350,6 +350,11 @@ func (l *License) IsStarted() bool { return l.StartsAt < GetMillis() } +// Cloud preview is a cloud license, that is also a trial, and the difference between the start and end date is exactly 1 hour. +func (l *License) IsCloudPreview() bool { + return l.IsCloud() && l.IsTrialLicense() && l.ExpiresAt-l.StartsAt == 1*time.Hour.Milliseconds() +} + func (l *License) IsCloud() bool { return l != nil && l.Features != nil && l.Features.Cloud != nil && *l.Features.Cloud } diff --git a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx index 8b6d22a60ad..2a0567746b4 100644 --- a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx +++ b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx @@ -7,6 +7,7 @@ import type {ClientLicense, ClientConfig, WarnMetricStatus} from '@mattermost/ty import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription'; +import CloudPreviewAnnouncementBar from './cloud_preview_announcement_bar'; import CloudTrialAnnouncementBar from './cloud_trial_announcement_bar'; import CloudTrialEndAnnouncementBar from './cloud_trial_ended_announcement_bar'; import ConfigurationAnnouncementBar from './configuration_bar'; @@ -66,6 +67,7 @@ class AnnouncementBarController extends React.PureComponent { let paymentAnnouncementBar = null; let cloudTrialAnnouncementBar = null; let cloudTrialEndAnnouncementBar = null; + let cloudPreviewAnnouncementBar = null; const notifyAdminDowngradeDelinquencyBar = null; const toYearlyNudgeBannerDismissable = null; if (this.props.license?.Cloud === 'true') { @@ -78,6 +80,9 @@ class AnnouncementBarController extends React.PureComponent { cloudTrialEndAnnouncementBar = ( ); + cloudPreviewAnnouncementBar = ( + + ); } let autoStartTrialModal = null; @@ -109,6 +114,7 @@ class AnnouncementBarController extends React.PureComponent { {paymentAnnouncementBar} {cloudTrialAnnouncementBar} {cloudTrialEndAnnouncementBar} + {cloudPreviewAnnouncementBar} {notifyAdminDowngradeDelinquencyBar} {toYearlyNudgeBannerDismissable} {this.props.license?.Cloud !== 'true' && } diff --git a/webapp/channels/src/components/announcement_bar/cloud_preview_announcement_bar/cloud_preview_announcement_bar.test.tsx b/webapp/channels/src/components/announcement_bar/cloud_preview_announcement_bar/cloud_preview_announcement_bar.test.tsx new file mode 100644 index 00000000000..c22fec036df --- /dev/null +++ b/webapp/channels/src/components/announcement_bar/cloud_preview_announcement_bar/cloud_preview_announcement_bar.test.tsx @@ -0,0 +1,283 @@ +// 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 type {Subscription} from '@mattermost/types/cloud'; + +import {mountWithIntl} from 'tests/helpers/intl-test-helper'; +import mockStore from 'tests/test_store'; + +import CloudPreviewAnnouncementBar from './index'; + +describe('components/announcement_bar/CloudPreviewAnnouncementBar', () => { + const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch'); + + beforeEach(() => { + useDispatchMock.mockClear(); + }); + + const baseSubscription: Subscription = { + id: 'test-id', + customer_id: 'test-customer', + product_id: 'test-product', + add_ons: [], + start_at: Date.now() - (24 * 60 * 60 * 1000), // 1 day ago + end_at: Date.now() + (2 * 60 * 60 * 1000), // 2 hours from now + create_at: Date.now() - (24 * 60 * 60 * 1000), + seats: 10, + trial_end_at: 0, + is_free_trial: 'false', + is_cloud_preview: true, + }; + + const initialState = { + views: { + announcementBar: { + announcementBarState: { + announcementBarCount: 1, + }, + }, + }, + entities: { + general: { + license: { + IsLicensed: 'true', + Cloud: 'true', + }, + }, + users: { + currentUserId: 'current_user_id', + profiles: { + current_user_id: {roles: 'system_admin'}, + }, + }, + cloud: { + subscription: baseSubscription, + }, + }, + }; + + it('should show banner when is_cloud_preview is true and isCloud is true', () => { + const store = mockStore(initialState); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + expect(wrapper.find('AnnouncementBar').exists()).toEqual(true); + expect(wrapper.text()).toContain('This is your Mattermost preview environment'); + }); + + it('should not show banner when is_cloud_preview is false', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud.subscription = { + ...baseSubscription, + is_cloud_preview: false, + }; + + const store = mockStore(state); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + expect(wrapper.find('AnnouncementBar').exists()).toEqual(false); + }); + + it('should not show banner when not cloud', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.general.license.Cloud = 'false'; + + const store = mockStore(state); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + expect(wrapper.find('AnnouncementBar').exists()).toEqual(false); + }); + + it('should not show banner when subscription is undefined', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud.subscription = undefined; + + const store = mockStore(state); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + expect(wrapper.find('AnnouncementBar').exists()).toEqual(false); + }); + + it('should display time in correct format when less than a day', () => { + const store = mockStore(initialState); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + // Should show format like "02h 00m" + expect(wrapper.text()).toMatch(/Time left: \d{2}h \d{2}m/); + }); + + it('should display time with days when more than a day', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud.subscription = { + ...baseSubscription, + end_at: Date.now() + (25 * 60 * 60 * 1000), // 25 hours from now + }; + + const store = mockStore(state); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + // Should show format like "1d 01h 00m" + expect(wrapper.text()).toMatch(/Time left: 1d \d{2}h \d{2}m/); + }); + + it('should display only minutes when less than an hour', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud.subscription = { + ...baseSubscription, + end_at: Date.now() + (45 * 60 * 1000), // 45 minutes from now + }; + + const store = mockStore(state); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + // Should show format like "45m" + expect(wrapper.text()).toMatch(/Time left: \d{2}m/); + }); + + it('should display seconds when less than a minute', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud.subscription = { + ...baseSubscription, + end_at: Date.now() + (30 * 1000), // 30 seconds from now + }; + + const store = mockStore(state); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + // Should show format like "30s" + expect(wrapper.text()).toMatch(/Time left: \d+s/); + }); + + it('should display 00:00 when time has expired', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud.subscription = { + ...baseSubscription, + end_at: Date.now() - 1000, // 1 second ago + }; + + const store = mockStore(state); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + expect(wrapper.text()).toContain('Time left: 00:00'); + }); + + it('should not be dismissable', () => { + const store = mockStore(initialState); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + expect(wrapper.find('AnnouncementBar').prop('showCloseButton')).toEqual(false); + }); + + it('should show contact sales button', () => { + const store = mockStore(initialState); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + expect(wrapper.text()).toContain('Contact sales'); + expect(wrapper.find('AnnouncementBar').prop('showLinkAsButton')).toEqual(true); + }); + + it('should have advisor type', () => { + const store = mockStore(initialState); + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + const wrapper = mountWithIntl( + + + , + ); + + expect(wrapper.find('AnnouncementBar').prop('type')).toEqual('advisor'); + }); +}); diff --git a/webapp/channels/src/components/announcement_bar/cloud_preview_announcement_bar/index.tsx b/webapp/channels/src/components/announcement_bar/cloud_preview_announcement_bar/index.tsx new file mode 100644 index 00000000000..c5d64df94d9 --- /dev/null +++ b/webapp/channels/src/components/announcement_bar/cloud_preview_announcement_bar/index.tsx @@ -0,0 +1,136 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState, useCallback} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {InformationOutlineIcon} from '@mattermost/compass-icons/components'; + +import {getCloudSubscription} from 'mattermost-redux/selectors/entities/cloud'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; + +import {trackEvent} from 'actions/telemetry_actions'; + +import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; + +import {AnnouncementBarTypes} from 'utils/constants'; + +import AnnouncementBar from '../default_announcement_bar'; + +const CloudPreviewAnnouncementBar: React.FC = () => { + const subscription = useSelector(getCloudSubscription); + const license = useSelector(getLicense); + const isCloud = license?.Cloud === 'true'; + const [openContactSales] = useOpenSalesLink(); + + const [timeLeft, setTimeLeft] = useState(''); + + const calculateTimeLeft = useCallback(() => { + if (!subscription?.end_at) { + return ''; + } + + const now = Date.now(); + const endTime = subscription.end_at; + const timeDiff = endTime - now; + + if (timeDiff <= 0) { + return '00:00'; + } + + const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((timeDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((timeDiff % (1000 * 60 * 60)) / (1000 * 60)); + + // If less than 1 minute, show seconds + if (days === 0 && hours === 0 && minutes === 0) { + const seconds = Math.floor((timeDiff % (1000 * 60)) / 1000); + return `${seconds}s`; + } + + // Build time string based on what units are needed + const parts = []; + if (days > 0) { + parts.push(`${days}d`); + } + if (hours > 0 || days > 0) { + parts.push(`${hours.toString().padStart(2, '0')}h`); + } + parts.push(`${minutes.toString().padStart(2, '0')}m`); + + return parts.join(' '); + }, [subscription?.end_at]); + + useEffect(() => { + if (!subscription?.is_cloud_preview || !isCloud) { + return undefined; + } + + let interval: NodeJS.Timeout; + + const updateTimeAndScheduleNext = () => { + setTimeLeft(calculateTimeLeft()); + + // Calculate time remaining to determine next interval + const now = Date.now(); + const endTime = subscription.end_at || 0; + const timeDiff = endTime - now; + const minutesLeft = Math.floor(timeDiff / (1000 * 60)); + + // Use 1 second interval if less than 1 minute remains, otherwise 60 seconds + const intervalTime = minutesLeft < 1 ? 1000 : 60000; + + // Schedule the next update + interval = setTimeout(updateTimeAndScheduleNext, intervalTime); + }; + + // Start the update cycle + updateTimeAndScheduleNext(); + + return () => { + if (interval) { + clearTimeout(interval); + } + }; + }, [subscription, isCloud, calculateTimeLeft]); + + const handleContactSalesClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + trackEvent('admin', 'cloud_preview_announcement_bar_contact_sales'); + openContactSales(); + }, [openContactSales]); + + if (!subscription?.is_cloud_preview || !isCloud) { + return null; + } + + const message = ( + + ); + + const contactSalesText = ( + + ); + + return ( + } + showLinkAsButton={true} + onButtonClick={handleContactSalesClick} + ctaText={contactSalesText} + /> + ); +}; + +export default CloudPreviewAnnouncementBar; diff --git a/webapp/channels/src/components/announcement_bar/cloud_trial_announcement_bar/cloud_trial_announcement_bar.tsx b/webapp/channels/src/components/announcement_bar/cloud_trial_announcement_bar/cloud_trial_announcement_bar.tsx index 1e5757a7c50..193d2e0c0b5 100644 --- a/webapp/channels/src/components/announcement_bar/cloud_trial_announcement_bar/cloud_trial_announcement_bar.tsx +++ b/webapp/channels/src/components/announcement_bar/cloud_trial_announcement_bar/cloud_trial_announcement_bar.tsx @@ -88,8 +88,8 @@ class CloudTrialAnnouncementBarInternal extends React.PureComponent { - const {isFreeTrial, userIsAdmin, isCloud, isAirGapped} = this.props; - return isFreeTrial && userIsAdmin && isCloud && !isAirGapped; + const {isFreeTrial, userIsAdmin, isCloud, isAirGapped, subscription} = this.props; + return isFreeTrial && userIsAdmin && isCloud && !isAirGapped && !subscription?.is_cloud_preview; }; isDismissable = () => { diff --git a/webapp/channels/src/components/onboarding_tasklist/onboarding_tasklist.tsx b/webapp/channels/src/components/onboarding_tasklist/onboarding_tasklist.tsx index 7975066e93e..89903f40f44 100644 --- a/webapp/channels/src/components/onboarding_tasklist/onboarding_tasklist.tsx +++ b/webapp/channels/src/components/onboarding_tasklist/onboarding_tasklist.tsx @@ -10,7 +10,8 @@ import {CloseIcon, PlaylistCheckIcon} from '@mattermost/compass-icons/components import {getPrevTrialLicense} from 'mattermost-redux/actions/admin'; import {getMyPreferences, savePreferences} from 'mattermost-redux/actions/preferences'; -import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getCloudSubscription} from 'mattermost-redux/selectors/entities/cloud'; +import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; import { getBool, getMyPreferences as getMyPreferencesSelector, @@ -128,6 +129,10 @@ const Button = styled.button<{open: boolean}>(({open}) => { const OnBoardingTaskList = (): JSX.Element | null => { const {formatMessage} = useIntl(); const hasPreferences = useSelector((state: GlobalState) => Object.keys(getMyPreferencesSelector(state)).length !== 0); + const subscription = useSelector(getCloudSubscription); + const license = useSelector(getLicense); + const isCloud = license?.Cloud === 'true'; + const isCloudPreview = subscription?.is_cloud_preview === true; useEffect(() => { dispatch(getPrevTrialLicense()); @@ -244,7 +249,7 @@ const OnBoardingTaskList = (): JSX.Element | null => { trackEvent(OnboardingTaskCategory, open ? OnboardingTaskList.ONBOARDING_TASK_LIST_CLOSE : OnboardingTaskList.ONBOARDING_TASK_LIST_OPEN); }, [open, currentUserId]); - if (!hasPreferences || !showTaskList || !isEnableOnboardingFlow) { + if (!hasPreferences || !showTaskList || !isEnableOnboardingFlow || (isCloud && isCloudPreview)) { return null; } @@ -303,7 +308,7 @@ const OnBoardingTaskList = (): JSX.Element | null => { > diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 7493ec2218c..5a073249016 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3148,6 +3148,8 @@ "analytics.team.totalUsers": "Total Activated Users", "analytics.team.totalUsers.title.tooltip.hint": "Also called Registered Users", "analytics.team.totalUsers.title.tooltip.title": "Activated users on this server", + "announcement_bar.cloud_preview.contact_sales": "Contact sales", + "announcement_bar.cloud_preview.message": "This is your Mattermost preview environment. Time left: {timeLeft}", "announcement_bar.error.email_verification_required": "Check your email inbox to verify the address.", "announcement_bar.error.license_expired": "{licenseSku} license is expired and some features may be disabled.", "announcement_bar.error.license_expiring": "{licenseSku} license expires on {date, date, long}.", diff --git a/webapp/platform/types/src/cloud.ts b/webapp/platform/types/src/cloud.ts index 07705d9c545..ba7ceadd464 100644 --- a/webapp/platform/types/src/cloud.ts +++ b/webapp/platform/types/src/cloud.ts @@ -47,6 +47,7 @@ export type Subscription = { cancel_at?: number; will_renew?: string; simulated_current_time_ms?: number; + is_cloud_preview?: boolean; } export type Product = {