From 33437b7ef6e4510a07ca041b64e832556f05ec31 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:42:36 -0400 Subject: [PATCH] [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 --- .../__snapshots__/product_menu.test.tsx.snap | 160 ++++++++++++++++-- .../product_menu_item.test.tsx | 38 ++++- .../product_menu_item/product_menu_item.tsx | 67 ++++++-- webapp/channels/src/i18n/en.json | 1 + 4 files changed, 238 insertions(+), 28 deletions(-) diff --git a/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap b/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap index 60611824377..7c710d9cd85 100644 --- a/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap +++ b/webapp/channels/src/components/global_header/left_controls/product_menu/__snapshots__/product_menu.test.tsx.snap @@ -46,7 +46,7 @@ exports[`components/global/product_switcher should have an active button state w role="menu" > @@ -86,7 +86,7 @@ exports[`components/global/product_switcher should have an active button state w Boards + Playbooks +
@@ -328,7 +362,7 @@ exports[`components/global/product_switcher should match snapshot with product s Boards
+ Playbooks +
@@ -531,7 +599,7 @@ exports[`components/global/product_switcher should render once when there are no Boards
+ Playbooks +
@@ -695,7 +797,7 @@ exports[`components/global/product_switcher should render the correct amount of Boards
+ Playbooks +
; 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(); - 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(); + + 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(); + + 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 =
{tourTipContent}
; diff --git a/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx b/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx index c343a077d6e..c97cd34284c 100644 --- a/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx +++ b/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.tsx @@ -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 ( {text} - {active && ( + {active ? ( + ) : ( + + + + + )} {tourTip || null} diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 3b67749402a..d5b8d2c6889 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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?",