Keep the team sidebar and current team in global products with the product rail (#39552)

* Keep the team sidebar and a selected team in global products

With the rail on screen, products that hide the team sidebar (Agents)
keep it, and loading one of their pages restores the user's last team, so
the rail and its team menu don't disappear on a refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restore a team only where the rail shows, and survive blocked storage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave the product rail out of backstage pages

Integrations and Custom Emoji take the whole page with their own way
back, so the rail stays off there, and the header and team sidebar
behave as they do without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remember a team picked inside a global product

Switching teams on a global product's page selects the team without a
team URL, so the team controller never recorded it and a refresh
restored the earlier one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Record the last team only where the product rail shows

With the flag off or on mobile, switching teams in a global product
leaves user_prev_team as before. The blocked-storage test now has a
current user, so it reaches the storage write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Avoid an import cycle when recording the last team

team_actions importing the product rail selectors closed a cycle
through utils/utils, which broke channel_header_title.test.tsx. Check
the flag and mobile view directly instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test when the team sidebar shows alongside the product rail

Cover global products with and without the rail, the hidden sidebar,
the hide button, and backstage pages bringing the sidebar back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Asaad MahmoodandClaude Opus 5.5 authored and GitHub committed 2026-10-29 15:57:50 -04:00
1 parent e93c098703
commit 68b173ab79
14 files changed
+346 -15

No files matched your search

@@ -6,9 +6,11 @@ import * as TeamActions from 'mattermost-redux/actions/teams';
import * as userActions from 'mattermost-redux/actions/users';
import * as Actions from 'actions/team_actions';
import LocalStorageStore from 'stores/local_storage_store';
import configureStore from 'tests/test_store';
import {getHistory} from 'utils/browser_history';
import {WindowSizes} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
jest.mock('mattermost-redux/actions/teams', () => ({
@@ -73,6 +75,7 @@ describe('Actions.Team', () => {
currentChannelId,
manuallyUnread: {},
},
general: {config: {}},
},
};
@@ -96,6 +99,56 @@ describe('Actions.Team', () => {
expect(getHistory().push).not.toHaveBeenCalled();
expect(testStore.getActions()).toContainEqual(TeamActions.selectTeam(team));
});
describe('with the product rail on', () => {
const railState = {
entities: {
...initialState.entities,
general: {config: {FeatureFlagProductRail: 'true'}},
teams: {currentTeamId: 'team-one'},
users: {currentUserId: 'user-one'},
},
views: {browser: {windowSize: WindowSizes.DESKTOP_VIEW}},
};
test('should remember a team selected without changing URL, so a refresh keeps it', () => {
const setPreviousTeamId = jest.spyOn(LocalStorageStore, 'setPreviousTeamId').mockImplementation(() => {});
const testStore = configureStore(railState);
testStore.dispatch(Actions.switchTeam('/test', TestHelper.getTeamMock({id: 'team-two'})));
expect(setPreviousTeamId).toHaveBeenCalledWith('user-one', 'team-two');
setPreviousTeamId.mockRestore();
});
test('should still select the team when storage is blocked', () => {
const setPreviousTeamId = jest.spyOn(LocalStorageStore, 'setPreviousTeamId').mockImplementation(() => {
throw new Error('blocked');
});
const testStore = configureStore(railState);
const team = TestHelper.getTeamMock({id: 'team-two'});
testStore.dispatch(Actions.switchTeam('/test', team));
expect(setPreviousTeamId).toHaveBeenCalledWith('user-one', 'team-two');
expect(testStore.getActions()).toContainEqual(TeamActions.selectTeam(team));
setPreviousTeamId.mockRestore();
});
test('should leave the last team alone while the rail is hidden, as on mobile', () => {
const setPreviousTeamId = jest.spyOn(LocalStorageStore, 'setPreviousTeamId').mockImplementation(() => {});
const testStore = configureStore({...railState, views: {browser: {windowSize: WindowSizes.MOBILE_VIEW}}});
const team = TestHelper.getTeamMock({id: 'team-two'});
testStore.dispatch(Actions.switchTeam('/test', team));
expect(setPreviousTeamId).not.toHaveBeenCalled();
expect(testStore.getActions()).toContainEqual(TeamActions.selectTeam(team));
setPreviousTeamId.mockRestore();
});
});
});
test('addUsersToTeam', () => {
+16 -1
View File
@@ -14,8 +14,12 @@ import {selectTeam} from 'mattermost-redux/actions/teams';
import {getUser} from 'mattermost-redux/actions/users';
import {Client4} from 'mattermost-redux/client';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {isProductRailEnabled} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getIsMobileView} from 'selectors/views/browser';
import LocalStorageStore from 'stores/local_storage_store';
import {getHistory} from 'utils/browser_history';
import {Preferences} from 'utils/constants';
@@ -90,13 +94,24 @@ export function addUsersToTeam(teamId: Team['id'], userIds: Array<UserProfile['i
}
export function switchTeam(url: string, team?: Team): ThunkActionFunc<void> {
return (dispatch) => {
return (dispatch, getState) => {
// In Channels, the team argument is undefined, and team switching is done by pushing a URL onto history.
// In other products, a team is passed instead of a URL because the current team isn't tied to the page URL.
//
// Note that url may also be a non-team URL since this is called when switching to the Create Team page
// from the team sidebar as well.
if (team) {
// The team controller records the last team on a team URL; a global product's page has
// none, so the product rail records it here, or a refresh brings back the earlier team.
// (The rail's own selectors would make an import cycle through utils/utils.)
const state = getState();
if (isProductRailEnabled(state) && !getIsMobileView(state)) {
try {
LocalStorageStore.setPreviousTeamId(getCurrentUserId(state), team.id);
} catch {
// Storage can be blocked (a private window, say); the team is still selected.
}
}
dispatch(selectTeam(team));
} else {
getHistory().push(url);
@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createMemoryHistory} from 'history';
import React from 'react';
import {renderWithContext, screen} from 'tests/react_testing_utils';
@@ -274,4 +275,20 @@ describe('ProductBranding', () => {
expect(screen.queryByText('Acme Chat')).not.toBeInTheDocument();
});
test('should keep the product name on backstage pages, which leave the rail out', () => {
const state = {
entities: {
general: {
...licensedState.entities.general,
config: {FeatureFlagProductRail: 'true', SiteName: 'Acme Chat'},
},
teams: {currentTeamId: 'team1'},
},
};
renderWithContext(<ProductBranding/>, state, {history: createMemoryHistory({initialEntries: ['/team1/integrations/bots']})});
expect(screen.queryByText('Acme Chat')).not.toBeInTheDocument();
});
});
@@ -6,14 +6,14 @@ import {useSelector} from 'react-redux';
import {getConfig, isFreeEdition as isFreeEditionSelector} from 'mattermost-redux/selectors/entities/general';
import {isProductRailShown} from 'selectors/views/product_rail';
import useProductRailShown from 'components/product_rail/use_product_rail_shown';
import ProductBrandingFreeEdition from './product_branding_free_edition';
import ProductBrandingLicensedEdition from './product_branding_licensed_edition';
export function ProductBranding() {
const isFreeEdition = useSelector(isFreeEditionSelector);
const productRailShown = useSelector(isProductRailShown);
const productRailShown = useProductRailShown();
const siteName = useSelector(getConfig).SiteName || 'Mattermost';
if (isFreeEdition) {
@@ -13,9 +13,8 @@ import {getConfig, isCloudLicense} from 'mattermost-redux/selectors/entities/gen
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {isProductRailShown} from 'selectors/views/product_rail';
import * as Menu from 'components/menu';
import useProductRailShown from 'components/product_rail/use_product_rail_shown';
import {isChannels} from 'utils/products';
@@ -71,8 +70,8 @@ export function SwitchProductMenu(props: Props) {
const isChannelsProductActive = isChannels(props.productId);
// The product rail lists Channels and every product, so the menu keeps only the rest. The rail
// needs a current team and isn't shown on mobile, so then the menu keeps them too.
const productRailShown = useSelector(isProductRailShown);
// needs a current team and isn't shown on mobile or backstage pages, so there the menu keeps them too.
const productRailShown = useProductRailShown();
useEffect(() => {
dispatch(getPrevTrialLicense());
@@ -139,6 +139,14 @@ describe('ProductRail', () => {
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
test('should render nothing on backstage pages', () => {
renderAt('/team-one/integrations', getState());
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
renderAt('/team-one/emoji/add', getState());
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
test('should link Channels and Threads to the current team', () => {
renderAt('/team-one/channels/town-square', getState());
@@ -10,6 +10,7 @@ import {Link, matchPath, useLocation} from 'react-router-dom';
import glyphMap, {MessageTextOutlineIcon, ProductBoardsIcon, ProductChannelsIcon} from '@mattermost/compass-icons/components';
import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs';
import {selectTeam} from 'mattermost-redux/actions/teams';
import {getThreadCounts} from 'mattermost-redux/actions/threads';
import {getCurrentChannel, getRedirectChannelNameForTeam, getTeamsUnreadStatuses} from 'mattermost-redux/selectors/entities/channels';
import {isProductRailEnabled} from 'mattermost-redux/selectors/entities/general';
@@ -27,12 +28,13 @@ import {
getProductRailLastPaths,
getProductRailSection,
getProductRailSectionUnreads,
getProductRailTeamIdToRestore,
areBoardsShownInChannels,
areProductRailLastPathsLoaded,
isTeamSidebarHidden,
} from 'selectors/views/product_rail';
import {TEAM_NAME_PATH_PATTERN} from 'utils/path';
import {isBackstagePath, TEAM_NAME_PATH_PATTERN} from 'utils/path';
import {getProductSwitcherLinkURL, useCurrentProduct} from 'utils/products';
import type {GlobalState} from 'types/store';
@@ -182,6 +184,15 @@ export default function ProductRail() {
}
}, [dispatch, enabled, threadsEnabled, currentUserId, currentTeamId]);
// A global product's page names no team, so loading one leaves none selected; pick the user's
// last, so the rail, the team sidebar and the team menu all have one.
const teamIdToRestore = useSelector(getProductRailTeamIdToRestore);
useEffect(() => {
if (enabled && currentProduct && teamIdToRestore) {
dispatch(selectTeam(teamIdToRestore));
}
}, [dispatch, enabled, currentProduct, teamIdToRestore]);
// Bring back where each item was in earlier sessions.
useEffect(() => {
if (enabled && currentUserId) {
@@ -214,7 +225,8 @@ export default function ProductRail() {
}
}, [enabled, currentUserId, lastPaths, lastPathsLoaded]);
if (!enabled || !currentTeam) {
// Backstage pages (Integrations, Custom Emoji) take the whole page, with their own way back.
if (!enabled || !currentTeam || isBackstagePath(pathname)) {
return null;
}
@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useSelector} from 'react-redux';
import {useLocation} from 'react-router-dom';
import {isProductRailShown} from 'selectors/views/product_rail';
import {isBackstagePath} from 'utils/path';
/** Whether the product rail is on screen: where the store allows it, except on backstage pages. */
export default function useProductRailShown(): boolean {
const shown = useSelector(isProductRailShown);
const {pathname} = useLocation();
return shown && !isBackstagePath(pathname);
}
@@ -1,9 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {defaultIntl} from 'tests/helpers/intl-test-helper';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import {getTeamSwitchURL} from './team_sidebar';
import {getTeamSwitchURL, TeamSidebar} from './team_sidebar';
import type {Props} from './team_sidebar';
describe('getTeamSwitchURL', () => {
it('returns the team-scoped product URL when in a team-scoped product', () => {
@@ -22,3 +27,82 @@ describe('getTeamSwitchURL', () => {
expect(getTeamSwitchURL(null, 'myteam')).toBe('/myteam');
});
});
describe('TeamSidebar', () => {
const teams = [
TestHelper.getTeamMock({id: 'team-one', name: 'team-one', display_name: 'Team One'}),
TestHelper.getTeamMock({id: 'team-two', name: 'team-two', display_name: 'Team Two'}),
];
const agents = {...TestHelper.makeProduct('agents'), baseURL: '/plug/agents', showTeamSidebar: false};
// The sidebar marks #root when there are teams to switch between.
beforeEach(() => {
const root = document.createElement('div');
root.id = 'root';
document.body.appendChild(root);
});
afterEach(() => {
document.getElementById('root')?.remove();
});
function renderAt(pathname: string, props: Partial<Props> = {}) {
const allProps: Props = {
currentTeamId: 'team-one',
myTeams: teams,
isOpen: false,
experimentalPrimaryTeam: undefined,
locale: 'en',
moreTeamsToJoin: false,
userTeamsOrderPreference: '',
products: [agents],
unreadTeamsSet: new Set(),
mentionsInTeamMap: new Map(),
teamHasUrgentMap: new Map(),
isProductRailShown: true,
isHiddenByProductRail: false,
actions: {
getTeams: jest.fn(),
switchTeam: jest.fn(),
updateTeamsOrderForUser: jest.fn(),
setTeamSidebarHidden: jest.fn(),
},
intl: defaultIntl,
location: {pathname, search: '', hash: '', state: undefined},
...props,
} as unknown as Props;
return renderWithContext(<TeamSidebar {...allProps}/>, {entities: {teams: {currentTeamId: 'team-one'}}});
}
test('should stay in a global product while the product rail is shown', () => {
const {container} = renderAt('/plug/agents');
expect(container.querySelector('.team-sidebar')).toBeInTheDocument();
});
test('should leave a global product that hides it alone without the rail', () => {
const {container} = renderAt('/plug/agents', {isProductRailShown: false});
expect(container.querySelector('.team-sidebar')).not.toBeInTheDocument();
});
test('should offer to hide it, for the rail team menu to stand in', () => {
renderAt('/team-one/channels/town-square');
expect(screen.getByRole('button', {name: 'Hide team sidebar'})).toBeInTheDocument();
});
test('should stay hidden where the rail team menu stands in for it', () => {
const {container} = renderAt('/team-one/channels/town-square', {isHiddenByProductRail: true});
expect(container.querySelector('.team-sidebar')).not.toBeInTheDocument();
});
test('should come back on backstage pages, which leave the rail out, without the hide button', () => {
const {container} = renderAt('/team-one/integrations', {isHiddenByProductRail: true});
expect(container.querySelector('.team-sidebar')).toBeInTheDocument();
expect(screen.queryByRole('button', {name: 'Hide team sidebar'})).not.toBeInTheDocument();
});
});
@@ -23,6 +23,7 @@ import WebSocketClient from 'client/web_websocket_client';
import Pluggable from 'plugins/pluggable';
import {Constants} from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import {isBackstagePath} from 'utils/path';
import {getCurrentProduct, getTeamScopedProductURL, isTeamScopedProduct} from 'utils/products';
import {filterAndSortTeamsByDisplayName} from 'utils/team_utils';
import * as Utils from 'utils/utils';
@@ -204,8 +205,11 @@ export class TeamSidebar extends React.PureComponent<Props, State> {
const {intl} = this.props;
const root: Element | null = document.querySelector('#root');
// Backstage pages leave the rail out, and its team menu with it.
const productRailShown = this.props.isProductRailShown && !isBackstagePath(this.props.location.pathname);
// The product rail's team menu stands in for a hidden team sidebar.
if (this.props.myTeams.length <= 1 || this.props.isHiddenByProductRail) {
if (this.props.myTeams.length <= 1 || (this.props.isHiddenByProductRail && productRailShown)) {
root!.classList.remove('multi-teams');
return null;
}
@@ -215,7 +219,10 @@ export class TeamSidebar extends React.PureComponent<Props, State> {
const sortedTeams = filterAndSortTeamsByDisplayName(this.props.myTeams, this.props.locale, this.props.userTeamsOrderPreference);
const currentProduct = getCurrentProduct(this.props.products, this.props.location.pathname);
if (currentProduct && !currentProduct.showTeamSidebar) {
// The rail keeps a team selected in every product, global ones (like Agents) included, so with
// it on screen the team sidebar stays too.
if (currentProduct && !currentProduct.showTeamSidebar && !productRailShown) {
return null;
}
@@ -313,7 +320,7 @@ export class TeamSidebar extends React.PureComponent<Props, State> {
// Not on mobile, where the rail (and the team menu that would bring the sidebar back) is hidden.
let hideButton;
if (this.props.isProductRailShown) {
if (productRailShown) {
const hideLabel = intl.formatMessage({
id: 'team_sidebar.hide',
defaultMessage: 'Hide team sidebar',
@@ -3,12 +3,14 @@
import type {DeepPartial} from '@mattermost/types/utilities';
import localStorageStore from 'stores/local_storage_store';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import type {ProductRailSection} from 'types/store/views';
import {filterChannelIdsForSection, getBoardChannelIds, getProductRailFilter, getProductRailSection, isProductRailShown, isTeamSidebarHidden, toFullListIndex} from './product_rail';
import {filterChannelIdsForSection, getBoardChannelIds, getProductRailFilter, getProductRailSection, getProductRailTeamIdToRestore, isProductRailShown, isTeamSidebarHidden, toFullListIndex} from './product_rail';
import type {ChannelKindRegistration} from './product_rail';
const channel = TestHelper.getChannelMock({id: 'channel'});
@@ -179,3 +181,62 @@ describe('isProductRailShown', () => {
expect(isProductRailShown(withView(getState(), {windowSize: 'mobileView'}))).toBe(false);
});
});
describe('getProductRailTeamIdToRestore', () => {
const alpha = TestHelper.getTeamMock({id: 'alpha', display_name: 'Alpha'});
const beta = TestHelper.getTeamMock({id: 'beta', display_name: 'Beta'});
const left = TestHelper.getTeamMock({id: 'left', display_name: 'Left'});
const withTeams = (currentTeamId = '') => {
const state = getState();
return {
...state,
entities: {
...state.entities,
users: {currentUserId: 'user', profiles: {user: TestHelper.getUserMock({id: 'user', locale: 'en'})}},
teams: {
currentTeamId,
teams: {alpha, beta, left},
myMembers: {alpha: {team_id: 'alpha'}, beta: {team_id: 'beta'}},
},
},
} as unknown as GlobalState;
};
afterEach(() => {
localStorage.clear();
});
test('should restore nothing while a team is selected', () => {
localStorageStore.setPreviousTeamId('user', 'beta');
expect(getProductRailTeamIdToRestore(withTeams('alpha'))).toBe('');
});
test('should restore the team the user was last in', () => {
localStorageStore.setPreviousTeamId('user', 'beta');
expect(getProductRailTeamIdToRestore(withTeams())).toBe('beta');
});
test('should fall back to the first of the user\'s teams for a team they\'ve left, or none', () => {
localStorageStore.setPreviousTeamId('user', 'left');
expect(getProductRailTeamIdToRestore(withTeams())).toBe('alpha');
});
test('should fall back to the first team when storage can\'t be read', () => {
const getItem = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
expect(getProductRailTeamIdToRestore(withTeams())).toBe('alpha');
getItem.mockRestore();
});
test('should restore nothing where the rail never shows', () => {
localStorageStore.setPreviousTeamId('user', 'beta');
const state = withTeams();
expect(getProductRailTeamIdToRestore({...state, views: {...state.views, browser: {windowSize: 'mobileView'}}} as GlobalState)).toBe('');
expect(getProductRailTeamIdToRestore({
...state,
entities: {...state.entities, general: {...state.entities.general, config: {FeatureFlagProductRail: 'false'}}},
} as GlobalState)).toBe('');
});
});
@@ -8,9 +8,11 @@ import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {getAllChannels, getMyChannelMemberships} from 'mattermost-redux/selectors/entities/channels';
import {isProductRailEnabled} from 'mattermost-redux/selectors/entities/general';
import {getBool, isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentTeamId, getMySortedTeamIds} from 'mattermost-redux/selectors/entities/teams';
import {calculateUnreadCount} from 'mattermost-redux/utils/channel_utils';
import {getCurrentLocale} from 'selectors/i18n';
import {getPreviousTeamId} from 'selectors/local_storage';
import {getIsMobileView} from 'selectors/views/browser';
import {Preferences} from 'utils/constants';
@@ -138,6 +140,30 @@ export function isProductRailShown(state: GlobalState): boolean {
return isProductRailEnabled(state) && Boolean(getCurrentTeamId(state)) && !getIsMobileView(state);
}
/**
* The team to select when none is, as after loading a global product's page (Agents, say), whose
* URL names no team: the last one the user was in, or else their first. Empty while a team is
* selected, and where the rail never shows (flag off, mobile).
*/
export function getProductRailTeamIdToRestore(state: GlobalState): string {
if (!isProductRailEnabled(state) || getIsMobileView(state) || getCurrentTeamId(state)) {
return '';
}
const myTeamIds = getMySortedTeamIds(state, getCurrentLocale(state));
// Storage can be blocked (a private window, say); the first team will do then.
let previousTeamId: string | null = null;
try {
previousTeamId = getPreviousTeamId(state);
} catch {
// Fall through to the first team.
}
if (previousTeamId && myTeamIds.includes(previousTeamId)) {
return previousTeamId;
}
return myTeamIds[0] ?? '';
}
/** Whether the user has hidden the team sidebar, so the product rail shows a team menu in its place. */
export function isTeamSidebarHidden(state: GlobalState): boolean {
// On mobile the rail, and its team menu, are hidden, so the team sidebar stays to switch teams.
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {isBackstagePath} from './path';
describe('isBackstagePath', () => {
test.each([
'/team-one/integrations',
'/team-one/integrations/bots/add',
'/team-one/emoji',
'/team-one/emoji/add',
])('should match %s', (pathname) => {
expect(isBackstagePath(pathname)).toBe(true);
});
test.each([
'/team-one/channels/integrations',
'/team-one/channels/town-square',
'/team-one/integrationsfoo',
'/admin_console/integrations/bot_accounts',
'/plug/mattermost-ai/agents',
])('should not match %s', (pathname) => {
expect(isBackstagePath(pathname)).toBe(false);
});
});
+8
View File
@@ -18,3 +18,11 @@ export const TEAM_NAME_PATH_PATTERN = '[a-z0-9\\-_]+';
// - User ID
// - Email
export const IDENTIFIER_PATH_PATTERN = '[@a-zA-Z\\-_0-9][@a-zA-Z\\-_0-9.:]*';
// The System Console is no team, though it has integrations pages too.
const BACKSTAGE_PATH_PATTERN = new RegExp(`^/(?!admin_console/)${TEAM_NAME_PATH_PATTERN}/(integrations|emoji)(/|$)`);
/** Whether the path is a backstage page (Integrations, Custom Emoji), which has its own full-page layout. */
export function isBackstagePath(pathname: string): boolean {
return BACKSTAGE_PATH_PATTERN.test(pathname);
}