[MM-65630] Implement Search RHS popout, clean up and rework parts of search RHS (#35499)

* [MM-65630] Implement Search RHS popout, clean up and rework parts of search RHS

* Update webapp/channels/src/components/common/hooks/use_search_results_actions.test.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix lint

* Fix tests

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Devin Binnie
2026-03-12 07:40:25 -04:00
committed by GitHub
co-authored by coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
parent 5aefff30cf
commit 2bd143ced7
27 changed files with 1539 additions and 311 deletions
@@ -0,0 +1,237 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test('MM-65630-1 Search results should show popout button that opens results in a new window', async ({pw}) => {
const {adminClient, user, team} = await pw.initSetup();
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
displayName: 'Search Popout Channel',
name: 'search-popout-channel',
}),
);
await adminClient.addToChannel(user.id, channel.id);
const uniqueText = `popout-search-test-${await pw.random.id()}`;
await adminClient.createPost({
channel_id: channel.id,
message: uniqueText,
});
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const page = channelsPage.page;
await channelsPage.globalHeader.openSearch();
await channelsPage.searchBox.searchInput.fill(uniqueText);
await channelsPage.searchBox.searchInput.press('Enter');
await expect(page.locator('#searchContainer')).toBeVisible();
await expect(page.locator('#searchContainer').getByText(uniqueText)).toBeVisible();
const popoutButton = page.locator('.PopoutButton');
await expect(popoutButton).toBeVisible();
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);
await popoutPage.waitForLoadState('domcontentloaded');
const popoutUrl = popoutPage.url();
expect(popoutUrl).toContain('/_popout/rhs/');
expect(popoutUrl).toContain('/search');
expect(popoutUrl).toContain(`q=${encodeURIComponent(uniqueText)}`);
expect(popoutUrl).toContain('mode=search');
await expect(popoutPage.locator('#searchContainer')).toBeVisible({timeout: 10000});
await expect(popoutPage.locator('#searchContainer').getByText(uniqueText)).toBeVisible({timeout: 10000});
await popoutPage.close();
});
test('MM-65630-2 Recent mentions popout should open with the right results', async ({pw}) => {
const {adminClient, user, team} = await pw.initSetup();
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
displayName: 'Mentions Popout Channel',
name: 'mentions-popout-channel',
}),
);
await adminClient.addToChannel(user.id, channel.id);
const mentionText = `hey @${user.username} check this mention-${await pw.random.id()}`;
await adminClient.createPost({
channel_id: channel.id,
message: mentionText,
});
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const page = channelsPage.page;
await channelsPage.globalHeader.openRecentMentions();
await expect(page.locator('#searchContainer')).toBeVisible();
await expect(page.locator('#searchContainer').getByRole('heading', {name: 'Recent Mentions'})).toBeVisible();
await expect(page.locator('#searchContainer').getByText(mentionText)).toBeVisible();
const popoutButton = page.locator('.PopoutButton');
await expect(popoutButton).toBeVisible();
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);
await popoutPage.waitForLoadState('domcontentloaded');
const popoutUrl = popoutPage.url();
expect(popoutUrl).toContain('/_popout/rhs/');
expect(popoutUrl).toContain('/search');
expect(popoutUrl).toContain('mode=mention');
await expect(popoutPage.locator('#searchContainer')).toBeVisible({timeout: 10000});
await expect(popoutPage.locator('#searchContainer').getByText(mentionText)).toBeVisible({timeout: 10000});
await popoutPage.close();
});
test('MM-65630-3 Saved messages popout should open with the right results', async ({pw}) => {
const {adminClient, user, userClient, team} = await pw.initSetup();
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
displayName: 'Saved Popout Channel',
name: 'saved-popout-channel',
}),
);
await adminClient.addToChannel(user.id, channel.id);
const savedText = `saved-message-test-${await pw.random.id()}`;
const post = await adminClient.createPost({
channel_id: channel.id,
message: savedText,
});
await userClient.savePreferences(user.id, [
{
user_id: user.id,
category: 'flagged_post',
name: post.id,
value: 'true',
},
]);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const page = channelsPage.page;
await channelsPage.globalHeader.savedMessagesButton.click();
await expect(page.locator('#searchContainer')).toBeVisible();
await expect(page.locator('#searchContainer').getByRole('heading', {name: 'Saved messages'})).toBeVisible();
await expect(page.locator('#searchContainer').getByText(savedText)).toBeVisible();
const popoutButton = page.locator('.PopoutButton');
await expect(popoutButton).toBeVisible();
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);
await popoutPage.waitForLoadState('domcontentloaded');
const popoutUrl = popoutPage.url();
expect(popoutUrl).toContain('/_popout/rhs/');
expect(popoutUrl).toContain('/search');
expect(popoutUrl).toContain('mode=flag');
await expect(popoutPage.locator('#searchContainer')).toBeVisible({timeout: 10000});
await expect(popoutPage.locator('#searchContainer').getByText(savedText)).toBeVisible({timeout: 10000});
await popoutPage.close();
});
test('MM-65630-4 Search popout should not show popout button in the popout window itself', async ({pw}) => {
const {adminClient, user, team} = await pw.initSetup();
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
displayName: 'Popout No Button Channel',
name: 'popout-no-button-channel',
}),
);
await adminClient.addToChannel(user.id, channel.id);
const uniqueText = `no-button-test-${await pw.random.id()}`;
await adminClient.createPost({
channel_id: channel.id,
message: uniqueText,
});
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const page = channelsPage.page;
await channelsPage.globalHeader.openSearch();
await channelsPage.searchBox.searchInput.fill(uniqueText);
await channelsPage.searchBox.searchInput.press('Enter');
await expect(page.locator('#searchContainer')).toBeVisible();
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), page.locator('.PopoutButton').click()]);
await popoutPage.waitForLoadState('domcontentloaded');
await expect(popoutPage.locator('#searchContainer')).toBeVisible({timeout: 10000});
await expect(popoutPage.locator('.PopoutButton')).not.toBeVisible();
await expect(popoutPage.locator('#searchResultsCloseButton')).not.toBeVisible();
await popoutPage.close();
});
test('MM-65630-5 Search popout should preserve search type (files) in the URL', async ({pw}) => {
const {adminClient, user, team} = await pw.initSetup();
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
displayName: 'Files Search Channel',
name: 'files-search-channel',
}),
);
await adminClient.addToChannel(user.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const page = channelsPage.page;
await channelsPage.globalHeader.openSearch();
await channelsPage.searchBox.searchInput.fill('test');
await channelsPage.searchBox.searchInput.press('Enter');
await expect(page.locator('#searchContainer')).toBeVisible();
const filesTab = page.locator('#searchContainer').getByRole('tab', {name: /Files/});
await filesTab.click();
const popoutButton = page.locator('.PopoutButton');
await expect(popoutButton).toBeVisible();
const [popoutPage] = await Promise.all([page.waitForEvent('popup'), popoutButton.click()]);
await popoutPage.waitForLoadState('domcontentloaded');
const popoutUrl = popoutPage.url();
expect(popoutUrl).toContain('type=files');
await popoutPage.close();
});
@@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act} from '@testing-library/react';
import {getMoreFilesForSearch, getMorePostsForSearch} from 'mattermost-redux/actions/search';
import {
filterFilesSearchByExt,
showChannelFiles,
showSearchResults,
updateSearchTerms as updateSearchTermsAction,
updateSearchType as updateSearchTypeAction,
} from 'actions/views/rhs';
import {renderHookWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import useSearchResultsActions from './use_search_results_actions';
const MOCK_ACTION = {type: 'MOCK'};
jest.mock('mattermost-redux/actions/search', () => ({
getMorePostsForSearch: jest.fn(() => MOCK_ACTION),
getMoreFilesForSearch: jest.fn(() => MOCK_ACTION),
}));
jest.mock('actions/views/rhs', () => ({
filterFilesSearchByExt: jest.fn(() => MOCK_ACTION),
showChannelFiles: jest.fn(() => MOCK_ACTION),
showSearchResults: jest.fn(() => MOCK_ACTION),
updateSearchTeam: jest.fn(() => MOCK_ACTION),
updateSearchTerms: jest.fn(() => MOCK_ACTION),
updateSearchType: jest.fn(() => MOCK_ACTION),
}));
const {updateSearchTeam: updateSearchTeamAction} = jest.requireMock('actions/views/rhs');
describe('useSearchResultsActions', () => {
const channel = TestHelper.getChannelMock({id: 'channel1', name: 'test-channel'});
const initialState = {
entities: {
channels: {
currentChannelId: channel.id,
channels: {
[channel.id]: channel,
},
myMembers: {},
},
},
views: {
rhs: {
rhsState: 'search',
searchTeam: 'team1',
searchTerms: 'hello world',
},
},
};
beforeEach(() => {
jest.clearAllMocks();
});
function callAction(
callback: (actions: ReturnType<typeof useSearchResultsActions>) => void,
rhsState?: string,
) {
const state = {
...initialState,
views: {
rhs: {
...initialState.views.rhs,
rhsState: rhsState ?? initialState.views.rhs.rhsState,
},
},
};
const {result} = renderHookWithContext(() => useSearchResultsActions(), state);
act(() => callback(result.current));
return result;
}
test('getMorePostsForSearch should dispatch with search team', () => {
callAction((a) => a.getMorePostsForSearch());
expect(jest.mocked(getMorePostsForSearch)).toHaveBeenCalledWith('team1');
});
test('getMorePostsForSearch should dispatch with empty team for mention search', () => {
callAction((a) => a.getMorePostsForSearch(), 'mention');
expect(jest.mocked(getMorePostsForSearch)).toHaveBeenCalledWith('');
});
test('getMoreFilesForSearch should dispatch with search team', () => {
callAction((a) => a.getMoreFilesForSearch());
expect(jest.mocked(getMoreFilesForSearch)).toHaveBeenCalledWith('team1');
});
test('setSearchFilterType should dispatch filter and trigger search results', () => {
callAction((a) => a.setSearchFilterType('documents'));
expect(jest.mocked(filterFilesSearchByExt)).toHaveBeenCalledWith(['doc', 'pdf', 'docx', 'odt', 'rtf', 'txt']);
expect(jest.mocked(showSearchResults)).toHaveBeenCalledWith(false);
});
test('setSearchFilterType should dispatch showChannelFiles when in channel files mode', () => {
callAction((a) => a.setSearchFilterType('images'), 'channel-files');
expect(jest.mocked(filterFilesSearchByExt)).toHaveBeenCalledWith(['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'svg', 'xcf']);
expect(jest.mocked(showChannelFiles)).toHaveBeenCalledWith(channel.id);
});
test('setSearchFilterType "all" should dispatch empty extension array', () => {
callAction((a) => a.setSearchFilterType('all'));
expect(jest.mocked(filterFilesSearchByExt)).toHaveBeenCalledWith([]);
});
test('updateSearchTerms should append term replacing last word', () => {
callAction((a) => a.updateSearchTerms('From:'));
expect(jest.mocked(updateSearchTermsAction)).toHaveBeenCalledWith('hello from:');
});
test('setSearchType should dispatch updateSearchType', () => {
callAction((a) => a.setSearchType('files'));
expect(jest.mocked(updateSearchTypeAction)).toHaveBeenCalledWith('files');
});
test('updateSearchTeam should dispatch team update and re-search', () => {
callAction((a) => a.updateSearchTeam('team2'));
expect(jest.mocked(updateSearchTeamAction)).toHaveBeenCalledWith('team2');
expect(jest.mocked(showSearchResults)).toHaveBeenCalledWith(false);
});
test('updateSearchTeam should strip in: and from: filters from terms', () => {
function callWithTerms(terms: string) {
const state = {
...initialState,
views: {rhs: {...initialState.views.rhs, searchTerms: terms}},
};
const {result} = renderHookWithContext(() => useSearchResultsActions(), state);
let cleaned = '';
act(() => {
cleaned = result.current.updateSearchTeam('team2');
});
return cleaned;
}
expect(callWithTerms('hello in:town-square from:user1')).toBe('hello');
expect(callWithTerms('hello world')).toBe('hello world');
expect(jest.mocked(updateSearchTermsAction)).toHaveBeenCalledTimes(1);
});
test('updateSearchTeam should return cleaned terms', () => {
let result = '';
callAction((a) => {
result = a.updateSearchTeam('team2');
});
expect(result).toBe('hello world');
});
});
@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {getMoreFilesForSearch, getMorePostsForSearch} from 'mattermost-redux/actions/search';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {filterFilesSearchByExt, showChannelFiles, showSearchResults, updateSearchTeam as updateSearchTeamAction, updateSearchTerms as updateSearchTermsAction, updateSearchType as updateSearchTypeAction} from 'actions/views/rhs';
import {getRhsState, getSearchTeam, getSearchTerms} from 'selectors/rhs';
import type {SearchFilterType} from 'components/search/types';
import {RHSStates} from 'utils/constants';
import type {SearchType} from 'types/store/rhs';
export default function useSearchResultsActions() {
const dispatch = useDispatch();
const searchTeam = useSelector(getSearchTeam);
const rhsState = useSelector(getRhsState);
const searchTerms = useSelector(getSearchTerms);
const currentChannel = useSelector(getCurrentChannel);
const isMentionSearch = rhsState === RHSStates.MENTION;
const isChannelFiles = rhsState === RHSStates.CHANNEL_FILES;
const [searchFilterType, setSearchFilterType] = useState<SearchFilterType>('all');
const getMorePostsForSearchCallback = useCallback(() => {
let team = searchTeam;
if (isMentionSearch) {
team = '';
}
dispatch(getMorePostsForSearch(team));
}, [dispatch, isMentionSearch, searchTeam]);
const getMoreFilesForSearchCallback = useCallback(() => {
let team = searchTeam;
if (isMentionSearch) {
team = '';
}
dispatch(getMoreFilesForSearch(team));
}, [dispatch, isMentionSearch, searchTeam]);
const handleSetSearchFilter = useCallback((filterType: SearchFilterType) => {
switch (filterType) {
case 'documents':
dispatch(filterFilesSearchByExt(['doc', 'pdf', 'docx', 'odt', 'rtf', 'txt']));
break;
case 'spreadsheets':
dispatch(filterFilesSearchByExt(['xls', 'xlsx', 'ods']));
break;
case 'presentations':
dispatch(filterFilesSearchByExt(['ppt', 'pptx', 'odp']));
break;
case 'code':
dispatch(filterFilesSearchByExt(['py', 'go', 'java', 'kt', 'c', 'cpp', 'h', 'html', 'js', 'ts', 'cs', 'vb', 'php', 'pl', 'r', 'rb', 'sql', 'swift', 'json']));
break;
case 'images':
dispatch(filterFilesSearchByExt(['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'svg', 'xcf']));
break;
case 'audio':
dispatch(filterFilesSearchByExt(['ogg', 'mp3', 'wav', 'flac']));
break;
case 'video':
dispatch(filterFilesSearchByExt(['ogm', 'mp4', 'avi', 'webm', 'mov', 'mkv', 'mpeg', 'mpg']));
break;
default:
dispatch(filterFilesSearchByExt([]));
}
setSearchFilterType(filterType);
if (isChannelFiles && currentChannel?.id) {
dispatch(showChannelFiles(currentChannel.id));
} else {
dispatch(showSearchResults(false));
}
}, [dispatch, isChannelFiles, currentChannel?.id]);
const handleUpdateSearchTerms = useCallback((term: string) => {
const pretextArray = searchTerms?.split(' ') || [];
pretextArray.pop();
pretextArray.push(term.toLowerCase());
dispatch(updateSearchTermsAction(pretextArray.join(' ')));
}, [dispatch, searchTerms]);
const handleSetSearchType = useCallback((type: SearchType) => {
dispatch(updateSearchTypeAction(type));
}, [dispatch]);
const handleUpdateSearchTeam = useCallback((teamId: string): string => {
dispatch(updateSearchTeamAction(teamId));
// When we switch teams, we need to remove the in: and from: filters from the search terms
// since the channel and user filters might not be valid for the new team.
const cleanedTerms = searchTerms.
replace(/\bin:[^\s]*/gi, '').replace(/\s{2,}/g, ' ').
replace(/\bfrom:[^\s]*/gi, '').replace(/\s{2,}/g, ' ').
trim();
if (cleanedTerms.trim() !== searchTerms.trim()) {
dispatch(updateSearchTermsAction(cleanedTerms));
}
dispatch(showSearchResults(isMentionSearch));
return cleanedTerms;
}, [dispatch, searchTerms, isMentionSearch]);
return {
searchFilterType,
getMorePostsForSearch: getMorePostsForSearchCallback,
getMoreFilesForSearch: getMoreFilesForSearchCallback,
setSearchFilterType: handleSetSearchFilter,
updateSearchTerms: handleUpdateSearchTerms,
updateSearchTeam: handleUpdateSearchTeam,
setSearchType: handleSetSearchType,
};
}
@@ -19,7 +19,7 @@ import {useUserTheme} from 'components/theme_provider';
import ThreadPopout from 'components/thread_popout';
import Pluggable from 'plugins/pluggable';
import {TEAM_NAME_PATH_PATTERN, ID_PATH_PATTERN, IDENTIFIER_PATH_PATTERN} from 'utils/path';
import {TEAM_NAME_PATH_PATTERN, ID_PATH_PATTERN} from 'utils/path';
import {useBrowserPopout} from 'utils/popouts/use_browser_popout';
import './popout_controller.scss';
@@ -52,7 +52,7 @@ const PopoutController: React.FC<RouteComponentProps> = (routeProps) => {
component={ThreadPopout}
/>
<Route
path={`/_popout/rhs/:team(${TEAM_NAME_PATH_PATTERN})/:identifier(${IDENTIFIER_PATH_PATTERN})`}
path={`/_popout/rhs/:team(${TEAM_NAME_PATH_PATTERN})`}
component={RhsPopout}
/>
<Route
@@ -38,6 +38,7 @@ import {getIsMobileView} from 'selectors/views/browser';
import {isArchivedChannel} from 'utils/channel_utils';
import {Locations, Preferences, RHSStates} from 'utils/constants';
import {isPopoutWindow} from 'utils/popouts/popout_windows';
import {areConsecutivePostsBySameUser, canDeletePost, getPostTranslation, shouldShowActionsMenu, shouldShowDotMenu} from 'utils/post_utils';
import {getDisplayNameByUser} from 'utils/utils';
@@ -168,7 +169,8 @@ function makeMapStateToProps() {
}
const isPostBurnOnRead = isBurnOnReadPost(state, post.id);
const canReply = !isPostBurnOnRead && (isDMorGM || (channel.team_id === currentTeam?.id));
const isSearchPopout = isPopoutWindow() && ownProps.location === Locations.SEARCH;
const canReply = !isPostBurnOnRead && (isDMorGM || isSearchPopout || (channel.team_id === currentTeam?.id));
const directTeammate = getDirectTeammate(state, channel.id);
const previewCollapsed = get(
@@ -12,6 +12,7 @@ import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {getHistory} from 'utils/browser_history';
import {Locations} from 'utils/constants';
import * as PopoutWindows from 'utils/popouts/popout_windows';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
@@ -331,6 +332,66 @@ describe('PostComponent', () => {
expect(propsForRootPost.actions.selectPostFromRightHandSideSearch).not.toHaveBeenCalled();
expect(getHistory().push).toHaveBeenCalled();
});
test('should navigate within popout when clicking reply on a search result in a popout window', async () => {
jest.spyOn(PopoutWindows, 'isPopoutWindow').mockReturnValue(true);
const props = {
...propsForRootPost,
location: Locations.SEARCH,
matches: ['test'],
teamName: currentTeam.name,
};
renderWithContext(<PostComponent {...props}/>, state);
await userEvent.click(screen.getByText('1 reply'));
expect(propsForRootPost.actions.selectPostFromRightHandSideSearch).not.toHaveBeenCalled();
expect(getHistory().replace).toHaveBeenCalledWith(
expect.stringContaining(`/_popout/thread/${currentTeam.name}/${rootPost.id}`),
);
jest.restoreAllMocks();
});
test('should navigate within popout on cross-team reply click instead of jumping', async () => {
jest.spyOn(PopoutWindows, 'isPopoutWindow').mockReturnValue(true);
const props = {
...propsForRootPost,
location: Locations.SEARCH,
matches: ['test'],
team: TestHelper.getTeamMock({id: 'another_team'}),
teamName: currentTeam.name,
};
renderWithContext(<PostComponent {...props}/>, state);
await userEvent.click(screen.getByText('1 reply'));
expect(getHistory().push).not.toHaveBeenCalled();
expect(getHistory().replace).toHaveBeenCalledWith(
expect.stringContaining('/_popout/thread/'),
);
jest.restoreAllMocks();
});
test('should not navigate within popout when not a search result item', async () => {
jest.spyOn(PopoutWindows, 'isPopoutWindow').mockReturnValue(true);
const props = {
...propsForRootPost,
location: Locations.CENTER,
};
renderWithContext(<PostComponent {...props}/>, state);
await userEvent.click(screen.getByText('1 reply'));
expect(propsForRootPost.actions.selectPostFromRightHandSideSearch).toHaveBeenCalledWith(rootPost);
expect(getHistory().replace).not.toHaveBeenCalled();
jest.restoreAllMocks();
});
});
});
@@ -47,6 +47,7 @@ import {getArchiveIconComponent} from 'utils/channel_utils';
import Constants, {A11yCustomEventTypes, AppEvents, Locations, PostTypes, ModalIdentifiers} from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {isPopoutWindow} from 'utils/popouts/popout_windows';
import * as PostUtils from 'utils/post_utils';
import {makeIsEligibleForClick} from 'utils/utils';
@@ -411,22 +412,28 @@ function PostComponent(props: Props) {
const {selectPostFromRightHandSideSearch} = props.actions;
const isSearchPopoutWindow = useMemo(() => isPopoutWindow() && isSearchResultItem, [isSearchResultItem]);
const handleCommentClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
if (!post) {
return;
}
if (isSearchPopoutWindow) {
const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
getHistory().replace(`/_popout/thread/${props.teamName}/${post.root_id || post.id}?returnTo=${returnTo}`);
return;
}
selectPostFromRightHandSideSearch(post);
}, [post, selectPostFromRightHandSideSearch]);
}, [post, props.teamName, selectPostFromRightHandSideSearch, isSearchPopoutWindow]);
const handleThreadClick = useCallback((e: React.MouseEvent) => {
if (props.currentTeam?.id === teamId) {
if (isSearchPopoutWindow || props.currentTeam?.id === teamId) {
handleCommentClick(e);
} else {
handleJumpClick(e);
}
}, [handleCommentClick, handleJumpClick, props.currentTeam?.id, teamId]);
}, [handleCommentClick, handleJumpClick, props.currentTeam?.id, teamId, isSearchPopoutWindow]);
const translation = PostUtils.getPostTranslation(post, locale);
@@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import React from 'react';
import * as ReactRedux from 'react-redux';
import {MemoryRouter, Route} from 'react-router-dom';
import {fetchChannelsAndMembers, getChannelMembers, selectChannel} from 'mattermost-redux/actions/channels';
@@ -15,28 +14,14 @@ import {TestHelper} from 'utils/test_helper';
import RhsPopout from './rhs_popout';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: jest.fn(),
useSelector: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
jest.mock('mattermost-redux/actions/channels', () => ({
fetchChannelsAndMembers: jest.fn(),
getChannelMembers: jest.fn(),
selectChannel: jest.fn(),
fetchChannelsAndMembers: jest.fn(() => ({type: 'MOCK'})),
getChannelMembers: jest.fn(() => ({type: 'MOCK'})),
selectChannel: jest.fn(() => ({type: 'MOCK'})),
}));
jest.mock('mattermost-redux/actions/teams', () => ({
selectTeam: jest.fn(),
}));
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
getChannelByName: jest.fn(),
selectTeam: jest.fn(() => ({type: 'MOCK'})),
}));
jest.mock('components/common/hooks/use_team', () => ({
@@ -53,69 +38,68 @@ jest.mock('components/rhs_plugin_popout', () => ({
default: () => <div data-testid='rhs-plugin-popout'>{'RHS Plugin Popout'}</div>,
}));
const mockDispatch = jest.fn();
const mockUseDispatch = ReactRedux.useDispatch as jest.MockedFunction<typeof ReactRedux.useDispatch>;
const mockUseSelector = ReactRedux.useSelector as jest.MockedFunction<typeof ReactRedux.useSelector>;
const mockUseParams = jest.spyOn(require('react-router-dom'), 'useParams');
const mockSelectChannel = selectChannel as jest.MockedFunction<typeof selectChannel>;
const mockGetChannelMembers = getChannelMembers as jest.MockedFunction<typeof getChannelMembers>;
const mockSelectTeam = selectTeam as jest.MockedFunction<typeof selectTeam>;
const mockFetchChannelsAndMembers = fetchChannelsAndMembers as jest.MockedFunction<typeof fetchChannelsAndMembers>;
const mockUseTeamByName = useTeamByName as jest.MockedFunction<typeof useTeamByName>;
jest.mock('components/rhs_search_popout', () => ({
__esModule: true,
default: () => <div data-testid='rhs-search-popout'>{'RHS Search Popout'}</div>,
}));
describe('RhsPopout', () => {
const team1 = TestHelper.getTeamMock({id: 'team1', name: 'team1'});
const channel1 = TestHelper.getChannelMock({id: 'channel1', name: 'channel1'});
beforeEach(() => {
mockDispatch.mockReturnValue({type: 'MOCK_ACTION'});
mockUseDispatch.mockReturnValue(mockDispatch);
mockUseParams.mockReturnValue({team: 'team1', identifier: 'channel1'});
mockUseTeamByName.mockReturnValue(team1);
mockUseSelector.mockReturnValue(channel1);
const baseState = {
entities: {
channels: {
channels: {[channel1.id]: channel1},
myMembers: {},
},
teams: {
currentTeamId: team1.id,
teams: {[team1.id]: team1},
},
},
};
mockSelectChannel.mockImplementation((channelId: string) => ({
type: 'SELECT_CHANNEL',
data: channelId,
}));
mockGetChannelMembers.mockImplementation(() => jest.fn(() => Promise.resolve({data: []})));
mockSelectTeam.mockImplementation((team: string | typeof team1) => {
const teamId = typeof team === 'string' ? team : team.id;
return {
type: 'SELECT_TEAM',
data: teamId,
};
});
mockFetchChannelsAndMembers.mockImplementation(() => jest.fn(() => Promise.resolve({data: {channels: [], channelMembers: []}})));
beforeEach(() => {
jest.mocked(useTeamByName).mockReturnValue(team1);
});
it('should dispatch correct actions when channelId and teamId are available', async () => {
renderWithContext(
<MemoryRouter initialEntries={['/_popout/rhs/team1/channel1']}>
afterEach(() => {
jest.clearAllMocks();
});
function renderPopout(path: string, state = baseState) {
return renderWithContext(
<MemoryRouter initialEntries={[path]}>
<Route
path='/_popout/rhs/:team/:identifier'
path='/_popout/rhs/:team'
component={RhsPopout}
/>
</MemoryRouter>,
state,
);
}
it('should dispatch selectTeam and fetchChannelsAndMembers when team is available', async () => {
renderPopout('/_popout/rhs/team1/search?q=test');
await waitFor(() => {
expect(mockSelectChannel).toHaveBeenCalledWith(channel1.id);
expect(mockGetChannelMembers).toHaveBeenCalledWith(channel1.id);
expect(mockSelectTeam).toHaveBeenCalledWith(team1.id);
expect(mockFetchChannelsAndMembers).toHaveBeenCalledWith(team1.id);
expect(jest.mocked(selectTeam)).toHaveBeenCalledWith(team1.id);
expect(jest.mocked(fetchChannelsAndMembers)).toHaveBeenCalledWith(team1.id);
});
});
it('should dispatch selectChannel and getChannelMembers when channel is in query params', async () => {
renderPopout('/_popout/rhs/team1/search?channel=channel1');
await waitFor(() => {
expect(jest.mocked(selectChannel)).toHaveBeenCalledWith(channel1.id);
expect(jest.mocked(getChannelMembers)).toHaveBeenCalledWith(channel1.id);
});
});
it('should render the correct structure', () => {
const {container} = renderWithContext(
<MemoryRouter initialEntries={['/_popout/rhs/team1/channel1']}>
<Route
path='/_popout/rhs/:team/:identifier'
component={RhsPopout}
/>
</MemoryRouter>,
);
const {container} = renderPopout('/_popout/rhs/team1/search?q=test');
expect(container.querySelector('[data-testid="unreads-status-handler"]')).toBeInTheDocument();
expect(container.querySelector('.main-wrapper.rhs-popout')).toBeInTheDocument();
@@ -123,17 +107,24 @@ describe('RhsPopout', () => {
expect(container.querySelector('.sidebar-right__body')).toBeInTheDocument();
});
it('should render RhsPluginPopout for plugin route', () => {
renderWithContext(
<MemoryRouter initialEntries={['/_popout/rhs/team1/channel1/plugin/test-plugin']}>
<Route
path='/_popout/rhs/:team/:identifier'
component={RhsPopout}
/>
</MemoryRouter>,
);
it('should render RhsSearchPopout for search route', () => {
renderPopout('/_popout/rhs/team1/search?q=test');
expect(screen.getByTestId('rhs-search-popout')).toBeInTheDocument();
});
it('should render RhsPluginPopout for plugin route', () => {
renderPopout('/_popout/rhs/team1/plugin/test-plugin');
expect(screen.getByTestId('rhs-plugin-popout')).toBeInTheDocument();
});
});
it('should not dispatch channel actions when no channel in query params', async () => {
renderPopout('/_popout/rhs/team1/search?q=test');
await waitFor(() => {
expect(jest.mocked(selectTeam)).toHaveBeenCalledWith(team1.id);
});
expect(jest.mocked(selectChannel)).not.toHaveBeenCalled();
expect(jest.mocked(getChannelMembers)).not.toHaveBeenCalled();
});
});
@@ -3,7 +3,7 @@
import React, {useEffect} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {Route, Switch, useParams, useRouteMatch} from 'react-router-dom';
import {Route, Switch, useLocation, useParams, useRouteMatch} from 'react-router-dom';
import {fetchChannelsAndMembers, getChannelMembers, selectChannel} from 'mattermost-redux/actions/channels';
import {selectTeam} from 'mattermost-redux/actions/teams';
@@ -11,6 +11,7 @@ import {getChannelByName} from 'mattermost-redux/selectors/entities/channels';
import {useTeamByName} from 'components/common/hooks/use_team';
import RhsPluginPopout from 'components/rhs_plugin_popout';
import RhsSearchPopout from 'components/rhs_search_popout';
import UnreadsStatusHandler from 'components/unreads_status_handler';
import type {GlobalState} from 'types/store';
@@ -21,12 +22,15 @@ export default function RhsPopout() {
const match = useRouteMatch();
const dispatch = useDispatch();
const {team: teamName, identifier: channelIdentifier} = useParams<{team: string; pluginId: string; identifier: string}>();
const location = useLocation();
const {team: teamName} = useParams<{team: string}>();
const team = useTeamByName(teamName);
const channel = useSelector((state: GlobalState) => getChannelByName(state, channelIdentifier));
const channelIdentifier = new URLSearchParams(location.search).get('channel') ?? '';
const channel = useSelector((state: GlobalState) => (channelIdentifier ? getChannelByName(state, channelIdentifier) : undefined));
const teamId = team?.id;
const channelId = channel?.id;
useEffect(() => {
@@ -50,6 +54,10 @@ export default function RhsPopout() {
<div className='sidebar--right'>
<div className='sidebar-right__body'>
<Switch>
<Route
path={`${match.path}/search`}
component={RhsSearchPopout}
/>
<Route
path={`${match.path}/plugin/:pluginId`}
component={RhsPluginPopout}
@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './rhs_search_popout';
@@ -0,0 +1,221 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {MemoryRouter, Route} from 'react-router-dom';
import {
showChannelFiles,
showFlaggedPosts,
showMentions,
showPinnedPosts,
showSearchResults,
updateRhsState,
updateSearchTeam,
updateSearchTerms,
updateSearchType,
} from 'actions/views/rhs';
import {renderWithContext, screen, waitFor} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import RhsSearchPopout from './rhs_search_popout';
jest.mock('components/search_results', () => ({
__esModule: true,
default: () => <div data-testid='search-results'>{'Search Results'}</div>,
}));
jest.mock('utils/popouts/use_popout_title', () => ({
__esModule: true,
default: jest.fn(),
}));
const MOCK_ACTION = {type: 'MOCK'};
jest.mock('actions/views/rhs', () => ({
showChannelFiles: jest.fn(() => MOCK_ACTION),
showFlaggedPosts: jest.fn(() => MOCK_ACTION),
showMentions: jest.fn(() => MOCK_ACTION),
showPinnedPosts: jest.fn(() => MOCK_ACTION),
showSearchResults: jest.fn(() => MOCK_ACTION),
updateRhsState: jest.fn(() => MOCK_ACTION),
updateSearchTeam: jest.fn(() => MOCK_ACTION),
updateSearchTerms: jest.fn(() => MOCK_ACTION),
updateSearchType: jest.fn(() => MOCK_ACTION),
filterFilesSearchByExt: jest.fn(() => MOCK_ACTION),
}));
describe('RhsSearchPopout', () => {
const team = TestHelper.getTeamMock({id: 'team1', name: 'test-team'});
const channel = TestHelper.getChannelMock({
id: 'channel1',
name: 'test-channel',
display_name: 'Test Channel',
team_id: team.id,
});
const baseState = {
entities: {
channels: {
currentChannelId: channel.id,
channels: {[channel.id]: channel},
channelsInTeam: {[team.id]: new Set([channel.id])},
myMembers: {},
},
teams: {
currentTeamId: team.id,
teams: {[team.id]: team},
},
general: {config: {}, license: {}},
users: {currentUserId: 'user1'},
},
views: {
rhs: {
rhsState: 'search',
isSidebarExpanded: false,
searchTeam: team.id,
searchTerms: '',
},
},
};
beforeEach(() => {
jest.clearAllMocks();
});
function renderPopout(search: string, rhsState?: string) {
const state = rhsState ? {
...baseState,
views: {rhs: {...baseState.views.rhs, rhsState}},
} : baseState;
return renderWithContext(
<MemoryRouter initialEntries={[`/_popout/rhs/test-team/search${search}`]}>
<Route
path='/_popout/rhs/:team/search'
component={RhsSearchPopout}
/>
</MemoryRouter>,
state,
);
}
test('should render SearchResults component', () => {
renderPopout('?q=test&type=messages&mode=search');
expect(screen.getByTestId('search-results')).toBeInTheDocument();
});
test('should dispatch search setup actions from query params on mount', async () => {
renderPopout('?q=hello+world&type=messages&mode=search');
await waitFor(() => {
expect(jest.mocked(updateSearchType)).toHaveBeenCalledWith('messages');
expect(jest.mocked(updateSearchTerms)).toHaveBeenCalledWith('hello world');
expect(jest.mocked(updateSearchTeam)).toHaveBeenCalledWith(team.id);
expect(jest.mocked(showSearchResults)).toHaveBeenCalledWith(false);
});
});
test('should dispatch showMentions for mention mode without search terms', async () => {
renderPopout('?q=&type=messages&mode=mention', 'mention');
await waitFor(() => {
expect(jest.mocked(showMentions)).toHaveBeenCalled();
});
});
test('should dispatch showSearchResults(true) for mention mode with search terms', async () => {
renderPopout('?q=from:user&type=messages&mode=mention', 'mention');
await waitFor(() => {
expect(jest.mocked(showSearchResults)).toHaveBeenCalledWith(true);
});
});
test('should dispatch showFlaggedPosts for flag mode', async () => {
renderPopout('?q=&type=messages&mode=flag', 'flag');
await waitFor(() => {
expect(jest.mocked(showFlaggedPosts)).toHaveBeenCalled();
});
});
test('should dispatch showPinnedPosts for pin mode when channelId is available', async () => {
renderPopout('?q=&type=messages&mode=pin&channel=test-channel', 'pin');
await waitFor(() => {
expect(jest.mocked(showPinnedPosts)).toHaveBeenCalledWith(channel.id);
});
});
test('should not dispatch showPinnedPosts when channelId is not available', async () => {
renderPopout('?q=&type=messages&mode=pin', 'pin');
await waitFor(() => {
expect(jest.mocked(updateSearchType)).toHaveBeenCalled();
});
expect(jest.mocked(showPinnedPosts)).not.toHaveBeenCalled();
expect(jest.mocked(updateRhsState)).toHaveBeenCalledWith('pin', undefined);
});
test('should dispatch showChannelFiles for channel_files mode when channelId is available', async () => {
renderPopout('?q=&type=messages&mode=channel-files&channel=test-channel', 'channel-files');
await waitFor(() => {
expect(jest.mocked(showChannelFiles)).toHaveBeenCalledWith(channel.id);
});
});
test('should resolve searchTeamId from query params with fallback to current team', async () => {
renderPopout('?q=test&type=messages&mode=search&searchTeamId=other-team');
await waitFor(() => {
expect(jest.mocked(updateSearchTeam)).toHaveBeenCalledWith('other-team');
});
jest.clearAllMocks();
renderPopout('?q=test&type=messages&mode=search&searchTeamId=');
await waitFor(() => {
expect(jest.mocked(updateSearchTeam)).toHaveBeenCalledWith('');
});
jest.clearAllMocks();
renderPopout('?q=test&type=messages&mode=search');
await waitFor(() => {
expect(jest.mocked(updateSearchTeam)).toHaveBeenCalledWith(team.id);
});
});
test('should not dispatch actions when teamId is not yet available', () => {
const noTeamState = {
...baseState,
entities: {
...baseState.entities,
teams: {...baseState.entities.teams, currentTeamId: ''},
},
};
renderWithContext(
<MemoryRouter initialEntries={['/_popout/rhs/test-team/search?q=test&type=messages&mode=search']}>
<Route
path='/_popout/rhs/:team/search'
component={RhsSearchPopout}
/>
</MemoryRouter>,
noTeamState,
);
expect(jest.mocked(updateSearchType)).not.toHaveBeenCalled();
expect(jest.mocked(updateSearchTerms)).not.toHaveBeenCalled();
});
test('should fallback to updateRhsState for search mode with no search terms', async () => {
renderPopout('?q=&type=messages&mode=search');
await waitFor(() => {
expect(jest.mocked(updateRhsState)).toHaveBeenCalledWith('search', undefined);
});
expect(jest.mocked(showSearchResults)).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,143 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {useLocation} from 'react-router-dom';
import {getChannelByName} from 'mattermost-redux/selectors/entities/channels';
import {getIsCrossTeamSearchEnabled} from 'mattermost-redux/selectors/entities/general';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {showChannelFiles, showFlaggedPosts, showMentions, showPinnedPosts, showSearchResults, updateRhsState, updateSearchTeam, updateSearchTerms, updateSearchType} from 'actions/views/rhs';
import {getIsRhsExpanded, getRhsState} from 'selectors/rhs';
import useSearchResultsActions from 'components/common/hooks/use_search_results_actions';
import SearchResults from 'components/search_results';
import {getHistory} from 'utils/browser_history';
import {RHSStates} from 'utils/constants';
import usePopoutTitle from 'utils/popouts/use_popout_title';
import type {GlobalState} from 'types/store';
import type {RhsState, SearchType} from 'types/store/rhs';
import {getSearchPopoutTitle} from './title';
export default function RhsSearchPopout() {
const dispatch = useDispatch();
const location = useLocation();
const teamId = useSelector(getCurrentTeamId);
const query = useMemo(() => {
const queryParams = new URLSearchParams(location.search);
const searchTerms = queryParams.get('q') ?? '';
const searchType = queryParams.get('type') as SearchType;
const mode = queryParams.get('mode') as NonNullable<RhsState>;
const channelIdentifier = queryParams.get('channel') ?? '';
const searchTeamId = queryParams.get('searchTeamId');
return {
mode,
searchTerms,
searchType,
channelIdentifier,
searchTeamId,
};
}, [location.search]);
const channel = useSelector((state: GlobalState) => (query.channelIdentifier ? getChannelByName(state, query.channelIdentifier) : undefined));
const rhsState = useSelector(getRhsState);
const isRhsExpanded = useSelector(getIsRhsExpanded);
const crossTeamSearchEnabled = useSelector(getIsCrossTeamSearchEnabled);
const searchActions = useSearchResultsActions();
const popoutTitle = useMemo(() => getSearchPopoutTitle(query.mode), [query.mode]);
const popoutTitleParams = useMemo(() => ({searchTerms: query.searchTerms}), [query.searchTerms]);
usePopoutTitle(popoutTitle, popoutTitleParams);
const channelId = channel?.id;
const channelDisplayName = channel?.display_name ?? '';
const isMentionSearch = rhsState === RHSStates.MENTION;
const isFlaggedPosts = rhsState === RHSStates.FLAG;
const isPinnedPosts = rhsState === RHSStates.PIN;
const isChannelFiles = rhsState === RHSStates.CHANNEL_FILES;
useEffect(() => {
if (!teamId) {
return;
}
dispatch(updateSearchType(query.searchType));
dispatch(updateSearchTerms(query.searchTerms));
dispatch(updateSearchTeam(query.searchTeamId ?? teamId));
switch (query.mode) {
case RHSStates.CHANNEL_FILES:
if (channelId) {
dispatch(showChannelFiles(channelId));
return;
}
break;
case RHSStates.MENTION:
if (query.searchTerms.trim()) {
dispatch(showSearchResults(true));
} else {
dispatch(showMentions());
}
return;
case RHSStates.FLAG:
dispatch(showFlaggedPosts());
return;
case RHSStates.PIN:
if (channelId) {
dispatch(showPinnedPosts(channelId));
return;
}
break;
default:
if (query.searchTerms) {
dispatch(showSearchResults(false));
return;
}
break;
}
dispatch(updateRhsState(query.mode, channelId));
}, [dispatch, query, channelId, teamId]);
const handleUpdateSearchTeam = useCallback((newTeamId: string) => {
const cleanedTerms = searchActions.updateSearchTeam(newTeamId);
const params = new URLSearchParams(window.location.search);
params.set('searchTeamId', newTeamId);
const currentTerms = params.get('q') ?? '';
if (cleanedTerms.trim() !== currentTerms.trim()) {
params.set('q', cleanedTerms);
}
getHistory().replace(`${window.location.pathname}?${params.toString()}`);
}, [searchActions]);
return (
<SearchResults
isMentionSearch={isMentionSearch}
isFlaggedPosts={isFlaggedPosts}
isPinnedPosts={isPinnedPosts}
isChannelFiles={isChannelFiles}
isSideBarExpanded={isRhsExpanded}
isOpened={true}
channelDisplayName={channelDisplayName}
updateSearchTerms={searchActions.updateSearchTerms}
updateSearchTeam={handleUpdateSearchTeam}
getMorePostsForSearch={searchActions.getMorePostsForSearch}
getMoreFilesForSearch={searchActions.getMoreFilesForSearch}
setSearchFilterType={searchActions.setSearchFilterType}
searchFilterType={searchActions.searchFilterType}
searchType={query.searchType}
crossTeamSearchEnabled={crossTeamSearchEnabled}
/>
);
}
@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable formatjs/enforce-placeholders */
import type {MessageDescriptor} from 'react-intl';
import {defineMessage} from 'react-intl';
import {RHSStates} from 'utils/constants';
export function getSearchPopoutTitle(mode: string): MessageDescriptor {
switch (mode) {
case RHSStates.MENTION:
return defineMessage({
id: 'rhs_search_popout.title.mentions',
defaultMessage: 'Recent Mentions - {serverName}',
});
case RHSStates.FLAG:
return defineMessage({
id: 'rhs_search_popout.title.saved',
defaultMessage: 'Saved Messages - {serverName}',
});
case RHSStates.PIN:
return defineMessage({
id: 'rhs_search_popout.title.pinned',
defaultMessage: 'Pinned Messages - {channelName} - {serverName}',
});
case RHSStates.CHANNEL_FILES:
return defineMessage({
id: 'rhs_search_popout.title.channel_files',
defaultMessage: 'Channel Files - {channelName} - {serverName}',
});
default:
return defineMessage({
id: 'rhs_search_popout.title.search',
defaultMessage: 'Search Results for "{searchTerms}" - {serverName}',
});
}
}
@@ -8,8 +8,6 @@ import type {Dispatch} from 'redux';
import type {Channel} from '@mattermost/types/channels';
import type {ServerError} from '@mattermost/types/errors';
import {getMorePostsForSearch, getMoreFilesForSearch} from 'mattermost-redux/actions/search';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getIsCrossTeamSearchEnabled} from 'mattermost-redux/selectors/entities/general';
import {autocompleteChannelsForSearch} from 'actions/channel_actions';
@@ -19,15 +17,12 @@ import {
updateSearchTeam,
updateSearchTermsForShortcut,
showSearchResults,
showChannelFiles,
closeRightHandSide,
updateRhsState,
setRhsExpanded,
openRHSSearch,
filterFilesSearchByExt,
updateSearchType,
} from 'actions/views/rhs';
import {getRhsState, getSearchTeam, getSearchTerms, getSearchType, getIsSearchingTerm, getIsRhsOpen, getIsRhsExpanded} from 'selectors/rhs';
import {getRhsState, getSearchTerms, getSearchType, getIsSearchingTerm, getIsRhsOpen, getIsRhsExpanded} from 'selectors/rhs';
import {getIsMobileView} from 'selectors/views/browser';
import {RHSStates} from 'utils/constants';
@@ -38,17 +33,14 @@ import Search from './search';
function mapStateToProps(state: GlobalState) {
const rhsState = getRhsState(state);
const currentChannel = getCurrentChannel(state);
const isMobileView = getIsMobileView(state);
const isRhsOpen = getIsRhsOpen(state);
const crossTeamSearchEnabled = getIsCrossTeamSearchEnabled(state);
return {
currentChannel,
isRhsExpanded: getIsRhsExpanded(state),
isSearchingTerm: getIsSearchingTerm(state),
searchTerms: getSearchTerms(state),
searchTeam: getSearchTeam(state),
searchType: getSearchType(state),
searchVisible: rhsState !== null && (![
RHSStates.PLUGIN,
@@ -78,16 +70,11 @@ function mapDispatchToProps(dispatch: Dispatch) {
updateSearchTermsForShortcut,
updateSearchType,
showSearchResults,
showChannelFiles,
setRhsExpanded,
closeRightHandSide,
autocompleteChannelsForSearch: autocompleteChannels,
autocompleteUsersInTeam: autocompleteUsersInCurrentTeam,
updateRhsState,
getMorePostsForSearch,
openRHSSearch,
getMoreFilesForSearch,
filterFilesSearchByExt,
}, dispatch),
};
}
@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState, useRef, useCallback} from 'react';
import React, {useEffect, useState, useRef} from 'react';
import type {ChangeEvent, FormEvent} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
@@ -9,6 +9,7 @@ import {useSelector} from 'react-redux';
import {getCurrentChannelNameForSearchShortcut} from 'mattermost-redux/selectors/entities/channels';
import HeaderIconWrapper from 'components/channel_header/components/header_icon_wrapper';
import useSearchResultsActions from 'components/common/hooks/use_search_results_actions';
import SearchBar from 'components/search_bar/search_bar';
import SearchHint from 'components/search_hint/search_hint';
import SearchResults from 'components/search_results';
@@ -26,7 +27,7 @@ import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent';
import type {SearchType} from 'types/store/rhs';
import type {Props, SearchFilterType} from './types';
import type {Props} from './types';
interface SearchHintOption {
searchTerm: string;
@@ -82,12 +83,7 @@ const Search = ({
autocompleteChannelsForSearch,
autocompleteUsersInTeam,
closeRightHandSide,
filterFilesSearchByExt,
getMoreFilesForSearch,
getMorePostsForSearch,
openRHSSearch,
setRhsExpanded,
showChannelFiles,
showSearchResults,
updateRhsState,
updateSearchTeam,
@@ -104,13 +100,11 @@ const Search = ({
isPinnedPosts,
isRhsExpanded,
isSearchingTerm,
searchTeam,
searchTerms = '',
searchType,
searchVisible,
channelDisplayName = '',
children,
currentChannel,
enableFindShortcut,
getFocus,
hideSearchBar,
@@ -129,7 +123,7 @@ const Search = ({
const [visibleSearchHintOptions, setVisibleSearchHintOptions] = useState<SearchHintOption[]>(
determineVisibleSearchHintOptions(searchTerms, searchType),
);
const [searchFilterType, setSearchFilterType] = useState<SearchFilterType>('all');
const searchActions = useSearchResultsActions();
const suggestionProviders = useRef<Provider[]>([
new SearchDateProvider(),
@@ -190,22 +184,6 @@ const Search = ({
}
}, [isMobileView, searchTerms]);
const getMorePostsForSearchCallback = useCallback(() => {
let team = searchTeam;
if (isMentionSearch) {
team = '';
}
getMorePostsForSearch(team);
}, [searchTeam, isMentionSearch, getMorePostsForSearch]);
const getMoreFilesForSearchCallback = useCallback(() => {
let team = searchTeam;
if (isMentionSearch) {
team = '';
}
getMoreFilesForSearch(team);
}, [searchTeam, isMentionSearch, getMoreFilesForSearch]);
// handle cloding of rhs-flyout
const handleClose = (): void => closeRightHandSide();
@@ -248,19 +226,9 @@ const Search = ({
};
const handleUpdateSearchTeamFromResult = async (teamId: string) => {
updateSearchTeam(teamId);
const newTerms = searchTerms.
replace(/\bin:[^\s]*/gi, '').replace(/\s{2,}/g, ' ').
replace(/\bfrom:[^\s]*/gi, '').replace(/\s{2,}/g, ' ');
if (newTerms.trim() !== searchTerms.trim()) {
updateSearchTerms(newTerms);
}
handleSearch().then(() => {
setKeepInputFocused(false);
setFocused(false);
});
searchActions.updateSearchTeam(teamId);
setKeepInputFocused(false);
setFocused(false);
};
const handleUpdateSearchTerms = (terms: string): void => {
@@ -373,44 +341,6 @@ const Search = ({
updateSearchType('');
};
const handleShrink = (): void => {
setRhsExpanded(false);
};
const handleSetSearchFilter = (filterType: SearchFilterType): void => {
switch (filterType) {
case 'documents':
filterFilesSearchByExt(['doc', 'pdf', 'docx', 'odt', 'rtf', 'txt']);
break;
case 'spreadsheets':
filterFilesSearchByExt(['xls', 'xlsx', 'ods']);
break;
case 'presentations':
filterFilesSearchByExt(['ppt', 'pptx', 'odp']);
break;
case 'code':
filterFilesSearchByExt(['py', 'go', 'java', 'kt', 'c', 'cpp', 'h', 'html', 'js', 'ts', 'cs', 'vb', 'php', 'pl', 'r', 'rb', 'sql', 'swift', 'json']);
break;
case 'images':
filterFilesSearchByExt(['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'svg', 'xcf']);
break;
case 'audio':
filterFilesSearchByExt(['ogg', 'mp3', 'wav', 'flac']);
break;
case 'video':
filterFilesSearchByExt(['ogm', 'mp4', 'avi', 'webm', 'mov', 'mkv', 'mpeg', 'mpg']);
break;
default:
filterFilesSearchByExt([]);
}
setSearchFilterType(filterType);
if (isChannelFiles && currentChannel) {
showChannelFiles(currentChannel.id);
} else {
showSearchResults(false);
}
};
const setHoverHintIndex = (_highlightedSearchHintIndex: number): void => {
setHighlightedSearchHintIndex(_highlightedSearchHintIndex);
setIndexChangedViaKeyPress(false);
@@ -547,18 +477,16 @@ const Search = ({
isFlaggedPosts={isFlaggedPosts}
isPinnedPosts={isPinnedPosts}
isChannelFiles={isChannelFiles}
shrink={handleShrink}
channelDisplayName={channelDisplayName}
isOpened={isSideBarRightOpen}
updateSearchTerms={handleAddSearchTerm}
updateSearchTeam={handleUpdateSearchTeamFromResult}
handleSearchHintSelection={handleSearchHintSelection}
isSideBarExpanded={isRhsExpanded}
getMorePostsForSearch={getMorePostsForSearchCallback}
getMoreFilesForSearch={getMoreFilesForSearchCallback}
setSearchFilterType={handleSetSearchFilter}
searchFilterType={searchFilterType}
setSearchType={(value: SearchType) => updateSearchType(value)}
getMorePostsForSearch={searchActions.getMorePostsForSearch}
getMoreFilesForSearch={searchActions.getMoreFilesForSearch}
setSearchFilterType={searchActions.setSearchFilterType}
searchFilterType={searchActions.searchFilterType}
searchType={searchType || 'messages'}
crossTeamSearchEnabled={crossTeamSearchEnabled}
/>
@@ -26,7 +26,6 @@ export type StateProps = {
isRhsExpanded: boolean;
isSearchingTerm: boolean;
searchTerms: string;
searchTeam: string;
searchType: SearchType;
searchVisible: boolean;
hideMobileSearchBarInRHS: boolean;
@@ -34,7 +33,6 @@ export type StateProps = {
isFlaggedPosts: boolean;
isPinnedPosts: boolean;
isChannelFiles: boolean;
currentChannel?: Channel;
isMobileView: boolean;
crossTeamSearchEnabled: boolean;
}
@@ -46,16 +44,11 @@ export type DispatchProps = {
updateSearchTermsForShortcut: () => void;
updateSearchType: (searchType: string) => Action;
showSearchResults: (isMentionSearch: boolean) => unknown;
showChannelFiles: (channelId: string) => void;
setRhsExpanded: (expanded: boolean) => Action;
closeRightHandSide: () => void;
autocompleteChannelsForSearch: (term: string, teamId: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => void;
autocompleteUsersInTeam: (username: string) => Promise<UserAutocomplete>;
updateRhsState: (rhsState: string) => void;
getMorePostsForSearch: (teamId: string) => void;
openRHSSearch: () => void;
getMoreFilesForSearch: (teamId: string) => void;
filterFilesSearchByExt: (extensions: string[]) => void;
};
}
@@ -1,10 +1,186 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {arePropsEqual} from 'components/search_results/search_results';
import {within} from '@testing-library/react';
import React from 'react';
import SearchResults, {arePropsEqual} from 'components/search_results/search_results';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {getHistory} from 'utils/browser_history';
import {popoutRhsSearch} from 'utils/popouts/popout_windows';
import {TestHelper} from 'utils/test_helper';
import type {Props} from './types';
jest.mock('utils/popouts/popout_windows', () => ({
popoutRhsSearch: jest.fn(),
canPopout: () => true,
}));
jest.mock('utils/browser_history', () => ({
getHistory: jest.fn(() => ({push: jest.fn()})),
}));
jest.mock('components/search_results_header', () => ({
__esModule: true,
default: (props: {newWindowHandler?: () => void; children: React.ReactNode}) => (
<div data-testid='search-results-header'>
{props.children}
{props.newWindowHandler && (
<button
data-testid='popout-button'
onClick={props.newWindowHandler}
/>
)}
</div>
),
}));
window.HTMLElement.prototype.scrollTo = jest.fn();
describe('components/SearchResults', () => {
describe('shouldRenderFromProps', () => {
const team = TestHelper.getTeamMock({id: 'team1', name: 'test-team'});
const channel = TestHelper.getChannelMock({
id: 'channel1',
name: 'test-channel',
display_name: 'Test Channel',
team_id: team.id,
});
const baseState = {
entities: {
channels: {
currentChannelId: channel.id,
channels: {[channel.id]: channel},
channelsInTeam: {[team.id]: new Set([channel.id])},
myMembers: {},
},
teams: {
currentTeamId: team.id,
teams: {[team.id]: team},
myMembers: {[team.id]: {}},
},
general: {
config: {},
license: {},
serverVersion: '',
},
users: {
currentUserId: 'user1',
profiles: {},
},
preferences: {myPreferences: {}},
roles: {roles: {}},
},
views: {
rhs: {
rhsState: 'search',
isSidebarExpanded: false,
searchTeam: team.id,
searchTerms: 'hello',
},
},
plugins: {
components: {},
},
};
const baseProps: Props = {
results: [],
fileResults: [],
matches: {},
searchPage: 0,
searchTerms: 'hello',
searchSelectedType: 'messages',
isSearchingTerm: false,
isSearchingFlaggedPost: false,
isSearchingPinnedPost: false,
isSearchGettingMore: false,
isSearchAtEnd: true,
isSearchFilesAtEnd: true,
currentTeamName: team.name,
channelDisplayName: '',
crossTeamSearchEnabled: false,
isChannelFiles: false,
isFlaggedPosts: false,
isMentionSearch: false,
isOpened: true,
isPinnedPosts: false,
isSideBarExpanded: false,
searchFilterType: 'all',
searchType: 'messages',
getMoreFilesForSearch: jest.fn(),
getMorePostsForSearch: jest.fn(),
setSearchFilterType: jest.fn(),
updateSearchTeam: jest.fn(),
updateSearchTerms: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(getHistory).mockReturnValue({push: jest.fn()} as any);
});
function renderSearchResults(propOverrides?: Partial<Props>) {
const props = {...baseProps, ...propOverrides};
return renderWithContext(
<SearchResults {...props}/>,
baseState,
);
}
describe('newWindowHandler', () => {
function clickPopout(propOverrides?: Partial<Props>) {
const {container} = renderSearchResults(propOverrides);
within(container).getByTestId('popout-button').click();
}
test('should resolve mode from boolean props and pass channel only when needed', () => {
clickPopout({isMentionSearch: true});
expect(jest.mocked(popoutRhsSearch)).toHaveBeenCalledWith(
expect.any(String), team.name, 'hello', 'mention', 'messages', undefined, team.id,
);
jest.clearAllMocks();
clickPopout({isFlaggedPosts: true});
expect(jest.mocked(popoutRhsSearch)).toHaveBeenCalledWith(
expect.any(String), team.name, 'hello', 'flag', 'messages', undefined, team.id,
);
jest.clearAllMocks();
clickPopout({isPinnedPosts: true});
expect(jest.mocked(popoutRhsSearch)).toHaveBeenCalledWith(
expect.any(String), team.name, 'hello', 'pin', 'messages', channel.name, team.id,
);
jest.clearAllMocks();
clickPopout({isChannelFiles: true});
expect(jest.mocked(popoutRhsSearch)).toHaveBeenCalledWith(
expect.any(String), team.name, 'hello', 'channel-files', 'messages', channel.name, team.id,
);
jest.clearAllMocks();
clickPopout();
expect(jest.mocked(popoutRhsSearch)).toHaveBeenCalledWith(
expect.any(String), team.name, 'hello', 'search', 'messages', undefined, team.id,
);
});
});
describe('handleChannelNameClick', () => {
test('should navigate to channel URL when channel display name is clicked', () => {
const pushMock = jest.fn();
jest.mocked(getHistory).mockReturnValue({push: pushMock} as any);
renderSearchResults({channelDisplayName: 'Test Channel'});
screen.getByText('Test Channel').click();
expect(pushMock).toHaveBeenCalledWith(`/${team.name}/channels/${channel.name}`);
});
});
describe('arePropsEqual', () => {
const result1 = {test: 'test'};
const result2 = {test: 'test'};
const fileResult1 = {test: 'test'};
@@ -18,9 +194,6 @@ describe('components/SearchResults', () => {
fileResults,
};
// Using a lot of anys here since the function is only used by SearchResults so the parameters are bound to its props
// But the tests are written using arbitrary props
test('should not render', () => {
expect(arePropsEqual(props as any, {...props} as any)).toBeTruthy();
expect(arePropsEqual(props as any, {...props, results: [result1, result2]} as any)).toBeTruthy();
@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useEffect, useRef, useState} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl, FormattedMessage, defineMessage} from 'react-intl';
import {useSelector} from 'react-redux';
@@ -10,22 +10,30 @@ import type {FileSearchResultItem as FileSearchResultItemType} from '@mattermost
import type {Post} from '@mattermost/types/posts';
import {debounce} from 'mattermost-redux/actions/helpers';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {isDateLine, getDateForDateLine} from 'mattermost-redux/utils/post_list';
import {getFilesDropdownPluginMenuItems} from 'selectors/plugins';
import {getSearchTeam} from 'selectors/rhs';
import Scrollbars from 'components/common/scrollbars';
import FileSearchResultItem from 'components/file_search_results';
import NoResultsIndicator from 'components/no_results_indicator/no_results_indicator';
import {NoResultsVariant} from 'components/no_results_indicator/types';
import DateSeparator from 'components/post_view/date_separator';
import {getSearchPopoutTitle} from 'components/rhs_search_popout/title';
import SearchHint from 'components/search_hint/search_hint';
import SearchResultsHeader from 'components/search_results_header';
import LoadingWrapper from 'components/widgets/loading/loading_wrapper';
import {searchHintOptions, DataSearchTypes} from 'utils/constants';
import {getHistory} from 'utils/browser_history';
import {searchHintOptions, DataSearchTypes, RHSStates} from 'utils/constants';
import {isFileAttachmentsEnabled} from 'utils/file_utils';
import {popoutRhsSearch} from 'utils/popouts/popout_windows';
import type {RhsState, SearchType} from 'types/store/rhs';
import FilesFilterMenu from './files_filter_menu';
import MessageOrFileSelector from './messages_or_files_selector';
@@ -48,6 +56,9 @@ const SearchResults: React.FC<Props> = (props: Props): JSX.Element => {
const [searchType, setSearchType] = useState<string>(props.searchType);
const filesDropdownPluginMenuItems = useSelector(getFilesDropdownPluginMenuItems);
const config = useSelector(getConfig);
const currentChannel = useSelector(getCurrentChannel);
const currentTeam = useSelector(getCurrentTeam);
const searchTeamId = useSelector(getSearchTeam);
const intl = useIntl();
useEffect(() => {
@@ -227,10 +238,42 @@ const SearchResults: React.FC<Props> = (props: Props): JSX.Element => {
const formattedTitle = intl.formatMessage(titleDescriptor);
const handleOptionSelection = (term: string): void => {
handleSearchHintSelection();
handleSearchHintSelection?.();
updateSearchTerms(term);
};
const newWindowHandler = useCallback(() => {
let mode: NonNullable<RhsState> = RHSStates.SEARCH;
if (isMentionSearch) {
mode = RHSStates.MENTION;
} else if (isFlaggedPosts) {
mode = RHSStates.FLAG;
} else if (isPinnedPosts) {
mode = RHSStates.PIN;
} else if (isChannelFiles) {
mode = RHSStates.CHANNEL_FILES;
}
const needsChannel = isPinnedPosts || isChannelFiles;
const popoutTitle = getSearchPopoutTitle(mode);
popoutRhsSearch(
intl.formatMessage(popoutTitle, {serverName: '{serverName}', channelName: '{channelName}', searchTerms}),
currentTeam?.name ?? '',
searchTerms,
mode,
searchType as SearchType,
needsChannel ? currentChannel?.name : undefined,
searchTeamId,
);
}, [isMentionSearch, isFlaggedPosts, isPinnedPosts, isChannelFiles, intl, searchTerms, searchType, currentTeam?.name, currentChannel?.name, searchTeamId]);
const handleChannelNameClick = useCallback(() => {
if (currentTeam?.name && currentChannel?.name) {
getHistory().push(`/${currentTeam.name}/channels/${currentChannel.name}`);
}
}, [currentTeam?.name, currentChannel?.name]);
switch (true) {
case isLoading:
contentItems = (
@@ -341,11 +384,20 @@ const SearchResults: React.FC<Props> = (props: Props): JSX.Element => {
id='searchContainer'
className='SearchResults sidebar-right__body'
>
<SearchResultsHeader>
<SearchResultsHeader
newWindowHandler={newWindowHandler}
>
<h2 id='rhsPanelTitle'>
{formattedTitle}
</h2>
{props.channelDisplayName && <div className='sidebar--right__title__channel'>{props.channelDisplayName}</div>}
{props.channelDisplayName &&
<button
className='style--none sidebar--right__title__channel'
onClick={handleChannelNameClick}
>
{props.channelDisplayName}
</button>
}
</SearchResultsHeader>
{isMessagesSearch &&
<MessageOrFileSelector
@@ -373,7 +425,9 @@ const SearchResults: React.FC<Props> = (props: Props): JSX.Element => {
/>
</div>
}
<SearchLimitsBanner searchType={searchType}/>
<SearchLimitsBanner
searchType={searchType}
/>
<Scrollbars
ref={scrollbars}
color='--center-channel-color-rgb'
@@ -422,7 +476,7 @@ export const arePropsEqual = (props: Props, nextProps: Props): boolean => {
continue;
}
if (nextProps[key] !== props[key]) {
if (nextProps[key as keyof Props] !== props[key as keyof Props]) {
return false;
}
}
@@ -1,9 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type React from 'react';
import type {IntlShape} from 'react-intl';
import type {FileInfo} from '@mattermost/types/files';
import type {Post} from '@mattermost/types/posts';
@@ -12,31 +9,32 @@ import type {SearchFilterType} from 'components/search/types';
import type {SearchType} from 'types/store/rhs';
export type OwnProps = {
[key: string]: any;
isSideBarExpanded: boolean;
isMentionSearch: boolean;
isFlaggedPosts: boolean;
isPinnedPosts: boolean;
updateSearchTerms: (terms: string) => void;
getMorePostsForSearch: () => void;
getMoreFilesForSearch: () => void;
shrink: () => void;
isCard?: boolean;
isOpened?: boolean;
channelDisplayName?: string;
children?: React.ReactNode;
searchType: SearchType;
setSearchType: (searchType: SearchType) => void;
crossTeamSearchEnabled: boolean;
isCard?: boolean;
isChannelFiles: boolean;
isFlaggedPosts: boolean;
isMentionSearch: boolean;
isOpened?: boolean;
isPinnedPosts: boolean;
isSideBarExpanded: boolean;
searchFilterType: SearchFilterType;
searchType: SearchType;
getMoreFilesForSearch: () => void;
getMorePostsForSearch: () => void;
handleSearchHintSelection?: () => void;
setSearchFilterType: (filterType: SearchFilterType) => void;
updateSearchTeam: (teamId: string) => void;
crossTeamSearchEnabled: boolean;
updateSearchTerms: (terms: string) => void;
};
export type StateProps = {
currentTeamName: string;
results: Array<Post|string>;
fileResults: FileInfo[];
matches: Record<string, string[]>;
searchPage: number;
searchTerms: string;
searchSelectedType: string;
isSearchingTerm: boolean;
@@ -47,8 +45,4 @@ export type StateProps = {
isSearchFilesAtEnd: boolean;
};
export type IntlProps = {
intl: IntlShape;
};
export type Props = OwnProps & StateProps & IntlProps;
export type Props = OwnProps & StateProps;
@@ -1,10 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useMemo} from 'react';
import React, {useCallback, useEffect, useMemo} from 'react';
import {defineMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {useParams} from 'react-router-dom';
import {useLocation, useParams} from 'react-router-dom';
import type {Channel} from '@mattermost/types/channels';
@@ -21,6 +21,7 @@ import {makeGetThreadOrSynthetic} from 'mattermost-redux/selectors/entities/thre
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {loadStatusesByIds} from 'actions/status_actions';
import {selectPost} from 'actions/views/rhs';
import {markThreadAsRead} from 'actions/views/threads';
import {usePost} from 'components/common/hooks/usePost';
@@ -29,6 +30,7 @@ import ThreadPane from 'components/threading/global_threads/thread_pane';
import ThreadViewer from 'components/threading/thread_viewer';
import UnreadsStatusHandler from 'components/unreads_status_handler';
import {getHistory} from 'utils/browser_history';
import {Constants} from 'utils/constants';
import usePopoutTitle from 'utils/popouts/use_popout_title';
import {isDesktopApp} from 'utils/user_agent';
@@ -54,6 +56,8 @@ export default function ThreadPopout() {
const getThreadOrSynthetic = useMemo(() => makeGetThreadOrSynthetic(), []);
const {postId, team: teamName} = useParams<{team: string; postId: string}>();
const location = useLocation();
const returnTo = new URLSearchParams(location.search).get('returnTo');
const currentUserId = useSelector(getCurrentUserId);
const post = usePost(postId);
@@ -70,6 +74,12 @@ export default function ThreadPopout() {
usePopoutTitle(getThreadPopoutTitle(channel));
useEffect(() => {
if (post) {
dispatch(selectPost(post));
}
}, [dispatch, post]);
const channelId = post?.channel_id;
useEffect(() => {
if (channelId) {
@@ -126,6 +136,12 @@ export default function ThreadPopout() {
};
}, []);
const backAction = useCallback(() => {
if (returnTo) {
getHistory().replace(returnTo);
}
}, [returnTo]);
if (!thread) {
return null;
}
@@ -135,6 +151,7 @@ export default function ThreadPopout() {
{isDesktopApp() && <UnreadsStatusHandler/>}
<ThreadPane
thread={thread}
backAction={returnTo ? backAction : undefined}
>
<ThreadViewer
rootPostId={postId}
@@ -24,6 +24,10 @@
.back {
display: none;
flex-shrink: 0;
&.hasBackAction {
display: unset;
}
}
.FollowButton,
@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {memo, useCallback} from 'react';
import type {ReactNode} from 'react';
import {useIntl} from 'react-intl';
@@ -36,11 +37,13 @@ const getPostsForThread = makeGetPostsForThread();
type Props = {
thread: UserThread | UserThreadSynthetic;
children?: ReactNode;
backAction?: () => void;
};
const ThreadPane = ({
thread,
children,
backAction,
}: Props) => {
const intl = useIntl();
const {formatMessage} = intl;
@@ -66,7 +69,13 @@ const ThreadPane = ({
const channel = useSelector((state: GlobalState) => getChannel(state, channelId));
const post = useSelector((state: GlobalState) => getPost(state, thread.id));
const postsInThread = useSelector((state: GlobalState) => getPostsForThread(state, post.id));
const selectHandler = useCallback(() => select(), []);
const selectHandler = useCallback(() => {
if (backAction) {
backAction();
return;
}
select();
}, [select, backAction]);
let unreadTimestamp = post.edit_at || post.create_at;
// if we have the whole thread, get the posts in it, sorted from newest to oldest.
@@ -103,7 +112,7 @@ const ThreadPane = ({
heading={(
<>
<Button
className='Button___icon Button___large back'
className={classNames('Button___icon Button___large back', {hasBackAction: backAction})}
onClick={selectHandler}
>
<i className='icon icon-arrow-back-ios'/>
+5
View File
@@ -5818,6 +5818,11 @@
"rhs_root.mobile.add_reaction": "Add Reaction",
"rhs_root.mobile.flag": "Save Message",
"rhs_root.mobile.unflag": "Remove from Saved",
"rhs_search_popout.title.channel_files": "Channel Files - {channelName} - {serverName}",
"rhs_search_popout.title.mentions": "Recent Mentions - {serverName}",
"rhs_search_popout.title.pinned": "Pinned Messages - {channelName} - {serverName}",
"rhs_search_popout.title.saved": "Saved Messages - {serverName}",
"rhs_search_popout.title.search": "Search Results for \"{searchTerms}\" - {serverName}",
"rhs_thread.rootPostDeletedMessage.body": "Part of this thread has been deleted due to a data retention policy. You can no longer reply to this thread.",
"rhs_thread.toast.newReplies": "New Replies",
"save": "Save",
+1 -1
View File
@@ -1444,7 +1444,7 @@ export default class PluginRegistry {
return dispatchPluginComponentAction('SystemConsoleGroupTable', this.id, component);
});
registerRHSPluginPopoutListener = reArg(['pluginId', 'onPopoutOpened'], ({pluginId, onPopoutOpened}: {pluginId: string; onPopoutOpened: (teamName: string, channelName: string, listeners: Partial<PopoutListeners>) => void}) => {
registerRHSPluginPopoutListener = reArg(['pluginId', 'onPopoutOpened'], ({pluginId, onPopoutOpened}: {pluginId: string; onPopoutOpened: (teamName: string, channelName: string | undefined, listeners: Partial<PopoutListeners>) => void}) => {
registerRHSPluginPopoutListener(pluginId, onPopoutOpened);
});
}
@@ -5,9 +5,8 @@ import DesktopApp from 'utils/desktop_api';
import {getBasePath} from 'utils/url';
import {isDesktopApp} from 'utils/user_agent';
import {FOCUS_REPLY_POST, popoutRhsPlugin, popoutThread} from './popout_windows';
import {FOCUS_REPLY_POST, popoutRhsPlugin, popoutRhsSearch, popoutThread} from './popout_windows';
// Mock dependencies
jest.mock('utils/desktop_api', () => ({
__esModule: true,
default: {
@@ -36,20 +35,33 @@ jest.mock('./browser_popouts', () => {
};
});
const mockDesktopApp = DesktopApp as jest.Mocked<typeof DesktopApp>;
const mockIsDesktopApp = isDesktopApp as jest.MockedFunction<typeof isDesktopApp>;
const mockGetBasePath = getBasePath as jest.MockedFunction<typeof getBasePath>;
const mockDesktopApp = jest.mocked(DesktopApp);
const getMockSetupBrowserPopout = () => {
return (globalThis as typeof globalThis & {mockSetupBrowserPopout: jest.MockedFunction<() => unknown>}).mockSetupBrowserPopout;
};
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: jest.fn(),
onClosePopout: jest.fn(),
};
function setupDesktop() {
jest.mocked(isDesktopApp).mockReturnValue(true);
mockDesktopApp.setupDesktopPopout.mockResolvedValue(mockListeners);
}
function setupBrowser(basePath = '') {
jest.mocked(isDesktopApp).mockReturnValue(false);
jest.mocked(getBasePath).mockReturnValue(basePath);
getMockSetupBrowserPopout().mockReturnValue(mockListeners);
}
describe('popout_windows', () => {
beforeEach(() => {
getMockSetupBrowserPopout().mockClear();
// Default: no subpath
mockGetBasePath.mockReturnValue('');
jest.mocked(getBasePath).mockReturnValue('');
});
describe('popoutThread', () => {
@@ -60,13 +72,7 @@ describe('popout_windows', () => {
});
it('should call popout with correct path and props for desktop app', async () => {
mockIsDesktopApp.mockReturnValue(true);
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: jest.fn(),
onClosePopout: jest.fn(),
};
mockDesktopApp.setupDesktopPopout.mockResolvedValue(mockListeners);
setupDesktop();
await popoutThread('Thread - {channelName} - {teamName} - {serverName}', 'thread-123', 'test-team', mockOnFocusPost);
@@ -80,13 +86,7 @@ describe('popout_windows', () => {
});
it('should call popout with correct path and props for browser popout', async () => {
mockIsDesktopApp.mockReturnValue(false);
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: jest.fn(),
onClosePopout: jest.fn(),
};
getMockSetupBrowserPopout().mockReturnValue(mockListeners);
setupBrowser();
await popoutThread('Thread - {channelName} - {teamName} - {serverName}', 'thread-123', 'test-team', mockOnFocusPost);
@@ -96,13 +96,7 @@ describe('popout_windows', () => {
});
it('should return popout listeners', async () => {
mockIsDesktopApp.mockReturnValue(true);
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: jest.fn(),
onClosePopout: jest.fn(),
};
mockDesktopApp.setupDesktopPopout.mockResolvedValue(mockListeners);
setupDesktop();
const result = await popoutThread('Thread - {channelName} - {teamName} - {serverName}', 'thread-123', 'test-team', mockOnFocusPost);
@@ -110,19 +104,17 @@ describe('popout_windows', () => {
});
it('should set up listener for FOCUS_REPLY_POST messages', async () => {
mockIsDesktopApp.mockReturnValue(true);
const mockListener = jest.fn();
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: mockListener,
onClosePopout: jest.fn(),
};
mockDesktopApp.setupDesktopPopout.mockResolvedValue(mockListeners);
setupDesktop();
const onMessage = jest.fn();
mockDesktopApp.setupDesktopPopout.mockResolvedValue({
...mockListeners,
onMessageFromPopout: onMessage,
});
await popoutThread('Thread - {channelName} - {teamName} - {serverName}', 'thread-123', 'test-team', mockOnFocusPost);
expect(mockListener).toHaveBeenCalledTimes(1);
const registeredListener = mockListener.mock.calls[0][0];
expect(onMessage).toHaveBeenCalledTimes(1);
const registeredListener = onMessage.mock.calls[0][0];
registeredListener(FOCUS_REPLY_POST, 'post-123', '/team/pl/post-123');
@@ -131,14 +123,7 @@ describe('popout_windows', () => {
});
it('should include subpath in popout URL when basename is set', async () => {
mockIsDesktopApp.mockReturnValue(false);
mockGetBasePath.mockReturnValue('/company/mattermost');
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: jest.fn(),
onClosePopout: jest.fn(),
};
getMockSetupBrowserPopout().mockReturnValue(mockListeners);
setupBrowser('/company/mattermost');
await popoutThread('Thread - {channelName} - {teamName} - {serverName}', 'thread-123', 'test-team', mockOnFocusPost);
@@ -150,18 +135,12 @@ describe('popout_windows', () => {
describe('popoutRhsPlugin', () => {
it('should call popout with correct path and props for desktop app', async () => {
mockIsDesktopApp.mockReturnValue(true);
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: jest.fn(),
onClosePopout: jest.fn(),
};
mockDesktopApp.setupDesktopPopout.mockResolvedValue(mockListeners);
setupDesktop();
await popoutRhsPlugin('{pluginDisplayName} - {serverName}', 'test-plugin-id', 'test-team', 'test-channel');
expect(mockDesktopApp.setupDesktopPopout).toHaveBeenCalledWith(
'/_popout/rhs/test-team/test-channel/plugin/test-plugin-id',
'/_popout/rhs/test-team/plugin/test-plugin-id?channel=test-channel',
{
isRHS: true,
titleTemplate: '{pluginDisplayName} - {serverName}',
@@ -170,29 +149,17 @@ describe('popout_windows', () => {
});
it('should call popout with correct path and props for browser popout', async () => {
mockIsDesktopApp.mockReturnValue(false);
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: jest.fn(),
onClosePopout: jest.fn(),
};
getMockSetupBrowserPopout().mockReturnValue(mockListeners);
setupBrowser();
await popoutRhsPlugin('{pluginDisplayName} - {serverName}', 'test-plugin-id', 'test-team', 'test-channel');
expect(getMockSetupBrowserPopout()).toHaveBeenCalledWith(
'/_popout/rhs/test-team/test-channel/plugin/test-plugin-id',
'/_popout/rhs/test-team/plugin/test-plugin-id?channel=test-channel',
);
});
it('should return popout listeners', async () => {
mockIsDesktopApp.mockReturnValue(true);
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: jest.fn(),
onClosePopout: jest.fn(),
};
mockDesktopApp.setupDesktopPopout.mockResolvedValue(mockListeners);
setupDesktop();
const result = await popoutRhsPlugin('{pluginDisplayName} - {serverName}', 'test-plugin-id', 'test-team', 'test-channel');
@@ -200,21 +167,80 @@ describe('popout_windows', () => {
});
it('should include subpath in popout URL when basename is set', async () => {
mockIsDesktopApp.mockReturnValue(false);
mockGetBasePath.mockReturnValue('/company/mattermost');
const mockListeners = {
sendToPopout: jest.fn(),
onMessageFromPopout: jest.fn(),
onClosePopout: jest.fn(),
};
getMockSetupBrowserPopout().mockReturnValue(mockListeners);
setupBrowser('/company/mattermost');
await popoutRhsPlugin('{pluginDisplayName} - {serverName}', 'test-plugin-id', 'test-team', 'test-channel');
expect(getMockSetupBrowserPopout()).toHaveBeenCalledWith(
'/company/mattermost/_popout/rhs/test-team/test-channel/plugin/test-plugin-id',
'/company/mattermost/_popout/rhs/test-team/plugin/test-plugin-id?channel=test-channel',
);
});
});
});
describe('popoutRhsSearch', () => {
async function callDesktop(...args: Parameters<typeof popoutRhsSearch>) {
setupDesktop();
const result = await popoutRhsSearch(...args);
return result;
}
function expectDesktopPath(substring: string) {
expect(mockDesktopApp.setupDesktopPopout).toHaveBeenCalledWith(
expect.stringContaining(substring),
expect.objectContaining({isRHS: true}),
);
}
it('should call popout with correct path and query params for desktop app', async () => {
await callDesktop('Search Results - {serverName}', 'test-team', 'hello world', 'search', 'messages');
expectDesktopPath('/_popout/rhs/test-team/search');
expectDesktopPath('q=hello+world');
expectDesktopPath('type=messages');
expectDesktopPath('mode=search');
});
it('should include channel param when provided', async () => {
await callDesktop('Pinned Messages - {serverName}', 'test-team', '', 'pin', 'messages', 'town-square');
expectDesktopPath('channel=town-square');
});
it('should include searchTeamId when provided', async () => {
await callDesktop('Search Results - {serverName}', 'test-team', 'test', 'search', 'messages', undefined, 'team-123');
expectDesktopPath('searchTeamId=team-123');
});
it('should include searchTeamId with empty string for All Teams', async () => {
await callDesktop('Search Results - {serverName}', 'test-team', 'test', 'search', 'messages', undefined, '');
expectDesktopPath('searchTeamId=');
});
it('should call browser popout when not desktop app', async () => {
setupBrowser();
await popoutRhsSearch('Search Results - {serverName}', 'test-team', 'hello', 'search', 'messages');
expect(getMockSetupBrowserPopout()).toHaveBeenCalledWith(
expect.stringContaining('/_popout/rhs/test-team/search'),
);
expect(getMockSetupBrowserPopout()).toHaveBeenCalledWith(
expect.stringContaining('q=hello'),
);
});
it('should include subpath when basename is set', async () => {
setupBrowser('/company/mattermost');
await popoutRhsSearch('Search Results - {serverName}', 'test-team', 'hello', 'search', 'messages');
expect(getMockSetupBrowserPopout()).toHaveBeenCalledWith(
expect.stringContaining('/company/mattermost/_popout/rhs/test-team/search'),
);
});
it('should return popout listeners', async () => {
const result = await callDesktop('Search Results - {serverName}', 'test-team', 'test', 'search', 'messages');
expect(result).toEqual(mockListeners);
});
});
});
@@ -5,18 +5,21 @@ import type {PopoutViewProps} from '@mattermost/desktop-api';
import {Client4} from 'mattermost-redux/client';
import {RHSStates} from 'utils/constants';
import DesktopApp from 'utils/desktop_api';
import {getBasePath} from 'utils/url';
import {isDesktopApp} from 'utils/user_agent';
import type {RhsState, SearchType} from 'types/store/rhs';
import BrowserPopouts from './browser_popouts';
import {
sendToParent as sendToParentBrowser,
onMessageFromParent as onMessageFromParentBrowser,
} from './use_browser_popout';
const pluginPopoutListeners: Map<string, (teamName: string, channelName: string, listeners: Partial<PopoutListeners>) => void> = new Map();
export function registerRHSPluginPopoutListener(pluginId: string, onPopoutOpened: (teamName: string, channelName: string, listeners: Partial<PopoutListeners>) => void) {
const pluginPopoutListeners: Map<string, (teamName: string, channelName: string | undefined, listeners: Partial<PopoutListeners>) => void> = new Map();
export function registerRHSPluginPopoutListener(pluginId: string, onPopoutOpened: (teamName: string, channelName: string | undefined, listeners: Partial<PopoutListeners>) => void) {
pluginPopoutListeners.set(pluginId, onPopoutOpened);
}
@@ -52,10 +55,20 @@ export async function popoutRhsPlugin(
titleTemplate: string,
pluginId: string,
teamName: string,
channelName: string,
channelName?: string,
) {
const url = new URL(
`${getBasePath()}/_popout/rhs/${teamName}/plugin/${pluginId}`,
window.location.origin,
);
if (channelName) {
url.searchParams.set('channel', channelName);
}
const path = `${url.pathname}${url.search}`;
const listeners = await popout(
`${getBasePath()}/_popout/rhs/${teamName}/${channelName}/plugin/${pluginId}`,
path,
{
isRHS: true,
titleTemplate,
@@ -67,6 +80,40 @@ export async function popoutRhsPlugin(
return listeners;
}
export async function popoutRhsSearch(
titleTemplate: string,
teamName: string,
searchTerms: string,
mode: NonNullable<RhsState> = RHSStates.SEARCH,
searchType: SearchType = 'messages',
channelName?: string,
searchTeamId?: string,
) {
const url = new URL(
`${getBasePath()}/_popout/rhs/${teamName}/search`,
window.location.origin,
);
url.searchParams.set('q', searchTerms);
url.searchParams.set('type', searchType);
url.searchParams.set('mode', mode);
if (channelName) {
url.searchParams.set('channel', channelName);
}
if (searchTeamId !== undefined) {
url.searchParams.set('searchTeamId', searchTeamId);
}
const path = `${url.pathname}${url.search}`;
return popout(
path,
{
isRHS: true,
titleTemplate,
},
);
}
export async function popoutHelp() {
return popout(
'/_popout/help',
@@ -13,6 +13,9 @@ export function useBrowserPopout() {
useEffect(() => {
if (!isDesktopApp()) {
const unblockHistory = history.block((blockState) => {
if (blockState.pathname.startsWith('/_popout')) {
return undefined;
}
window.opener.postMessage({
channel: NAVIGATE_CHANNEL,
args: [blockState.pathname],