Improve parallelization of loadMeAndConfig action (#27858)

1. Rewrite of loadConfigAndMe function which includes compatible return types along with less blocking redux actions
2. Actions of webapp/channels/src/components/root/actions.ts moved to webapp/channels/src/actions/views/root.ts for consistency
3. Removes 2 redundant network requests for config and license.
Этот коммит содержится в:
M-ZubairAhmed
2024-08-26 08:40:35 +00:00
коммит произвёл GitHub
родитель daff7a39c5
Коммит 15c9b15f67
17 изменённых файлов: 516 добавлений и 573 удалений

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

@@ -7,23 +7,6 @@ import * as i18nSelectors from 'selectors/i18n';
import mockStore from 'tests/test_store';
import {ActionTypes} from 'utils/constants';
jest.mock('mattermost-redux/actions/general', () => {
const original = jest.requireActual('mattermost-redux/actions/general');
return {
...original,
getClientConfig: () => ({type: 'MOCK_GET_CLIENT_CONFIG'}),
getLicenseConfig: () => ({type: 'MOCK_GET_LICENSE_CONFIG'}),
};
});
jest.mock('mattermost-redux/actions/users', () => {
const original = jest.requireActual('mattermost-redux/actions/users');
return {
...original,
loadMe: () => ({type: 'MOCK_LOAD_ME'}),
};
});
describe('root view actions', () => {
const origCookies = document.cookie;
const origWasLoggedIn = localStorage.getItem('was_logged_in');
@@ -38,25 +21,6 @@ describe('root view actions', () => {
localStorage.setItem('was_logged_in', origWasLoggedIn || '');
});
describe('loadConfigAndMe', () => {
test('loadConfigAndMe, without user logged in', async () => {
const testStore = mockStore({});
await testStore.dispatch(Actions.loadConfigAndMe());
expect(testStore.getActions()).toEqual([{type: 'MOCK_GET_CLIENT_CONFIG'}, {type: 'MOCK_GET_LICENSE_CONFIG'}]);
});
test('loadConfigAndMe, with user logged in', async () => {
const testStore = mockStore({});
document.cookie = 'MMUSERID=userid';
localStorage.setItem('was_logged_in', 'true');
await testStore.dispatch(Actions.loadConfigAndMe());
expect(testStore.getActions()).toEqual([{type: 'MOCK_GET_CLIENT_CONFIG'}, {type: 'MOCK_GET_LICENSE_CONFIG'}, {type: 'MOCK_LOAD_ME'}]);
});
});
describe('registerPluginTranslationsSource', () => {
test('Should not dispatch action when getTranslation is empty', () => {
const testStore = mockStore({});

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

@@ -1,10 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ClientConfig} from '@mattermost/types/config';
import {getClientConfig, getLicenseConfig} from 'mattermost-redux/actions/general';
import {loadMe} from 'mattermost-redux/actions/users';
import {Client4} from 'mattermost-redux/client';
import type {ActionFuncAsync, ThunkActionFunc} from 'mattermost-redux/types/actions';
@@ -20,26 +16,6 @@ const pluginTranslationSources: Record<string, TranslationPluginFunction> = {};
export type TranslationPluginFunction = (locale: string) => Translations
export function loadConfigAndMe(): ThunkActionFunc<Promise<{config?: ClientConfig; isMeLoaded: boolean}>> {
return async (dispatch) => {
const results = await Promise.all([
dispatch(getClientConfig()),
dispatch(getLicenseConfig()),
]);
let isMeLoaded = false;
if (document.cookie.includes('MMUSERID=')) {
const dataFromLoadMe = await dispatch(loadMe());
isMeLoaded = dataFromLoadMe?.data ?? false;
}
return {
config: results[0].data,
isMeLoaded,
};
};
}
export function registerPluginTranslationsSource(pluginId: string, sourceFunction: TranslationPluginFunction): ThunkActionFunc<void, GlobalState> {
pluginTranslationSources[pluginId] = sourceFunction;
return (dispatch, getState) => {
@@ -91,19 +67,3 @@ export function loadTranslations(locale: string, url: string): ActionFuncAsync {
return {data: true};
};
}
export function registerCustomPostRenderer(type: string, component: any, id: string): ActionFuncAsync {
return async (dispatch) => {
// piggyback on plugins state to register a custom post renderer
dispatch({
type: ActionTypes.RECEIVED_PLUGIN_POST_COMPONENT,
data: {
postTypeId: id,
pluginId: id,
type,
component,
},
});
return {data: true};
};
}

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

@@ -17,7 +17,8 @@ import Constants from 'utils/constants';
import DesktopApp from 'utils/desktop_api';
import {isKeyPressed} from 'utils/keyboard';
import {getBrowserTimezone} from 'utils/timezone';
import * as UserAgent from 'utils/user_agent';
import {isAndroid, isIos} from 'utils/user_agent';
import {doesCookieContainsMMUserId} from 'utils/utils';
declare global {
interface Window {
@@ -89,9 +90,9 @@ export default class LoggedIn extends React.PureComponent<Props> {
};
// Device tracking setup
if (UserAgent.isIos()) {
if (isIos()) {
document.body.classList.add('ios');
} else if (UserAgent.isAndroid()) {
} else if (isAndroid()) {
document.body.classList.add('android');
}
@@ -202,7 +203,7 @@ export default class LoggedIn extends React.PureComponent<Props> {
private handleBeforeUnload = (): void => {
// remove the event listener to prevent getting stuck in a loop
window.removeEventListener('beforeunload', this.handleBeforeUnload);
if (document.cookie.indexOf('MMUSERID=') > -1 && this.props.currentChannelId && !this.props.isCurrentChannelManuallyUnread) {
if (doesCookieContainsMMUserId() && this.props.currentChannelId && !this.props.isCurrentChannelManuallyUnread) {
this.props.actions.updateApproximateViewTime(this.props.currentChannelId);
}
WebSocketActions.close();

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

@@ -1,139 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/Root Routes Should mount public product routes 1`] = `
<RootProvider>
<Connect(MobileViewWatcher) />
<LuxonController />
<PerformanceReporterController />
<Switch>
<Route
component={[Function]}
path="/error"
/>
<HFRoute
component={[Function]}
path="/login"
/>
<HFRoute
component={[Function]}
path="/access_problem"
/>
<HFTRoute
component={[Function]}
path="/reset_password"
/>
<HFTRoute
component={[Function]}
path="/reset_password_complete"
/>
<HFRoute
component={[Function]}
path="/signup_user_complete"
/>
<HFRoute
component={[Function]}
path="/should_verify_email"
/>
<HFRoute
component={[Function]}
path="/do_verify_email"
/>
<HFTRoute
component={[Function]}
path="/claim"
/>
<LoggedInRoute
component={[Function]}
path="/terms_of_service"
/>
<Route
component={[Function]}
path="/landing"
/>
<Route
path="/admin_console"
>
<Switch>
<LoggedInRoute
component={[Function]}
path="/admin_console"
theme={Object {}}
/>
<Connect(RootRedirect) />
</Switch>
</Route>
<LoggedInHFTRoute
component={[Function]}
path="/select_team"
/>
<LoggedInHFTRoute
component={[Function]}
path="/oauth/authorize"
/>
<LoggedInHFTRoute
component={[Function]}
path="/create_team"
/>
<LoggedInRoute
component={[Function]}
path="/mfa"
/>
<LoggedInRoute
component={[Function]}
path="/preparing-workspace"
/>
<Redirect
from="/_redirect/integrations/:subpath*"
to="/myTeam/integrations/:subpath*"
/>
<Redirect
from="/_redirect/pl/:postid"
to="/myTeam/pl/:postid"
/>
<CompassThemeProvider
theme={Object {}}
>
<WindowSizeObserver />
<Connect(ModalController) />
<Connect(Component) />
<Connect(injectIntl(SystemNotice)) />
<GlobalHeader />
<CloudEffectsWrapper />
<withRouter(Connect(TeamSidebar)) />
<div
className="main-wrapper"
>
<Switch>
<Route
key="productwithpublic-public"
path="/productwithpublic/public"
render={[Function]}
/>
<Route
key="productwithpublic"
path="/productwithpublic"
render={[Function]}
/>
<Route
key="productwithoutpublic"
path="/productwithoutpublic"
render={[Function]}
/>
<LoggedInRoute
component={[Function]}
path="/:team([a-z0-9\\\\-_]+)"
theme={Object {}}
/>
<Connect(RootRedirect) />
</Switch>
<withRouter(Connect(SidebarRight)) />
</div>
<Connect(Pluggable)
pluggableName="Global"
/>
<AppBar />
<Connect(Component) />
</CompassThemeProvider>
</Switch>
</RootProvider>
`;

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

@@ -1,91 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {History} from 'history';
import type {UserProfile} from '@mattermost/types/users';
import {getFirstAdminSetupComplete} from 'mattermost-redux/actions/general';
import {getProfiles} from 'mattermost-redux/actions/users';
import {General} from 'mattermost-redux/constants';
import {getIsOnboardingFlowEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getActiveTeamsList} from 'mattermost-redux/selectors/entities/teams';
import {checkIsFirstAdmin, getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import type {ThunkActionFunc} from 'mattermost-redux/types/actions';
import * as GlobalActions from 'actions/global_actions';
import {StoragePrefixes} from 'utils/constants';
export function redirectToOnboardingOrDefaultTeam(history: History): ThunkActionFunc<void> {
return async (dispatch, getState) => {
const state = getState();
const isUserAdmin = isCurrentUserSystemAdmin(state);
if (!isUserAdmin) {
GlobalActions.redirectUserToDefaultTeam();
return;
}
const teams = getActiveTeamsList(state);
const onboardingFlowEnabled = getIsOnboardingFlowEnabled(state);
if (teams.length > 0 || !onboardingFlowEnabled) {
GlobalActions.redirectUserToDefaultTeam();
return;
}
const firstAdminSetupComplete = await dispatch(getFirstAdminSetupComplete());
if (firstAdminSetupComplete?.data) {
GlobalActions.redirectUserToDefaultTeam();
return;
}
const profilesResult = await dispatch(getProfiles(0, General.PROFILE_CHUNK_SIZE, {roles: General.SYSTEM_ADMIN_ROLE}));
if (profilesResult.error) {
GlobalActions.redirectUserToDefaultTeam();
return;
}
const currentUser = getCurrentUser(getState());
const adminProfiles = profilesResult.data?.reduce(
(acc: Record<string, UserProfile>, curr: UserProfile) => {
acc[curr.id] = curr;
return acc;
},
{},
);
if (adminProfiles && checkIsFirstAdmin(currentUser, adminProfiles)) {
history.push('/preparing-workspace');
return;
}
GlobalActions.redirectUserToDefaultTeam();
};
}
export function handleLoginLogoutSignal(e: StorageEvent): ThunkActionFunc<void> {
return (dispatch, getState) => {
// when one tab on a browser logs out, it sets __logout__ in localStorage to trigger other tabs to log out
const isNewLocalStorageEvent = (event: StorageEvent) => event.storageArea === localStorage && event.newValue;
if (e.key === StoragePrefixes.LOGOUT && isNewLocalStorageEvent(e)) {
console.log('detected logout from a different tab'); //eslint-disable-line no-console
GlobalActions.emitUserLoggedOutEvent('/', false, false);
}
if (e.key === StoragePrefixes.LOGIN && isNewLocalStorageEvent(e)) {
const isLoggedIn = getCurrentUser(getState());
// make sure this is not the same tab which sent login signal
// because another tabs will also send login signal after reloading
if (isLoggedIn) {
return;
}
// detected login from a different tab
function reloadOnFocus() {
location.reload();
}
window.addEventListener('focus', reloadOnFocus);
}
};
}

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

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import mockStore from 'tests/test_store';
import {loadConfigAndMe} from './index';
jest.mock('mattermost-redux/actions/general', () => {
const original = jest.requireActual('mattermost-redux/actions/general');
return {
...original,
getClientConfig: () => ({type: 'MOCK_GET_CLIENT_CONFIG'}),
getLicenseConfig: () => ({type: 'MOCK_GET_LICENSE_CONFIG'}),
};
});
jest.mock('mattermost-redux/actions/users', () => {
const original = jest.requireActual('mattermost-redux/actions/users');
return {
...original,
getMe: () => ({type: 'MOCK_LOAD_ME'}),
};
});
jest.mock('mattermost-redux/actions/preferences', () => {
const original = jest.requireActual('mattermost-redux/actions/preferences');
return {
...original,
getMyPreferences: () => ({type: 'MOCK_LOAD_PREFERENCES'}),
};
});
jest.mock('mattermost-redux/actions/teams', () => {
const original = jest.requireActual('mattermost-redux/actions/teams');
return {
...original,
getMyTeamMembers: () => ({type: 'MOCK_GET_MY_TEAM_MEMBERS'}),
getMyTeams: () => ({type: 'MOCK_GET_MY_TEAMS'}),
getMyTeamUnreads: () => ({type: 'MOCK_GET_MY_TEAM_UNREADS'}),
};
});
jest.mock('mattermost-redux/selectors/entities/preferences', () => {
const original = jest.requireActual('mattermost-redux/selectors/entities/preferences');
return {
...original,
isCollapsedThreadsEnabled: () => false,
};
});
jest.mock('mattermost-redux/actions/limits', () => ({
...jest.requireActual('mattermost-redux/actions/limits'),
getServerLimits: () => ({type: 'MOCK_GET_SERVER_LIMITS'}),
}));
describe('loadConfigAndMe', () => {
test('loadConfigAndMe, without user logged in', async () => {
const testStore = mockStore({});
await testStore.dispatch(loadConfigAndMe());
expect(testStore.getActions()).toEqual([{type: 'MOCK_GET_CLIENT_CONFIG'}, {type: 'MOCK_GET_LICENSE_CONFIG'}]);
});
test('loadConfigAndMe, with user logged in', async () => {
const testStore = mockStore({
entities: {
general: {
serverVersion: '1.0.0',
},
users: {
currentUserId: 'userid',
},
},
});
document.cookie = 'MMUSERID=userid';
localStorage.setItem('was_logged_in', 'true');
await testStore.dispatch(loadConfigAndMe());
expect(testStore.getActions()).toEqual([
{type: 'MOCK_GET_CLIENT_CONFIG'},
{type: 'MOCK_GET_LICENSE_CONFIG'},
{type: 'RECEIVED_SERVER_VERSION', data: '1.0.0'},
{type: 'MOCK_LOAD_ME'},
{type: 'MOCK_LOAD_PREFERENCES'},
{type: 'MOCK_GET_MY_TEAMS'},
{type: 'MOCK_GET_MY_TEAM_MEMBERS'},
{type: 'MOCK_GET_MY_TEAM_UNREADS'},
{type: 'MOCK_GET_SERVER_LIMITS'},
]);
});
});

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

@@ -0,0 +1,173 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {History} from 'history';
import type {ServerError} from '@mattermost/types/errors';
import type {UserProfile} from '@mattermost/types/users';
import {GeneralTypes} from 'mattermost-redux/action_types';
import {logError} from 'mattermost-redux/actions/errors';
import {getClientConfig, getLicenseConfig, getFirstAdminSetupComplete} from 'mattermost-redux/actions/general';
import {getServerLimits} from 'mattermost-redux/actions/limits';
import {getMyPreferences} from 'mattermost-redux/actions/preferences';
import {getMyTeamMembers, getMyTeams, getMyTeamUnreads} from 'mattermost-redux/actions/teams';
import {getMe, getProfiles} from 'mattermost-redux/actions/users';
import {Client4} from 'mattermost-redux/client';
import {General} from 'mattermost-redux/constants';
import {isCollapsedThreadsEnabled, getIsOnboardingFlowEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getActiveTeamsList} from 'mattermost-redux/selectors/entities/teams';
import {checkIsFirstAdmin, getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import type {ActionFuncAsync, ThunkActionFunc} from 'mattermost-redux/types/actions';
import {redirectUserToDefaultTeam, emitUserLoggedOutEvent} from 'actions/global_actions';
import {ActionTypes, StoragePrefixes} from 'utils/constants';
import {doesCookieContainsMMUserId} from 'utils/utils';
import type {Translations} from 'types/store/i18n';
export type TranslationPluginFunction = (locale: string) => Translations
/**
* This function meant to be used in root.tsx component loads config, license and if user is logged in, it loads user and its related data.
*/
export function loadConfigAndMe(): ThunkActionFunc<Promise<{isLoaded: boolean; isMeRequested?: boolean}>> {
return async (dispatch, getState) => {
// attempt to load config and license regardless if user is logged in or not
try {
await Promise.all([
dispatch(getClientConfig()),
dispatch(getLicenseConfig()),
]);
} catch (error) {
dispatch(logError(error as ServerError));
return {
isLoaded: false,
};
}
// Return early if user is not logged in
if (!doesCookieContainsMMUserId()) {
return {
isLoaded: true,
isMeRequested: false,
};
}
// Load user and its related data now that we know that user is logged in
const serverVersion = getState().entities.general.serverVersion || Client4.getServerVersion();
dispatch({type: GeneralTypes.RECEIVED_SERVER_VERSION, data: serverVersion});
try {
await Promise.all([
dispatch(getMe()),
dispatch(getMyPreferences()),
dispatch(getMyTeams()),
dispatch(getMyTeamMembers()),
]);
dispatch(getMyTeamUnreads(isCollapsedThreadsEnabled(getState())));
dispatch(getServerLimits());
} catch (error) {
dispatch(logError(error as ServerError));
return {
isLoaded: false,
};
}
return {
isLoaded: true,
isMeRequested: true,
};
};
}
export function registerCustomPostRenderer(type: string, component: any, id: string): ActionFuncAsync {
return async (dispatch) => {
// piggyback on plugins state to register a custom post renderer
dispatch({
type: ActionTypes.RECEIVED_PLUGIN_POST_COMPONENT,
data: {
postTypeId: id,
pluginId: id,
type,
component,
},
});
return {data: true};
};
}
export function redirectToOnboardingOrDefaultTeam(history: History): ThunkActionFunc<void> {
return async (dispatch, getState) => {
const state = getState();
const isUserAdmin = isCurrentUserSystemAdmin(state);
if (!isUserAdmin) {
redirectUserToDefaultTeam();
return;
}
const teams = getActiveTeamsList(state);
const onboardingFlowEnabled = getIsOnboardingFlowEnabled(state);
if (teams.length > 0 || !onboardingFlowEnabled) {
redirectUserToDefaultTeam();
return;
}
const firstAdminSetupComplete = await dispatch(getFirstAdminSetupComplete());
if (firstAdminSetupComplete?.data) {
redirectUserToDefaultTeam();
return;
}
const profilesResult = await dispatch(getProfiles(0, General.PROFILE_CHUNK_SIZE, {roles: General.SYSTEM_ADMIN_ROLE}));
if (profilesResult.error) {
redirectUserToDefaultTeam();
return;
}
const currentUser = getCurrentUser(getState());
const adminProfiles = profilesResult.data?.reduce(
(acc: Record<string, UserProfile>, curr: UserProfile) => {
acc[curr.id] = curr;
return acc;
},
{},
);
if (adminProfiles && checkIsFirstAdmin(currentUser, adminProfiles)) {
history.push('/preparing-workspace');
return;
}
redirectUserToDefaultTeam();
};
}
export function handleLoginLogoutSignal(e: StorageEvent): ThunkActionFunc<void> {
return (dispatch, getState) => {
// when one tab on a browser logs out, it sets __logout__ in localStorage to trigger other tabs to log out
const isNewLocalStorageEvent = (event: StorageEvent) => event.storageArea === localStorage && event.newValue;
if (e.key === StoragePrefixes.LOGOUT && isNewLocalStorageEvent(e)) {
console.log('detected logout from a different tab'); //eslint-disable-line no-console
emitUserLoggedOutEvent('/', false, false);
}
if (e.key === StoragePrefixes.LOGIN && isNewLocalStorageEvent(e)) {
const isLoggedIn = getCurrentUser(getState());
// make sure this is not the same tab which sent login signal
// because another tabs will also send login signal after reloading
if (isLoggedIn) {
return;
}
// detected login from a different tab
function reloadOnFocus() {
location.reload();
}
window.addEventListener('focus', reloadOnFocus);
}
};
}

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

@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import isEmpty from 'lodash/isEmpty';
import type {ConnectedProps} from 'react-redux';
import {connect} from 'react-redux';
import {withRouter} from 'react-router-dom';
import {bindActionCreators} from 'redux';
@@ -15,7 +17,6 @@ import {getTeam} from 'mattermost-redux/selectors/entities/teams';
import {shouldShowTermsOfService, getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {loadRecentlyUsedCustomEmojis, migrateRecentEmojis} from 'actions/emoji_actions';
import {loadConfigAndMe, registerCustomPostRenderer} from 'actions/views/root';
import {getShowLaunchingWorkspace} from 'selectors/onboarding';
import {shouldShowAppBar} from 'selectors/plugins';
import {
@@ -29,7 +30,12 @@ import {initializeProducts} from 'plugins/products';
import type {GlobalState} from 'types/store/index';
import {handleLoginLogoutSignal, redirectToOnboardingOrDefaultTeam} from './actions';
import {
loadConfigAndMe,
registerCustomPostRenderer,
handleLoginLogoutSignal,
redirectToOnboardingOrDefaultTeam,
} from './actions';
import Root from './root';
function mapStateToProps(state: GlobalState) {
@@ -42,11 +48,16 @@ function mapStateToProps(state: GlobalState) {
const teamId = LocalStorageStore.getPreviousTeamId(userId);
const permalinkRedirectTeam = getTeam(state, teamId!);
const isConfigLoaded = config && !isEmpty(config);
return {
theme: getTheme(state),
isConfigLoaded,
telemetryEnabled: config.DiagnosticsEnabled === 'true',
noAccounts: config.NoAccounts === 'true',
telemetryId: config.DiagnosticId,
serviceEnvironment: config.ServiceEnvironment,
siteURL: config.SiteURL,
iosDownloadLink: config.IosAppDownloadLink,
androidDownloadLink: config.AndroidAppDownloadLink,
appDownloadLink: config.AppDownloadLink,
@@ -79,4 +90,8 @@ function mapDispatchToProps(dispatch: Dispatch) {
};
}
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Root));
const connector = connect(mapStateToProps, mapDispatchToProps);
export type PropsFromRedux = ConnectedProps<typeof connector>;
export default withRouter(connector(Root));

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

@@ -1,11 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import type {RouteComponentProps} from 'react-router-dom';
import {bindActionCreators} from 'redux';
import rudderAnalytics from 'rudder-sdk-js';
import {ServiceEnvironment} from '@mattermost/types/config';
@@ -14,40 +12,56 @@ import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
import * as GlobalActions from 'actions/global_actions';
import Root from 'components/root/root';
import testConfigureStore from 'packages/mattermost-redux/test/test_store';
import {renderWithContext, waitFor} from 'tests/react_testing_utils';
import {StoragePrefixes} from 'utils/constants';
import type {ProductComponent} from 'types/store/plugins';
import {handleLoginLogoutSignal, redirectToOnboardingOrDefaultTeam} from './actions';
import type {Props} from './root';
import Root from './root';
jest.mock('rudder-sdk-js', () => ({
identify: jest.fn(),
load: jest.fn(),
page: jest.fn(),
ready: jest.fn((callback) => callback()),
track: jest.fn(),
jest.mock('mattermost-redux/client/rudder', () => ({
rudderAnalytics: {
identify: jest.fn(),
load: jest.fn(),
page: jest.fn(),
ready: jest.fn((callback) => callback()), // Default behavior: calls the callback
track: jest.fn(),
},
RudderTelemetryHandler: jest.fn(),
}));
jest.mock('mattermost-redux/client/rudder', () => {
const actual = jest.requireActual('mattermost-redux/client/rudder');
return {
...actual,
rudderAnalytics: {
...actual.rudderAnalytics,
ready: jest.fn((callback) => callback()),
},
};
});
jest.mock('actions/telemetry_actions');
jest.mock('actions/global_actions', () => ({
redirectUserToDefaultTeam: jest.fn(),
}));
jest.mock('components/announcement_bar', () => () => <div/>);
jest.mock('components/team_sidebar', () => () => <div/>);
jest.mock('components/mobile_view_watcher', () => () => <div/>);
jest.mock('./performance_reporter_controller', () => () => <div/>);
jest.mock('utils/utils', () => {
const original = jest.requireActual('utils/utils');
return {
...original,
localizeMessage: () => {},
applyTheme: jest.fn(),
makeIsEligibleForClick: jest.fn(),
};
});
jest.mock('actions/global_actions', () => ({
redirectUserToDefaultTeam: jest.fn(),
}));
jest.mock('mattermost-redux/actions/general', () => ({
getFirstAdminSetupComplete: jest.fn(() => Promise.resolve({
type: 'FIRST_ADMIN_COMPLETE_SETUP_RECEIVED',
@@ -59,23 +73,37 @@ jest.mock('mattermost-redux/actions/general', () => ({
describe('components/Root', () => {
const store = testConfigureStore();
const baseProps = {
telemetryEnabled: true,
telemetryId: '1234ab',
noAccounts: false,
showTermsOfService: false,
const baseProps: Props = {
theme: {} as Theme,
isConfigLoaded: true,
telemetryEnabled: true,
noAccounts: false,
telemetryId: '1234ab',
serviceEnvironment: undefined,
siteURL: 'http://localhost:8065',
iosDownloadLink: undefined,
androidDownloadLink: undefined,
appDownloadLink: undefined,
showTermsOfService: false,
plugins: [],
products: [],
showLaunchingWorkspace: false,
rhsIsExpanded: false,
rhsIsOpen: false,
rhsState: null,
shouldShowAppBar: false,
isCloud: false,
actions: {
loadConfigAndMe: jest.fn().mockImplementation(() => {
return Promise.resolve({
config: {},
isMeLoaded: false,
isLoaded: true,
isMeRequested: false,
});
}),
getFirstAdminSetupComplete: jest.fn(),
getProfiles: jest.fn(),
loadRecentlyUsedCustomEmojis: jest.fn(),
migrateRecentEmojis: jest.fn(),
savePreferences: jest.fn(),
registerCustomPostRenderer: jest.fn(),
initializeProducts: jest.fn(),
...bindActionCreators({
@@ -84,37 +112,62 @@ describe('components/Root', () => {
}, store.dispatch),
},
permalinkRedirectTeamName: 'myTeam',
showLaunchingWorkspace: false,
plugins: [],
products: [],
...{
location: {
pathname: '/',
},
} as RouteComponentProps,
isCloud: false,
rhsIsExpanded: false,
rhsIsOpen: false,
shouldShowAppBar: false,
};
test('should load config and license on mount and redirect to sign-up page', () => {
const props = {
...baseProps,
noAccounts: true,
history: {
push: jest.fn(),
} as unknown as RouteComponentProps['history'],
};
} as RouteComponentProps,
};
const wrapper = shallow<Root>(<Root {...props}/>);
let originalMatchMedia: (query: string) => MediaQueryList;
let originalReload: () => void;
wrapper.instance().onConfigLoaded({});
expect(props.history.push).toHaveBeenCalledWith('/signup_user_complete');
wrapper.unmount();
beforeAll(() => {
originalMatchMedia = window.matchMedia;
originalReload = window.location.reload;
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
})),
});
Object.defineProperty(window.location, 'reload', {
configurable: true,
writable: true,
});
window.location.reload = jest.fn();
});
test('should load user, config, and license on mount and redirect to defaultTeam on success', (done) => {
afterEach(() => {
jest.restoreAllMocks();
});
afterAll(() => {
window.matchMedia = originalMatchMedia;
window.location.reload = originalReload;
});
test('should load config and license on mount and redirect to sign-up page', async () => {
const props = {
...baseProps,
noAccounts: true,
};
renderWithContext(<Root {...props}/>);
await waitFor(() => {
expect(props.history.push).toHaveBeenCalledWith('/signup_user_complete');
});
});
test('should load user, config, and license on mount and redirect to defaultTeam on success', async () => {
document.cookie = 'MMUSERID=userid';
localStorage.setItem('was_logged_in', 'true');
@@ -124,28 +177,22 @@ describe('components/Root', () => {
...baseProps.actions,
loadConfigAndMe: jest.fn().mockImplementation(() => {
return Promise.resolve({
config: {},
isMeLoaded: true,
isLoaded: true,
isMeRequested: true,
});
}),
},
};
// Mock the method by extending the class because we don't have a chance to do it before shallow mounts the component
class MockedRoot extends Root {
onConfigLoaded = jest.fn(() => {
expect(this.onConfigLoaded).toHaveBeenCalledTimes(1);
expect(GlobalActions.redirectUserToDefaultTeam).toHaveBeenCalledTimes(1);
expect(props.actions.loadConfigAndMe).toHaveBeenCalledTimes(1);
done();
});
}
renderWithContext(<Root {...props}/>);
const wrapper = shallow(<MockedRoot {...props}/>);
wrapper.unmount();
await waitFor(() => {
expect(props.actions.loadConfigAndMe).toHaveBeenCalledTimes(1);
expect(GlobalActions.redirectUserToDefaultTeam).toHaveBeenCalledTimes(1);
});
});
test('should load user, config, and license on mount and should not redirect to defaultTeam id pathname is not root', (done) => {
test('should load user, config, and license on mount and should not redirect to defaultTeam id pathname is not root', async () => {
document.cookie = 'MMUSERID=userid';
localStorage.setItem('was_logged_in', 'true');
@@ -158,25 +205,19 @@ describe('components/Root', () => {
...baseProps.actions,
loadConfigAndMe: jest.fn().mockImplementation(() => {
return Promise.resolve({
config: {},
isMeLoaded: true,
isLoaded: true,
isMeRequested: true,
});
}),
},
};
// Mock the method by extending the class because we don't have a chance to do it before shallow mounts the component
class MockedRoot extends Root {
onConfigLoaded = jest.fn(() => {
expect(this.onConfigLoaded).toHaveBeenCalledTimes(1);
expect(GlobalActions.redirectUserToDefaultTeam).not.toHaveBeenCalled();
expect(props.actions.loadConfigAndMe).toHaveBeenCalledTimes(1);
done();
});
}
renderWithContext(<Root {...props}/>);
const wrapper = shallow(<MockedRoot {...props}/>);
wrapper.unmount();
await waitFor(() => {
expect(props.actions.loadConfigAndMe).toHaveBeenCalledTimes(1);
expect(GlobalActions.redirectUserToDefaultTeam).not.toHaveBeenCalled();
});
});
test('should call history on props change', () => {
@@ -187,23 +228,24 @@ describe('components/Root', () => {
push: jest.fn(),
} as unknown as RouteComponentProps['history'],
};
const wrapper = shallow<Root>(<Root {...props}/>);
const {rerender} = renderWithContext(<Root {...props}/>);
expect(props.history.push).not.toHaveBeenCalled();
const props2 = {
...props,
noAccounts: true,
};
wrapper.setProps(props2);
rerender(<Root {...props2}/>);
expect(props.history.push).toHaveBeenLastCalledWith('/signup_user_complete');
wrapper.unmount();
});
test('should reload on focus after getting signal login event from another tab', () => {
Object.defineProperty(window.location, 'reload', {
configurable: true,
writable: true,
});
window.location.reload = jest.fn();
const wrapper = shallow<Root>(<Root {...baseProps}/>);
renderWithContext(<Root {...baseProps}/>);
const loginSignal = new StorageEvent('storage', {
key: StoragePrefixes.LOGIN,
newValue: String(Math.random()),
@@ -212,89 +254,68 @@ describe('components/Root', () => {
window.dispatchEvent(loginSignal);
window.dispatchEvent(new Event('focus'));
expect(window.location.reload).toBeCalledTimes(1);
wrapper.unmount();
});
describe('onConfigLoaded', () => {
afterEach(() => {
Client4.telemetryHandler = undefined;
test('should not set a TelemetryHandler when onConfigLoaded is called if Rudder is not configured', async () => {
const props = {
...baseProps,
serviceEnvironment: ServiceEnvironment.DEV,
actions: {
...baseProps.actions,
loadConfigAndMe: jest.fn().mockImplementation(() => {
return Promise.resolve({
isLoaded: true,
isMeRequested: true,
});
}),
},
};
renderWithContext(<Root {...props}/>);
// Wait for the component to load config and call onConfigLoaded
await waitFor(() => {
expect(props.actions.loadConfigAndMe).toHaveBeenCalledTimes(1);
});
test('should not set a TelemetryHandler when onConfigLoaded is called if Rudder is not configured', () => {
const wrapper = shallow<Root>(<Root {...baseProps}/>);
Client4.trackEvent('category', 'event');
wrapper.instance().onConfigLoaded({
ServiceEnvironment: ServiceEnvironment.DEV,
});
Client4.trackEvent('category', 'event');
expect(Client4.telemetryHandler).not.toBeDefined();
wrapper.unmount();
});
test('should set a TelemetryHandler when onConfigLoaded is called if Rudder is configured', () => {
const wrapper = shallow<Root>(<Root {...baseProps}/>);
wrapper.instance().onConfigLoaded({
ServiceEnvironment: ServiceEnvironment.TEST,
});
Client4.trackEvent('category', 'event');
expect(Client4.telemetryHandler).toBeDefined();
wrapper.unmount();
});
test('should not set a TelemetryHandler when onConfigLoaded is called but Rudder has been blocked', () => {
(rudderAnalytics.ready as any).mockImplementation(() => {
// Simulate an error occurring and the callback not getting called
});
const wrapper = shallow<Root>(<Root {...baseProps}/>);
wrapper.instance().onConfigLoaded({
ServiceEnvironment: ServiceEnvironment.PRODUCTION,
});
Client4.trackEvent('category', 'event');
expect(Client4.telemetryHandler).not.toBeDefined();
wrapper.unmount();
});
expect(Client4.telemetryHandler).not.toBeDefined();
});
describe('Routes', () => {
test('Should mount public product routes', () => {
const mainComponent = () => (<p>{'TestMainComponent'}</p>);
const publicComponent = () => (<p>{'TestPublicProduct'}</p>);
test('should set a TelemetryHandler when onConfigLoaded is called if Rudder is configured', async () => {
const props = {
...baseProps,
isConfigLoaded: false,
serviceEnvironment: ServiceEnvironment.TEST,
actions: {
...baseProps.actions,
loadConfigAndMe: jest.fn().mockImplementation(() => {
return Promise.resolve({
isLoaded: true,
isMeRequested: true,
});
}),
},
};
const props = {
...baseProps,
products: [{
id: 'productwithpublic',
baseURL: '/productwithpublic',
mainComponent,
publicComponent,
} as unknown as ProductComponent,
{
id: 'productwithoutpublic',
baseURL: '/productwithoutpublic',
mainComponent,
publicComponent: null,
} as unknown as ProductComponent],
};
const {rerender} = renderWithContext(<Root {...props}/>);
const wrapper = shallow<Root>(<Root {...props}/>);
wrapper.instance().setState({configLoaded: true});
expect(wrapper).toMatchSnapshot();
wrapper.unmount();
// Wait for the component to load config and call onConfigLoaded
await waitFor(() => {
expect(props.actions.loadConfigAndMe).toHaveBeenCalledTimes(1);
});
const props2 = {
...props,
isConfigLoaded: true,
};
rerender(<Root {...props2}/>);
expect(Client4.telemetryHandler).toBeDefined();
});
describe('showLandingPageIfNecessary', () => {
@@ -309,19 +330,17 @@ describe('components/Root', () => {
search: '',
},
} as RouteComponentProps,
history: {
push: jest.fn(),
} as unknown as RouteComponentProps['history'],
};
test('should show for normal cases', () => {
const wrapper = shallow<Root>(<Root {...landingProps}/>);
wrapper.instance().onConfigLoaded({});
expect(landingProps.history.push).toHaveBeenCalledWith('/landing#/');
wrapper.unmount();
test('should show for normal cases', async () => {
renderWithContext(<Root {...landingProps}/>);
await waitFor(() => {
expect(landingProps.history.push).toHaveBeenCalledWith('/landing#/');
});
});
test('should not show for Desktop App login flow', () => {
test('should not show for Desktop App login flow', async () => {
const props = {
...landingProps,
...{
@@ -330,10 +349,12 @@ describe('components/Root', () => {
},
} as RouteComponentProps,
};
const wrapper = shallow<Root>(<Root {...props}/>);
wrapper.instance().onConfigLoaded({});
expect(props.history.push).not.toHaveBeenCalled();
wrapper.unmount();
renderWithContext(<Root {...props}/>);
await waitFor(() => {
expect(props.history.push).not.toHaveBeenCalled();
});
});
});
});

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

@@ -3,12 +3,10 @@
import classNames from 'classnames';
import deepEqual from 'fast-deep-equal';
import type {History} from 'history';
import React from 'react';
import {Route, Switch, Redirect} from 'react-router-dom';
import type {RouteComponentProps} from 'react-router-dom';
import type {ClientConfig} from '@mattermost/types/config';
import {ServiceEnvironment} from '@mattermost/types/config';
import {setSystemEmojis} from 'mattermost-redux/actions/emojis';
@@ -16,7 +14,6 @@ import {setUrl} from 'mattermost-redux/actions/general';
import {Client4} from 'mattermost-redux/client';
import {rudderAnalytics, RudderTelemetryHandler} from 'mattermost-redux/client/rudder';
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {measurePageLoadTelemetry, temporarilySetPageLoadContext, trackEvent, trackSelectorMetrics} from 'actions/telemetry_actions.jsx';
import BrowserStore from 'stores/browser_store';
@@ -52,13 +49,13 @@ import {getSiteURL} from 'utils/url';
import * as UserAgent from 'utils/user_agent';
import * as Utils from 'utils/utils';
import type {ProductComponent, PluginComponent} from 'types/store/plugins';
import LuxonController from './luxon_controller';
import PerformanceReporterController from './performance_reporter_controller';
import RootProvider from './root_provider';
import RootRedirect from './root_redirect';
import type {PropsFromRedux} from './index';
import 'plugins/export.js';
const LazyErrorPage = React.lazy(() => import('components/error_page'));
@@ -126,51 +123,19 @@ function LoggedInRoute(props: LoggedInRouteProps) {
const noop = () => {};
export type Actions = {
getProfiles: (page?: number, pageSize?: number, options?: Record<string, any>) => Promise<ActionResult>;
loadRecentlyUsedCustomEmojis: () => Promise<unknown>;
migrateRecentEmojis: () => void;
loadConfigAndMe: () => Promise<{config?: Partial<ClientConfig>; isMeLoaded: boolean}>;
registerCustomPostRenderer: (type: string, component: any, id: string) => Promise<ActionResult>;
initializeProducts: () => Promise<unknown>;
handleLoginLogoutSignal: (e: StorageEvent) => unknown;
redirectToOnboardingOrDefaultTeam: (history: History) => unknown;
}
type Props = {
theme: Theme;
telemetryEnabled: boolean;
telemetryId?: string;
iosDownloadLink?: string;
androidDownloadLink?: string;
appDownloadLink?: string;
noAccounts: boolean;
showTermsOfService: boolean;
permalinkRedirectTeamName: string;
isCloud: boolean;
actions: Actions;
plugins?: PluginComponent[];
products: ProductComponent[];
showLaunchingWorkspace: boolean;
rhsIsExpanded: boolean;
rhsIsOpen: boolean;
shouldShowAppBar: boolean;
} & RouteComponentProps
export type Props = PropsFromRedux & RouteComponentProps;
interface State {
configLoaded?: boolean;
shouldMountAppRoutes?: boolean;
}
export default class Root extends React.PureComponent<Props, State> {
private mounted: boolean;
// The constructor adds a bunch of event listeners,
// so we do need this.
private a11yController: A11yController;
constructor(props: Props) {
super(props);
this.mounted = false;
// Redux
setUrl(getSiteURL());
@@ -199,18 +164,18 @@ export default class Root extends React.PureComponent<Props, State> {
});
this.state = {
configLoaded: false,
shouldMountAppRoutes: false,
};
this.a11yController = new A11yController();
}
onConfigLoaded = (config: Partial<ClientConfig>) => {
setRudderConfig = () => {
const telemetryId = this.props.telemetryId;
const rudderUrl = 'https://pdat.matterlytics.com';
let rudderKey = '';
switch (config.ServiceEnvironment) {
switch (this.props.serviceEnvironment) {
case ServiceEnvironment.PRODUCTION:
rudderKey = '1aoejPqhgONMI720CsBSRWzzRQ9';
break;
@@ -223,13 +188,15 @@ export default class Root extends React.PureComponent<Props, State> {
if (rudderKey !== '' && this.props.telemetryEnabled) {
const rudderCfg: {setCookieDomain?: string} = {};
const siteURL = config.SiteURL;
if (siteURL !== '') {
if (this.props.siteURL !== '') {
try {
rudderCfg.setCookieDomain = new URL(siteURL || '').hostname;
// eslint-disable-next-line no-empty
} catch (_) {}
rudderCfg.setCookieDomain = new URL(this.props.siteURL || '').hostname;
} catch (_) {
// eslint-disable-next-line no-console
console.error('Failed to set cookie domain for RudderStack');
}
}
rudderAnalytics.load(rudderKey, rudderUrl || '', rudderCfg);
rudderAnalytics.identify(telemetryId, {}, {
@@ -268,20 +235,14 @@ export default class Root extends React.PureComponent<Props, State> {
}
});
}
};
if (this.props.location.pathname === '/' && this.props.noAccounts) {
this.props.history.push('/signup_user_complete');
}
onConfigLoaded = () => {
Promise.all([
this.props.actions.initializeProducts(),
initializePlugins(),
]).then(() => {
if (this.mounted) {
// supports enzyme tests, set state if and only if
// the component is still mounted on screen
this.setState({configLoaded: true});
}
this.setState({shouldMountAppRoutes: true});
});
this.props.actions.migrateRecentEmojis();
@@ -352,6 +313,7 @@ export default class Root extends React.PureComponent<Props, State> {
if (!deepEqual(prevProps.theme, this.props.theme)) {
Utils.applyTheme(this.props.theme);
}
if (this.props.location.pathname === '/') {
if (this.props.noAccounts) {
prevProps.history.push('/signup_user_complete');
@@ -359,6 +321,7 @@ export default class Root extends React.PureComponent<Props, State> {
prevProps.history.push('/terms_of_service');
}
}
if (
this.props.shouldShowAppBar !== prevProps.shouldShowAppBar ||
this.props.rhsIsOpen !== prevProps.rhsIsOpen ||
@@ -366,6 +329,10 @@ export default class Root extends React.PureComponent<Props, State> {
) {
this.setRootMeta();
}
if (!prevProps.isConfigLoaded && this.props.isConfigLoaded) {
this.setRudderConfig();
}
}
captureUTMParams() {
@@ -393,22 +360,26 @@ export default class Root extends React.PureComponent<Props, State> {
}
initiateMeRequests = async () => {
const {config, isMeLoaded} = await this.props.actions.loadConfigAndMe();
const {isLoaded, isMeRequested} = await this.props.actions.loadConfigAndMe();
if (isMeLoaded && this.props.location.pathname === '/') {
this.props.actions.redirectToOnboardingOrDefaultTeam(this.props.history);
}
if (isLoaded) {
const isUserAtRootRoute = this.props.location.pathname === '/';
if (config) {
this.onConfigLoaded(config);
if (isUserAtRootRoute) {
if (isMeRequested) {
this.props.actions.redirectToOnboardingOrDefaultTeam(this.props.history);
} else if (this.props.noAccounts) {
this.props.history.push('/signup_user_complete');
}
}
this.onConfigLoaded();
}
};
componentDidMount() {
temporarilySetPageLoadContext(PageLoadContext.PAGE_LOAD);
this.mounted = true;
this.initiateMeRequests();
// See figma design on issue https://mattermost.atlassian.net/browse/MM-43649
@@ -419,7 +390,6 @@ export default class Root extends React.PureComponent<Props, State> {
}
componentWillUnmount() {
this.mounted = false;
window.removeEventListener('storage', this.handleLogoutLoginSignal);
}
@@ -440,7 +410,7 @@ export default class Root extends React.PureComponent<Props, State> {
};
render() {
if (!this.state.configLoaded) {
if (!this.state.shouldMountAppRoutes) {
return <div/>;
}

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

@@ -8,10 +8,6 @@ export default keyMirror({
GET_TEAMS_SUCCESS: null,
GET_TEAMS_FAILURE: null,
MY_TEAMS_REQUEST: null,
MY_TEAMS_SUCCESS: null,
MY_TEAMS_FAILURE: null,
CREATE_TEAM_REQUEST: null,
CREATE_TEAM_SUCCESS: null,
CREATE_TEAM_FAILURE: null,

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

@@ -61,13 +61,8 @@ describe('Actions.Teams', () => {
reply(200, [TestHelper.basicTeam]);
await store.dispatch(Actions.getMyTeams());
const teamsRequest = store.getState().requests.teams.getMyTeams;
const {teams} = store.getState().entities.teams;
if (teamsRequest.status === RequestStatus.FAILURE) {
throw new Error(JSON.stringify(teamsRequest.error));
}
expect(teams).toBeTruthy();
expect(teams[TestHelper.basicTeam!.id]).toBeTruthy();
});

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

@@ -66,9 +66,7 @@ export function selectTeam(team: Team | Team['id']) {
export function getMyTeams() {
return bindClientFunc({
clientFunc: Client4.getMyTeams,
onRequest: TeamTypes.MY_TEAMS_REQUEST,
onSuccess: [TeamTypes.RECEIVED_TEAMS_LIST, TeamTypes.MY_TEAMS_SUCCESS],
onFailure: TeamTypes.MY_TEAMS_FAILURE,
onSuccess: TeamTypes.RECEIVED_TEAMS_LIST,
});
}

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

@@ -10,16 +10,6 @@ import {TeamTypes} from 'mattermost-redux/action_types';
import {handleRequest, initialRequestState} from './helpers';
function getMyTeams(state: RequestStatusType = initialRequestState(), action: AnyAction): RequestStatusType {
return handleRequest(
TeamTypes.MY_TEAMS_REQUEST,
TeamTypes.MY_TEAMS_SUCCESS,
TeamTypes.MY_TEAMS_FAILURE,
state,
action,
);
}
function getTeams(state: RequestStatusType = initialRequestState(), action: AnyAction): RequestStatusType {
return handleRequest(
TeamTypes.GET_TEAMS_REQUEST,
@@ -32,5 +22,4 @@ function getTeams(state: RequestStatusType = initialRequestState(), action: AnyA
export default combineReducers({
getTeams,
getMyTeams,
});

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

@@ -253,10 +253,6 @@ const state: GlobalState = {
},
},
teams: {
getMyTeams: {
status: 'not_started',
error: null,
},
getTeams: {
status: 'not_started',
error: null,

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

@@ -1657,3 +1657,7 @@ export function sortUsersAndGroups(a: UserProfile | Group, b: UserProfile | Grou
return aSortString.localeCompare(bSortString);
}
export function doesCookieContainsMMUserId() {
return document.cookie.includes('MMUSERID=');
}

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

@@ -29,7 +29,6 @@ export type ThreadsRequestStatuses = {
};
export type TeamsRequestsStatuses = {
getMyTeams: RequestStatusType;
getTeams: RequestStatusType;
};