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?
This commit is contained in:
Nick Misasi 2026-02-03 10:45:02 -05:00 committed by GitHub
parent f3c6602725
commit aa5d51131d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 269 additions and 11 deletions

View file

@ -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`] = `
<ProductBrandingContainer
tabIndex={-1}
>
<ProductChannelsIcon
size={24}
/>
<h1
className="sr-only"
>
InvalidProduct
</h1>
<ProductBrandingHeading>
InvalidProduct
</ProductBrandingHeading>
</ProductBrandingContainer>
`;
exports[`components/ProductBranding should render a React element icon when switcherIcon is a React node 1`] = `
<ProductBrandingContainer
tabIndex={-1}
>
<svg
data-testid="custom-icon"
>
<circle
cx="12"
cy="12"
r="10"
/>
</svg>
<h1
className="sr-only"
>
CustomProduct
</h1>
<ProductBrandingHeading>
CustomProduct
</ProductBrandingHeading>
</ProductBrandingContainer>
`;
exports[`components/ProductBranding should show correct icon glyph when we are on Boards 1`] = `
<ProductBrandingContainer
tabIndex={-1}

View file

@ -8,6 +8,8 @@ import {TopLevelProducts} from 'utils/constants';
import * as productUtils from 'utils/products';
import {TestHelper} from 'utils/test_helper';
import type {ProductComponent} from 'types/store/plugins';
import ProductBranding from './product_branding';
describe('components/ProductBranding', () => {
@ -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 = (
<svg data-testid='custom-icon'>
<circle
cx='12'
cy='12'
r='10'
/>
</svg>
);
const productWithReactIcon: ProductComponent = {
...TestHelper.makeProduct('CustomProduct'),
switcherIcon: CustomIcon,
};
currentProductSpy.mockReturnValue(productWithReactIcon);
const wrapper = shallow(
<ProductBranding/>,
);
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(
<ProductBranding/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('ProductChannelsIcon').exists()).toBe(true);
});
});

View file

@ -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 <ProductChannelsIcon size={24}/>;
}
if (typeof currentProduct.switcherIcon === 'string') {
const Icon = glyphMap[currentProduct.switcherIcon as IconGlyphTypes];
if (Icon) {
return <Icon size={24}/>;
}
// Fallback if icon name not found in glyphMap
return <ProductChannelsIcon size={24}/>;
}
// React element - render directly
return <>{currentProduct.switcherIcon}</>;
};
return (
<ProductBrandingContainer tabIndex={-1}>
<Icon size={24}/>
{renderIcon()}
<h1 className='sr-only'>
{currentProduct ? currentProduct.switcherText : 'Channels'}
</h1>

View file

@ -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(<ProductMenuItem {...defaultProps}/>);
expect(screen.getByRole('menuitem')).toBeInTheDocument();
expect(screen.getByText('Test Product')).toBeInTheDocument();
});
test('should render with string icon from glyphMap', () => {
renderWithContext(<ProductMenuItem {...defaultProps}/>);
// 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 = (
<svg data-testid='custom-svg-icon'>
<rect
width='24'
height='24'
/>
</svg>
);
const props: ProductMenuItemProps = {
...defaultProps,
icon: CustomIcon,
};
renderWithContext(<ProductMenuItem {...props}/>);
expect(screen.getByTestId('custom-svg-icon')).toBeInTheDocument();
});
test('should show check icon when active is true', () => {
const props: ProductMenuItemProps = {
...defaultProps,
active: true,
};
renderWithContext(<ProductMenuItem {...props}/>);
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(<ProductMenuItem {...defaultProps}/>);
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(<ProductMenuItem {...props}/>);
await userEvent.click(screen.getByRole('menuitem'));
expect(onClick).toHaveBeenCalledTimes(1);
});
test('should render tour tip when provided', () => {
const tourTipContent = 'Tour tip content';
const TourTip = <div data-testid='tour-tip'>{tourTipContent}</div>;
const props: ProductMenuItemProps = {
...defaultProps,
tourTip: TourTip,
};
renderWithContext(<ProductMenuItem {...props}/>);
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(<ProductMenuItem {...props}/>);
expect(screen.getByRole('menuitem')).toHaveAttribute('id', 'test-menu-item-id');
});
test('should render with correct destination link', () => {
renderWithContext(<ProductMenuItem {...defaultProps}/>);
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 = () => <span data-testid='custom-component-icon'>{customIconText}</span>;
const props: ProductMenuItemProps = {
...defaultProps,
icon: <CustomIconComponent/>,
};
renderWithContext(<ProductMenuItem {...props}/>);
expect(screen.getByTestId('custom-component-icon')).toBeInTheDocument();
expect(screen.getByText(customIconText)).toBeInTheDocument();
});
});

View file

@ -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 (
<MenuItem
@ -63,10 +63,14 @@ const ProductMenuItem = ({icon, destination, text, active, onClick, tourTip, id}
id={id}
role='menuitem'
>
<ProductIcon
size={24}
color={'var(--button-bg)'}
/>
{ProductIcon ? (
<ProductIcon
size={24}
color={'var(--button-bg)'}
/>
) : (
icon
)}
<MenuItemTextContainer>
{text}
</MenuItemTextContainer>

View file

@ -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),

View file

@ -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