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
|
// 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`] = `
|
exports[`components/ProductBranding should show correct icon glyph when we are on Boards 1`] = `
|
||||||
<ProductBrandingContainer
|
<ProductBrandingContainer
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {TopLevelProducts} from 'utils/constants';
|
||||||
import * as productUtils from 'utils/products';
|
import * as productUtils from 'utils/products';
|
||||||
import {TestHelper} from 'utils/test_helper';
|
import {TestHelper} from 'utils/test_helper';
|
||||||
|
|
||||||
|
import type {ProductComponent} from 'types/store/plugins';
|
||||||
|
|
||||||
import ProductBranding from './product_branding';
|
import ProductBranding from './product_branding';
|
||||||
|
|
||||||
describe('components/ProductBranding', () => {
|
describe('components/ProductBranding', () => {
|
||||||
|
|
@ -42,4 +44,45 @@ describe('components/ProductBranding', () => {
|
||||||
|
|
||||||
expect(wrapper).toMatchSnapshot();
|
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 styled from 'styled-components';
|
||||||
|
|
||||||
import glyphMap, {ProductChannelsIcon} from '@mattermost/compass-icons/components';
|
import glyphMap, {ProductChannelsIcon} from '@mattermost/compass-icons/components';
|
||||||
|
import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs';
|
||||||
|
|
||||||
import {useCurrentProduct} from 'utils/products';
|
import {useCurrentProduct} from 'utils/products';
|
||||||
|
|
||||||
|
|
@ -27,11 +28,29 @@ const ProductBrandingHeading = styled.span`
|
||||||
const ProductBranding = (): JSX.Element => {
|
const ProductBranding = (): JSX.Element => {
|
||||||
const currentProduct = useCurrentProduct();
|
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 (
|
return (
|
||||||
<ProductBrandingContainer tabIndex={-1}>
|
<ProductBrandingContainer tabIndex={-1}>
|
||||||
<Icon size={24}/>
|
{renderIcon()}
|
||||||
<h1 className='sr-only'>
|
<h1 className='sr-only'>
|
||||||
{currentProduct ? currentProduct.switcherText : 'Channels'}
|
{currentProduct ? currentProduct.switcherText : 'Channels'}
|
||||||
</h1>
|
</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 {
|
export interface ProductMenuItemProps {
|
||||||
destination: string;
|
destination: string;
|
||||||
icon: IconGlyphTypes;
|
icon: IconGlyphTypes | React.ReactNode;
|
||||||
text: React.ReactNode;
|
text: React.ReactNode;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
|
@ -54,7 +54,7 @@ const MenuItemTextContainer = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ProductMenuItem = ({icon, destination, text, active, onClick, tourTip, id}: ProductMenuItemProps): JSX.Element => {
|
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 (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|
@ -63,10 +63,14 @@ const ProductMenuItem = ({icon, destination, text, active, onClick, tourTip, id}
|
||||||
id={id}
|
id={id}
|
||||||
role='menuitem'
|
role='menuitem'
|
||||||
>
|
>
|
||||||
<ProductIcon
|
{ProductIcon ? (
|
||||||
size={24}
|
<ProductIcon
|
||||||
color={'var(--button-bg)'}
|
size={24}
|
||||||
/>
|
color={'var(--button-bg)'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
icon
|
||||||
|
)}
|
||||||
<MenuItemTextContainer>
|
<MenuItemTextContainer>
|
||||||
{text}
|
{text}
|
||||||
</MenuItemTextContainer>
|
</MenuItemTextContainer>
|
||||||
|
|
|
||||||
|
|
@ -1183,7 +1183,7 @@ export default class PluginRegistry {
|
||||||
dispatchPluginComponentWithData('Product', {
|
dispatchPluginComponentWithData('Product', {
|
||||||
id,
|
id,
|
||||||
pluginId: this.id,
|
pluginId: this.id,
|
||||||
switcherIcon,
|
switcherIcon: resolveReactElement(switcherIcon),
|
||||||
switcherText: resolveReactElement(switcherText),
|
switcherText: resolveReactElement(switcherText),
|
||||||
baseURL: '/' + standardizeRoute(baseURL),
|
baseURL: '/' + standardizeRoute(baseURL),
|
||||||
switcherLinkURL: '/' + standardizeRoute(switcherLinkURL),
|
switcherLinkURL: '/' + standardizeRoute(switcherLinkURL),
|
||||||
|
|
|
||||||
|
|
@ -244,9 +244,12 @@ export type ProductSubComponentNames = 'mainComponent' | 'publicComponent' | 'he
|
||||||
export type ProductComponent = PluginComponent & {
|
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
|
* A string or React element to display in the product switcher
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue