mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-03 20:40:00 -05:00
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:
parent
f3c6602725
commit
aa5d51131d
7 changed files with 269 additions and 11 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue