mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-27 05:37:15 -05:00
[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:
co-authored by
coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
parent
5aefff30cf
commit
2bd143ced7
@@ -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,
|
||||
|
||||
+11
-2
@@ -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'/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user