From aa5d51131da6f6e19cbb26a0e7e83f00f9d0ef0e Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Tue, 3 Feb 2026 10:45:02 -0500 Subject: [PATCH] Register product icon change (#34883) * Adjust registerProduct to accept generic icon * Undo unnecessary changes * Anotha one * Fix linting errors in product menu tests - Fix jsx-quotes to use single quotes instead of double quotes - Fix react/jsx-max-props-per-line by placing props on separate lines - Fix react/jsx-no-literals by wrapping literal strings in JSX expression containers - Fix react/jsx-wrap-multilines by wrapping multiline JSX in parentheses - Fix react/jsx-closing-bracket-location for proper bracket alignment * don't use snapshots or enzyme * Fix pipelines? --- .../product_branding.test.tsx.snap | 42 +++++ .../product_branding.test.tsx | 43 +++++ .../product_branding/product_branding.tsx | 23 ++- .../product_menu_item.test.tsx | 147 ++++++++++++++++++ .../product_menu_item/product_menu_item.tsx | 16 +- webapp/channels/src/plugins/registry.ts | 2 +- webapp/channels/src/types/store/plugins.ts | 7 +- 7 files changed, 269 insertions(+), 11 deletions(-) create mode 100644 webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.test.tsx diff --git a/webapp/channels/src/components/global_header/left_controls/product_menu/product_branding/__snapshots__/product_branding.test.tsx.snap b/webapp/channels/src/components/global_header/left_controls/product_menu/product_branding/__snapshots__/product_branding.test.tsx.snap index 17892495825..1364bd6e568 100644 --- a/webapp/channels/src/components/global_header/left_controls/product_menu/product_branding/__snapshots__/product_branding.test.tsx.snap +++ b/webapp/channels/src/components/global_header/left_controls/product_menu/product_branding/__snapshots__/product_branding.test.tsx.snap @@ -1,5 +1,47 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`components/ProductBranding should fallback to ProductChannelsIcon when string icon name is not found in glyphMap 1`] = ` + + +

+ InvalidProduct +

+ + InvalidProduct + +
+`; + +exports[`components/ProductBranding should render a React element icon when switcherIcon is a React node 1`] = ` + + + + +

+ CustomProduct +

+ + CustomProduct + +
+`; + exports[`components/ProductBranding should show correct icon glyph when we are on Boards 1`] = ` { @@ -42,4 +44,45 @@ describe('components/ProductBranding', () => { expect(wrapper).toMatchSnapshot(); }); + + test('should render a React element icon when switcherIcon is a React node', () => { + const currentProductSpy = jest.spyOn(productUtils, 'useCurrentProduct'); + const CustomIcon = ( + + + + ); + const productWithReactIcon: ProductComponent = { + ...TestHelper.makeProduct('CustomProduct'), + switcherIcon: CustomIcon, + }; + currentProductSpy.mockReturnValue(productWithReactIcon); + + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('[data-testid="custom-icon"]').exists()).toBe(true); + }); + + test('should fallback to ProductChannelsIcon when string icon name is not found in glyphMap', () => { + const currentProductSpy = jest.spyOn(productUtils, 'useCurrentProduct'); + const productWithInvalidIcon: ProductComponent = { + ...TestHelper.makeProduct('InvalidProduct'), + switcherIcon: 'non-existent-icon-name' as ProductComponent['switcherIcon'], + }; + currentProductSpy.mockReturnValue(productWithInvalidIcon); + + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('ProductChannelsIcon').exists()).toBe(true); + }); }); diff --git a/webapp/channels/src/components/global_header/left_controls/product_menu/product_branding/product_branding.tsx b/webapp/channels/src/components/global_header/left_controls/product_menu/product_branding/product_branding.tsx index 2a977226835..e1aa07a1f58 100644 --- a/webapp/channels/src/components/global_header/left_controls/product_menu/product_branding/product_branding.tsx +++ b/webapp/channels/src/components/global_header/left_controls/product_menu/product_branding/product_branding.tsx @@ -5,6 +5,7 @@ import React from 'react'; import styled from 'styled-components'; import glyphMap, {ProductChannelsIcon} from '@mattermost/compass-icons/components'; +import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs'; import {useCurrentProduct} from 'utils/products'; @@ -27,11 +28,29 @@ const ProductBrandingHeading = styled.span` const ProductBranding = (): JSX.Element => { const currentProduct = useCurrentProduct(); - const Icon = currentProduct?.switcherIcon ? glyphMap[currentProduct.switcherIcon] : ProductChannelsIcon; + // Handle both string icon names and React elements + const renderIcon = () => { + if (!currentProduct?.switcherIcon) { + return ; + } + + if (typeof currentProduct.switcherIcon === 'string') { + const Icon = glyphMap[currentProduct.switcherIcon as IconGlyphTypes]; + if (Icon) { + return ; + } + + // Fallback if icon name not found in glyphMap + return ; + } + + // React element - render directly + return <>{currentProduct.switcherIcon}; + }; return ( - + {renderIcon()}

{currentProduct ? currentProduct.switcherText : 'Channels'}

