[CLD-9186] Remove onboarding tasklist, add preview banner (#31203)

* 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á <guillermo.vaya@mattermost.com>

* Fix linter

---------

Co-authored-by: Guillermo Vayá <guillermo.vaya@mattermost.com>
This commit is contained in:
Nick Misasi 2025-06-04 16:24:58 -04:00 committed by GitHub
parent 93d1ec5f1c
commit 91862811f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 471 additions and 5 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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
}

View file

@ -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<Props> {
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<Props> {
cloudTrialEndAnnouncementBar = (
<CloudTrialEndAnnouncementBar/>
);
cloudPreviewAnnouncementBar = (
<CloudPreviewAnnouncementBar/>
);
}
let autoStartTrialModal = null;
@ -109,6 +114,7 @@ class AnnouncementBarController extends React.PureComponent<Props> {
{paymentAnnouncementBar}
{cloudTrialAnnouncementBar}
{cloudTrialEndAnnouncementBar}
{cloudPreviewAnnouncementBar}
{notifyAdminDowngradeDelinquencyBar}
{toYearlyNudgeBannerDismissable}
{this.props.license?.Cloud !== 'true' && <OverageUsersBanner/>}

View file

@ -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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
// 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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
// 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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
// 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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
// 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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
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(
<reactRedux.Provider store={store}>
<CloudPreviewAnnouncementBar/>
</reactRedux.Provider>,
);
expect(wrapper.find('AnnouncementBar').prop('type')).toEqual('advisor');
});
});

View file

@ -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<string>('');
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<HTMLButtonElement>) => {
e.preventDefault();
trackEvent('admin', 'cloud_preview_announcement_bar_contact_sales');
openContactSales();
}, [openContactSales]);
if (!subscription?.is_cloud_preview || !isCloud) {
return null;
}
const message = (
<FormattedMessage
id='announcement_bar.cloud_preview.message'
defaultMessage='This is your Mattermost preview environment. Time left: {timeLeft}'
values={{timeLeft: timeLeft || '00:00'}}
/>
);
const contactSalesText = (
<FormattedMessage
id='announcement_bar.cloud_preview.contact_sales'
defaultMessage='Contact sales'
/>
);
return (
<AnnouncementBar
type={AnnouncementBarTypes.ADVISOR}
showCloseButton={false}
message={message}
icon={<InformationOutlineIcon size={16}/>}
showLinkAsButton={true}
onButtonClick={handleContactSalesClick}
ctaText={contactSalesText}
/>
);
};
export default CloudPreviewAnnouncementBar;

View file

@ -88,8 +88,8 @@ class CloudTrialAnnouncementBarInternal extends React.PureComponent<PropsWithPri
};
shouldShowBanner = () => {
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 = () => {

View file

@ -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 => {
>
<FormattedMessage
id='onboardingTask.checklist.dismiss_link'
defaultMessage='No thanks, Ill figure it out myself'
defaultMessage="No thanks, I'll figure it out myself"
/>
</span>
</>

View file

@ -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}.",

View file

@ -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 = {