diff --git a/webapp/channels/src/components/channel_header/channel_header_title_favorite.test.tsx b/webapp/channels/src/components/channel_header/channel_header_title_favorite.test.tsx new file mode 100644 index 0000000000..71c636e71a --- /dev/null +++ b/webapp/channels/src/components/channel_header/channel_header_title_favorite.test.tsx @@ -0,0 +1,234 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen, fireEvent, act} from '@testing-library/react'; +import React from 'react'; + +import type {Channel} from '@mattermost/types/channels'; + +import {favoriteChannel, unfavoriteChannel} from 'mattermost-redux/actions/channels'; +import * as channelsSelectors from 'mattermost-redux/selectors/entities/channels'; + +import {renderWithContext} from 'tests/react_testing_utils'; +import type {A11yFocusEventDetail} from 'utils/constants'; +import {A11yCustomEventTypes} from 'utils/constants'; + +import ChannelHeaderTitleFavorite from './channel_header_title_favorite'; + +jest.mock('mattermost-redux/actions/channels', () => ({ + favoriteChannel: jest.fn(), + unfavoriteChannel: jest.fn(), +})); + +describe('ChannelHeaderTitleFavorite Component', () => { + let isCurrentChannelFavoriteMock: jest.SpyInstance; + let getCurrentChannelMock: jest.SpyInstance; + let dispatchMock: jest.Mock; + + const ADD_TO_FAVORITES_REGEX = /add to favorites/i; + const REMOVE_FROM_FAVORITES_REGEX = /remove from favorites/i; + const ADD_TO_FAVORITES_LABEL = 'add to favorites'; + const REMOVE_FROM_FAVORITES_LABEL = 'remove from favorites'; + + const activeChannel: Channel = { + id: 'channel_id', + name: 'Active Channel', + type: 'O', + display_name: 'Active Channel', + delete_at: 0, + } as Channel; + + const archivedChannel: Channel = { + id: 'channel_id', + name: 'Archived Channel', + type: 'O', + display_name: 'Archived Channel', + delete_at: 1, + } as Channel; + + beforeEach(() => { + jest.clearAllMocks(); + + // Spy on selectors + isCurrentChannelFavoriteMock = jest.spyOn(channelsSelectors, 'isCurrentChannelFavorite'); + getCurrentChannelMock = jest.spyOn(channelsSelectors, 'getCurrentChannel'); + + // Mock useDispatch to capture dispatch calls + dispatchMock = jest.fn(); + jest.spyOn(require('react-redux'), 'useDispatch').mockReturnValue(dispatchMock); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function renderComponent() { + return renderWithContext(); + } + + it('should dispatch favoriteChannel when "Add to Favorites" button is clicked', () => { + isCurrentChannelFavoriteMock.mockReturnValue(false); + getCurrentChannelMock.mockReturnValue(activeChannel); + + // Mock favoriteChannel + (favoriteChannel as jest.Mock).mockReturnValue({ + type: 'FAVORITE_CHANNEL', + data: activeChannel.id, + }); + + renderComponent(); + + const button = screen.getByRole('button', {name: ADD_TO_FAVORITES_REGEX}); + fireEvent.click(button); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenCalledWith({ + type: 'FAVORITE_CHANNEL', + data: activeChannel.id, + }); + }); + + it('should dispatch unfavoriteChannel when "Remove from Favorites" button is clicked', () => { + isCurrentChannelFavoriteMock.mockReturnValue(true); + getCurrentChannelMock.mockReturnValue(activeChannel); + + // Mock unfavoriteChannel + (unfavoriteChannel as jest.Mock).mockReturnValue({ + type: 'UNFAVORITE_CHANNEL', + data: activeChannel.id, + }); + + renderComponent(); + + const button = screen.getByRole('button', {name: REMOVE_FROM_FAVORITES_REGEX}); + fireEvent.click(button); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenCalledWith({ + type: 'UNFAVORITE_CHANNEL', + data: activeChannel.id, + }); + }); + + it('should not render anything when channel is null', () => { + isCurrentChannelFavoriteMock.mockReturnValue(false); + getCurrentChannelMock.mockReturnValue(null); + + renderComponent(); + + const button = screen.queryByRole('button', {name: ADD_TO_FAVORITES_REGEX}); + expect(button).toBeNull(); + }); + + it('should not render anything when channel is archived', () => { + isCurrentChannelFavoriteMock.mockReturnValue(false); + getCurrentChannelMock.mockReturnValue(archivedChannel); + + renderComponent(); + + const button = screen.queryByRole('button', {name: ADD_TO_FAVORITES_REGEX}); + expect(button).toBeNull(); + }); + + it('should render "Add to Favorites" button when not favorite', () => { + isCurrentChannelFavoriteMock.mockReturnValue(false); + getCurrentChannelMock.mockReturnValue(activeChannel); + + renderComponent(); + + const button = screen.getByRole('button', {name: ADD_TO_FAVORITES_REGEX}); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass('inactive'); + expect(button).toHaveAttribute('aria-label', ADD_TO_FAVORITES_LABEL); + + const icon = button.querySelector('i'); + expect(icon).toHaveClass('icon-star-outline'); + }); + + it('should render "Remove from Favorites" button when favorite', () => { + isCurrentChannelFavoriteMock.mockReturnValue(true); + getCurrentChannelMock.mockReturnValue(activeChannel); + + renderComponent(); + + const button = screen.getByRole('button', {name: REMOVE_FROM_FAVORITES_REGEX}); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass('active'); + expect(button).toHaveAttribute('aria-label', REMOVE_FROM_FAVORITES_LABEL); + + const icon = button.querySelector('i'); + expect(icon).toHaveClass('icon-star'); + }); + + it('should have correct aria-label and icon based on isFavorite', () => { + // Start with Not favorite channel + isCurrentChannelFavoriteMock.mockReturnValue(false); + getCurrentChannelMock.mockReturnValue(activeChannel); + + renderComponent(); + + let button = screen.getByRole('button', {name: ADD_TO_FAVORITES_REGEX}); + expect(button).toHaveAttribute('aria-label', ADD_TO_FAVORITES_LABEL); + let icon = button.querySelector('i'); + expect(icon).toHaveClass('icon-star-outline'); + + // Reset mocks to simulate favorite state + jest.clearAllMocks(); + isCurrentChannelFavoriteMock = jest.spyOn(channelsSelectors, 'isCurrentChannelFavorite'); + getCurrentChannelMock = jest.spyOn(channelsSelectors, 'getCurrentChannel'); + + isCurrentChannelFavoriteMock.mockReturnValue(true); + getCurrentChannelMock.mockReturnValue(activeChannel); + + // Re-render component with updated state, now is Favorite + renderComponent(); + + button = screen.getByRole('button', {name: REMOVE_FROM_FAVORITES_REGEX}); + expect(button).toHaveAttribute('aria-label', REMOVE_FROM_FAVORITES_LABEL); + icon = button.querySelector('i'); + expect(icon).toHaveClass('icon-star'); + }); + + it('should dispatch A11yFocusEvent after toggling favorite', () => { + // Use fake timers to handle requestAnimationFrame + jest.useFakeTimers(); + + isCurrentChannelFavoriteMock.mockReturnValue(false); + getCurrentChannelMock.mockReturnValue(activeChannel); + + (favoriteChannel as jest.Mock).mockReturnValue({ + type: 'FAVORITE_CHANNEL', + data: activeChannel.id, + }); + + // Spy on document.dispatchEvent + const dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + + renderComponent(); + + const button = screen.getByRole('button', {name: ADD_TO_FAVORITES_REGEX}); + fireEvent.click(button); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: 'FAVORITE_CHANNEL', + data: activeChannel.id, + }); + + // Execute the requestAnimationFrame callback + act(() => { + jest.runAllTimers(); + }); + + expect(dispatchEventSpy).toHaveBeenCalled(); + + // Verify the details of the dispatched event + const event = dispatchEventSpy.mock.calls.find((call) => call[0].type === A11yCustomEventTypes.FOCUS)?.[0] as CustomEvent; + expect(event).toBeDefined(); + expect(event.detail.target).toBe(button); + expect(event.detail.keyboardOnly).toBe(false); + + dispatchEventSpy.mockRestore(); + + jest.useRealTimers(); + }); +}); diff --git a/webapp/channels/src/components/channel_header/channel_header_title_favorite.tsx b/webapp/channels/src/components/channel_header/channel_header_title_favorite.tsx index 17b220b4e0..528b6290e9 100644 --- a/webapp/channels/src/components/channel_header/channel_header_title_favorite.tsx +++ b/webapp/channels/src/components/channel_header/channel_header_title_favorite.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; -import React, {memo, useCallback} from 'react'; +import React, {memo, useCallback, useRef} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {useSelector, useDispatch} from 'react-redux'; @@ -11,12 +11,16 @@ import {getCurrentChannel, isCurrentChannelFavorite} from 'mattermost-redux/sele import WithTooltip from 'components/with_tooltip'; +import type {A11yFocusEventDetail} from 'utils/constants'; +import {A11yCustomEventTypes} from 'utils/constants'; + const ChannelHeaderTitleFavorite = () => { const intl = useIntl(); const dispatch = useDispatch(); const isFavorite = useSelector(isCurrentChannelFavorite); const channel = useSelector(getCurrentChannel); const channelIsArchived = (channel?.delete_at ?? 0) > 0; + const favIconRef = useRef(null); const toggleFavoriteCallback = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -28,7 +32,19 @@ const ChannelHeaderTitleFavorite = () => { } else { dispatch(favoriteChannel(channel.id)); } - }, [isFavorite, channel?.id]); + requestAnimationFrame(() => { + if (favIconRef.current) { + document.dispatchEvent( + new CustomEvent(A11yCustomEventTypes.FOCUS, { + detail: { + target: favIconRef.current, + keyboardOnly: false, + }, + }), + ); + } + }); + }, [isFavorite, channel, dispatch]); if (!channel || channelIsArchived) { return null; @@ -67,6 +83,7 @@ const ChannelHeaderTitleFavorite = () => { onClick={toggleFavoriteCallback} className={classNames('channel-header__favorites btn btn-icon btn-xs', {active: isFavorite, inactive: !isFavorite})} aria-label={ariaLabel} + ref={favIconRef} >