diff --git a/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.test.tsx b/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.test.tsx new file mode 100644 index 00000000000..fdef21fb96b --- /dev/null +++ b/webapp/channels/src/components/global_header/left_controls/product_menu/product_menu_item/product_menu_item.test.tsx @@ -0,0 +1,147 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +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'; + +describe('components/ProductMenuItem', () => { + const defaultProps: ProductMenuItemProps = { + destination: '/test-destination', + icon: 'product-channels', + text: 'Test Product', + active: false, + onClick: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should render menu item with correct text', () => { + renderWithContext(); + + expect(screen.getByRole('menuitem')).toBeInTheDocument(); + expect(screen.getByText('Test Product')).toBeInTheDocument(); + }); + + test('should render with string icon from glyphMap', () => { + renderWithContext(); + + // When icon is a string, the component looks up the glyph from glyphMap + // The icon should be rendered with proper styling + const menuItem = screen.getByRole('menuitem'); + expect(menuItem).toBeInTheDocument(); + + // The ProductChannelsIcon should be rendered (via glyphMap lookup) + // We can verify the menu item contains an svg element + expect(menuItem.querySelector('svg')).toBeInTheDocument(); + }); + + test('should render with React element icon', () => { + const CustomIcon = ( + + + + ); + const props: ProductMenuItemProps = { + ...defaultProps, + icon: CustomIcon, + }; + + renderWithContext(); + + expect(screen.getByTestId('custom-svg-icon')).toBeInTheDocument(); + }); + + test('should show check icon when active is true', () => { + const props: ProductMenuItemProps = { + ...defaultProps, + active: true, + }; + + renderWithContext(); + + const menuItem = screen.getByRole('menuitem'); + + // When active, there should be two SVG elements: the product icon and the check icon + const svgElements = menuItem.querySelectorAll('svg'); + expect(svgElements.length).toBe(2); + }); + + test('should not show check icon when active is false', () => { + renderWithContext(); + + const menuItem = screen.getByRole('menuitem'); + + // 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 call onClick when clicked', async () => { + const onClick = jest.fn(); + const props: ProductMenuItemProps = { + ...defaultProps, + onClick, + }; + + renderWithContext(); + + await userEvent.click(screen.getByRole('menuitem')); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test('should render tour tip when provided', () => { + const tourTipContent = 'Tour tip content'; + const TourTip =
{tourTipContent}
; + const props: ProductMenuItemProps = { + ...defaultProps, + tourTip: TourTip, + }; + + renderWithContext(); + + expect(screen.getByTestId('tour-tip')).toBeInTheDocument(); + expect(screen.getByText(tourTipContent)).toBeInTheDocument(); + }); + + test('should pass correct id to menu item', () => { + const props: ProductMenuItemProps = { + ...defaultProps, + id: 'test-menu-item-id', + }; + + renderWithContext(); + + expect(screen.getByRole('menuitem')).toHaveAttribute('id', 'test-menu-item-id'); + }); + + test('should render with correct destination link', () => { + renderWithContext(); + + const menuItem = screen.getByRole('menuitem'); + expect(menuItem).toHaveAttribute('href', '/test-destination'); + }); + + test('should render custom React component as icon', () => { + const customIconText = 'Custom Icon'; + const CustomIconComponent = () => {customIconText}; + const props: ProductMenuItemProps = { + ...defaultProps, + icon: , + }; + + renderWithContext(); + + expect(screen.getByTestId('custom-component-icon')).toBeInTheDocument(); + expect(screen.getByText(customIconText)).toBeInTheDocument(); + }); +}); 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 d0699d9ce18..c343a077d6e 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 @@ -10,7 +10,7 @@ import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs'; export interface ProductMenuItemProps { destination: string; - icon: IconGlyphTypes; + icon: IconGlyphTypes | React.ReactNode; text: React.ReactNode; active: boolean; onClick: () => void; @@ -54,7 +54,7 @@ const MenuItemTextContainer = styled.div` `; const ProductMenuItem = ({icon, destination, text, active, onClick, tourTip, id}: ProductMenuItemProps): JSX.Element => { - const ProductIcon = glyphMap[icon]; + const ProductIcon = typeof icon === 'string' ? glyphMap[icon as IconGlyphTypes] : null; return ( - + {ProductIcon ? ( + + ) : ( + icon + )} {text} diff --git a/webapp/channels/src/plugins/registry.ts b/webapp/channels/src/plugins/registry.ts index 0ee238352ef..36518dc305e 100644 --- a/webapp/channels/src/plugins/registry.ts +++ b/webapp/channels/src/plugins/registry.ts @@ -1183,7 +1183,7 @@ export default class PluginRegistry { dispatchPluginComponentWithData('Product', { id, pluginId: this.id, - switcherIcon, + switcherIcon: resolveReactElement(switcherIcon), switcherText: resolveReactElement(switcherText), baseURL: '/' + standardizeRoute(baseURL), switcherLinkURL: '/' + standardizeRoute(switcherLinkURL), diff --git a/webapp/channels/src/types/store/plugins.ts b/webapp/channels/src/types/store/plugins.ts index e4ec7445844..499d4a311f1 100644 --- a/webapp/channels/src/types/store/plugins.ts +++ b/webapp/channels/src/types/store/plugins.ts @@ -244,9 +244,12 @@ export type ProductSubComponentNames = 'mainComponent' | 'publicComponent' | 'he export type ProductComponent = PluginComponent & { /** - * A compass-icon glyph to display as the icon in the product switcher + * A compass-icon glyph name or React element to display as the icon in the product switcher. + * Accepts either: + * - IconGlyphTypes: A string name from the Compass Icons library (e.g., 'product-channels') + * - React.ReactNode: A custom React element to render as the icon */ - switcherIcon: IconGlyphTypes; + switcherIcon: IconGlyphTypes | React.ReactNode; /** * A string or React element to display in the product switcher