[MM-67883] Add "Open in new tab" button to Product Switcher menu items (#35560)

* [MM-67883] Add buttons for Product Switcher to pop into new tabs

* PR feedback

* Fix snaps

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Devin Binnie
2026-03-13 18:42:36 -04:00
committed by GitHub
co-authored by Mattermost Build
parent a744a75805
commit 33437b7ef6
4 changed files with 238 additions and 28 deletions
@@ -46,7 +46,7 @@ exports[`components/global/product_switcher should have an active button state w
role="menu"
>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
role="menuitem"
>
@@ -86,7 +86,7 @@ exports[`components/global/product_switcher should have an active button state w
</svg>
</a>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
id="product-menu-item-Boards"
role="menuitem"
@@ -121,9 +121,26 @@ exports[`components/global/product_switcher should have an active button state w
>
Boards
</div>
<button
aria-label="Open in new tab"
class="OpenInNewTabButton-fKoVVp cLFfqU"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"
/>
</svg>
</button>
</a>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
id="product-menu-item-Playbooks"
role="menuitem"
@@ -148,6 +165,23 @@ exports[`components/global/product_switcher should have an active button state w
>
Playbooks
</div>
<button
aria-label="Open in new tab"
class="OpenInNewTabButton-fKoVVp cLFfqU"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"
/>
</svg>
</button>
</a>
<div
data-testid="product-menu-list"
@@ -288,7 +322,7 @@ exports[`components/global/product_switcher should match snapshot with product s
role="menu"
>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
role="menuitem"
>
@@ -328,7 +362,7 @@ exports[`components/global/product_switcher should match snapshot with product s
</svg>
</a>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
id="product-menu-item-Boards"
role="menuitem"
@@ -363,9 +397,26 @@ exports[`components/global/product_switcher should match snapshot with product s
>
Boards
</div>
<button
aria-label="Open in new tab"
class="OpenInNewTabButton-fKoVVp cLFfqU"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"
/>
</svg>
</button>
</a>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
id="product-menu-item-Playbooks"
role="menuitem"
@@ -390,6 +441,23 @@ exports[`components/global/product_switcher should match snapshot with product s
>
Playbooks
</div>
<button
aria-label="Open in new tab"
class="OpenInNewTabButton-fKoVVp cLFfqU"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"
/>
</svg>
</button>
</a>
<div
data-testid="product-menu-list"
@@ -491,7 +559,7 @@ exports[`components/global/product_switcher should render once when there are no
role="menu"
>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
role="menuitem"
>
@@ -531,7 +599,7 @@ exports[`components/global/product_switcher should render once when there are no
</svg>
</a>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
id="product-menu-item-Boards"
role="menuitem"
@@ -566,9 +634,26 @@ exports[`components/global/product_switcher should render once when there are no
>
Boards
</div>
<button
aria-label="Open in new tab"
class="OpenInNewTabButton-fKoVVp cLFfqU"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"
/>
</svg>
</button>
</a>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
id="product-menu-item-Playbooks"
role="menuitem"
@@ -593,6 +678,23 @@ exports[`components/global/product_switcher should render once when there are no
>
Playbooks
</div>
<button
aria-label="Open in new tab"
class="OpenInNewTabButton-fKoVVp cLFfqU"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"
/>
</svg>
</button>
</a>
<div
data-testid="product-menu-list"
@@ -655,7 +757,7 @@ exports[`components/global/product_switcher should render the correct amount of
role="menu"
>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
role="menuitem"
>
@@ -695,7 +797,7 @@ exports[`components/global/product_switcher should render the correct amount of
</svg>
</a>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
id="product-menu-item-Boards"
role="menuitem"
@@ -730,9 +832,26 @@ exports[`components/global/product_switcher should render the correct amount of
>
Boards
</div>
<button
aria-label="Open in new tab"
class="OpenInNewTabButton-fKoVVp cLFfqU"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"
/>
</svg>
</button>
</a>
<a
class="MenuItem-fJA-dRx RqdsE"
class="MenuItem-fJA-dRx eXCaei"
href="/"
id="product-menu-item-Playbooks"
role="menuitem"
@@ -757,6 +876,23 @@ exports[`components/global/product_switcher should render the correct amount of
>
Playbooks
</div>
<button
aria-label="Open in new tab"
class="OpenInNewTabButton-fKoVVp cLFfqU"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"
/>
</svg>
</button>
</a>
<div
data-testid="product-menu-list"
@@ -6,7 +6,8 @@ import React from 'react';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import ProductMenuItem from './product_menu_item';
import type {ProductMenuItemProps} from './product_menu_item';
type ProductMenuItemProps = React.ComponentProps<typeof ProductMenuItem>;
describe('components/ProductMenuItem', () => {
const defaultProps: ProductMenuItemProps = {
@@ -75,14 +76,21 @@ describe('components/ProductMenuItem', () => {
expect(svgElements.length).toBe(2);
});
test('should not show check icon when active is false', () => {
test('should show open in new tab button when active is false', () => {
renderWithContext(<ProductMenuItem {...defaultProps}/>);
const menuItem = screen.getByRole('menuitem');
expect(screen.getByLabelText('Open in new tab')).toBeInTheDocument();
});
// When not active, there should only be one SVG element: the product icon
const svgElements = menuItem.querySelectorAll('svg');
expect(svgElements.length).toBe(1);
test('should not show open in new tab button when active is true', () => {
const props: ProductMenuItemProps = {
...defaultProps,
active: true,
};
renderWithContext(<ProductMenuItem {...props}/>);
expect(screen.queryByLabelText('Open in new tab')).not.toBeInTheDocument();
});
test('should call onClick when clicked', async () => {
@@ -99,6 +107,24 @@ describe('components/ProductMenuItem', () => {
expect(onClick).toHaveBeenCalledTimes(1);
});
test('should open destination in new tab when open in new tab button is clicked', async () => {
const onClick = jest.fn();
const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation();
const props: ProductMenuItemProps = {
...defaultProps,
onClick,
};
renderWithContext(<ProductMenuItem {...props}/>);
await userEvent.click(screen.getByLabelText('Open in new tab'), {pointerEventsCheck: 0});
expect(windowOpenSpy).toHaveBeenCalledWith('/test-destination', '_blank', 'noopener,noreferrer');
expect(onClick).toHaveBeenCalledTimes(1);
windowOpenSpy.mockRestore();
});
test('should render tour tip when provided', () => {
const tourTipContent = 'Tour tip content';
const TourTip = <div data-testid='tour-tip'>{tourTipContent}</div>;
@@ -1,13 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Link} from 'react-router-dom';
import styled from 'styled-components';
import glyphMap, {CheckIcon} from '@mattermost/compass-icons/components';
import glyphMap, {CheckIcon, OpenInNewIcon} from '@mattermost/compass-icons/components';
import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs';
import WithTooltip from 'components/with_tooltip';
export interface ProductMenuItemProps {
destination: string;
icon: IconGlyphTypes | React.ReactNode;
@@ -19,6 +22,34 @@ export interface ProductMenuItemProps {
id?: string;
}
const MenuItemTextContainer = styled.div`
margin-left: 8px;
flex-grow: 1;
font-weight: 600;
font-size: 14px;
line-height: 20px;
`;
const OpenInNewTabButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: rgba(var(--center-channel-color-rgb), 0.56);
padding: 6px !important;
margin-right: -6px;
opacity: 0;
pointer-events: none;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.72);
}
`;
const MenuItem = styled(Link)`
&& {
text-decoration: none;
@@ -43,19 +74,24 @@ const MenuItem = styled(Link)`
button {
padding: 0 6px;
}
`;
const MenuItemTextContainer = styled.div`
margin-left: 8px;
flex-grow: 1;
font-weight: 600;
font-size: 14px;
line-height: 20px;
&:hover ${OpenInNewTabButton} {
opacity: 1;
pointer-events: auto;
}
`;
const ProductMenuItem = ({icon, destination, text, active, onClick, tourTip, id}: ProductMenuItemProps): JSX.Element => {
const {formatMessage} = useIntl();
const ProductIcon = typeof icon === 'string' ? glyphMap[icon as IconGlyphTypes] : null;
const handleOpenInNewTab = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
window.open(destination, '_blank', 'noopener,noreferrer');
onClick();
}, [destination, onClick]);
return (
<MenuItem
to={destination}
@@ -74,11 +110,22 @@ const ProductMenuItem = ({icon, destination, text, active, onClick, tourTip, id}
<MenuItemTextContainer>
{text}
</MenuItemTextContainer>
{active && (
{active ? (
<CheckIcon
size={18}
color={'var(--button-bg)'}
/>
) : (
<WithTooltip
title={formatMessage({id: 'product_menu_item.open_in_new_tab', defaultMessage: 'Open in new tab'})}
>
<OpenInNewTabButton
onClick={handleOpenInNewTab}
aria-label={formatMessage({id: 'product_menu_item.open_in_new_tab', defaultMessage: 'Open in new tab'})}
>
<OpenInNewIcon size={16}/>
</OpenInNewTabButton>
</WithTooltip>
)}
{tourTip || null}
</MenuItem>
+1
View File
@@ -5684,6 +5684,7 @@
"pricing_modal.plan_label_trialDays": "{days} DAYS LEFT ON TRIAL",
"pricing_modal.wantToTry": "Want to try? ",
"pricing_modal.wantToUpgrade": "Want to upgrade? ",
"product_menu_item.open_in_new_tab": "Open in new tab",
"profile_popover.aria_label.with_username": "{userName}'s profile popover",
"profile_popover.aria_label.without_username": "profile popover",
"promote_to_user_modal.desc": "This action promotes the guest {username} to a member. It will allow the user to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to promote guest {username} to member?",