From 1fcb6a49a84e75310ce3ed72c6c99af7ca2c6a2c Mon Sep 17 00:00:00 2001 From: Julien Tant <785518+JulienTant@users.noreply.github.com> Date: Thu, 30 Jan 2025 09:50:08 -0700 Subject: [PATCH] [MM-60407] Add team selector to search bar (#29389) Automatic Merge --- .../support/ui/components/global_header.ts | 2 +- .../channels/search/team_selector.spec.ts | 100 +++++++++++ .../channels/src/actions/channel_actions.ts | 16 ++ webapp/channels/src/actions/user_actions.ts | 9 +- .../__snapshots__/dot_menu.test.tsx.snap | 2 +- .../src/components/dot_menu/dot_menu.tsx | 2 +- webapp/channels/src/components/menu/index.ts | 2 + webapp/channels/src/components/menu/menu.tsx | 9 +- .../src/components/menu/menu_item.tsx | 6 +- .../src/components/menu/menu_item_input.tsx | 47 ++++++ .../src/components/menu/menu_title.tsx | 36 ++++ .../src/components/new_search/hooks.tsx | 12 +- .../components/new_search/new_search.test.tsx | 3 +- .../src/components/new_search/new_search.tsx | 29 +++- .../components/new_search/search_box.test.tsx | 2 + .../src/components/new_search/search_box.tsx | 66 +++++++- .../new_search/search_box_hints.test.tsx | 2 + .../new_search/search_box_hints.tsx | 6 +- .../search_box_suggestions.test.tsx | 3 +- .../new_search/search_box_suggestions.tsx | 9 +- .../new_search/search_hint.test.tsx | 15 ++ .../src/components/new_search/search_hint.tsx | 21 ++- .../components/new_search/select_team.scss | 58 +++++++ .../src/components/new_search/select_team.tsx | 156 ++++++++++++++++++ .../channels/src/components/search/index.tsx | 20 ++- .../channels/src/components/search/search.tsx | 27 ++- .../channels/src/components/search/types.ts | 6 +- .../src/components/search_results/index.tsx | 4 +- .../messages_or_files_selector.scss | 2 +- .../messages_or_files_selector.tsx | 30 +--- .../src/components/suggestion/provider.tsx | 2 +- .../suggestion/search_channel_provider.tsx | 5 +- .../suggestion/search_user_provider.tsx | 12 +- .../components/widgets/inputs/input/input.tsx | 2 +- webapp/channels/src/i18n/en.json | 4 + .../mattermost-redux/src/actions/search.ts | 11 +- .../src/reducers/entities/search.ts | 3 +- .../channels/src/sass/components/_search.scss | 2 + webapp/channels/src/selectors/rhs.ts | 9 + 39 files changed, 657 insertions(+), 95 deletions(-) create mode 100644 e2e-tests/playwright/tests/functional/channels/search/team_selector.spec.ts create mode 100644 webapp/channels/src/components/menu/menu_item_input.tsx create mode 100644 webapp/channels/src/components/menu/menu_title.tsx create mode 100644 webapp/channels/src/components/new_search/select_team.scss create mode 100644 webapp/channels/src/components/new_search/select_team.tsx diff --git a/e2e-tests/playwright/support/ui/components/global_header.ts b/e2e-tests/playwright/support/ui/components/global_header.ts index 33d90ef14f..4dff67f135 100644 --- a/e2e-tests/playwright/support/ui/components/global_header.ts +++ b/e2e-tests/playwright/support/ui/components/global_header.ts @@ -46,7 +46,7 @@ export default class GlobalHeader { async closeSearch() { await expect(this.searchBox).toBeVisible(); - await this.searchBox.getByTestId('input-clear').click(); + await this.searchBox.getByTestId('searchBoxClose').click(); } } diff --git a/e2e-tests/playwright/tests/functional/channels/search/team_selector.spec.ts b/e2e-tests/playwright/tests/functional/channels/search/team_selector.spec.ts new file mode 100644 index 0000000000..e6696990de --- /dev/null +++ b/e2e-tests/playwright/tests/functional/channels/search/team_selector.spec.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createRandomTeam} from '@e2e-support/server'; +import {expect, test} from '@e2e-support/test_fixture'; + +test('team selector must show all my teams', async ({pw}) => { + const {adminClient, adminConfig, user, team} = await pw.initSetup(); + + // # Enable Cross Team Search Feature Flag + const newConfig = { + ...adminConfig, + FeatureFlags: { + ...adminConfig.FeatureFlags, + ExperimentalCrossTeamSearch: true, + }, + }; + await adminClient.updateConfig(newConfig); + + // # create 2 more teams and add the user to them + const teams = [team]; + for (let i = 0; i < 2; i++) { + const newTeam = await adminClient.createTeam(createRandomTeam('team', 'Team', 'O', true)); + await adminClient.addUsersToTeam(newTeam.id, [user.id]); + teams.push(newTeam); + } + + // # Log in a user in new browser context + const {channelsPage} = await pw.testBrowser.login(user); + + // # Visit a default channel page + await channelsPage.goto(team.name); + await channelsPage.toBeVisible(); + + // # Open the search UI + await channelsPage.globalHeader.openSearch(); + + // * Check that the team selector is visible + const page = channelsPage.page; + await expect(page.getByTestId('searchTeamsSelectorMenuButton')).toBeVisible(); + + // # Click on the team selector + await page.getByTestId('searchTeamsSelectorMenuButton').click(); + + // * Check that the team selector is visible + const teamSelector = page.getByRole('menu', {name: 'Select team'}); + await expect(teamSelector).toBeVisible(); + // * Check that the team selector has the 3 teams + teams.forEach(async (t) => { + await expect(teamSelector.getByText(t.display_name)).toBeVisible(); + }); + // * Check that All teams is also visible + await expect(teamSelector.getByText('All teams')).toBeVisible(); + // * No should be visible in the menu + await expect(teamSelector.getByLabel('Search teams')).not.toBeVisible(); + + // now create and join 3 more teams + for (let i = 0; i < 3; i++) { + const newTeam = await adminClient.createTeam(createRandomTeam('team', 'Team', 'O', true)); + await adminClient.addUsersToTeam(newTeam.id, [user.id]); + teams.push(newTeam); + } + + // refresh the page + await channelsPage.goto(team.name); + + // # Open the search UI + await channelsPage.globalHeader.openSearch(); + + // # Click on the team selector + await page.getByTestId('searchTeamsSelectorMenuButton').click(); + + // * Check that the team selector is visible + await expect(teamSelector).toBeVisible(); + // * Check that the team selector has the 6 teams + teams.forEach(async (t) => { + await expect(teamSelector.getByText(t.display_name)).toBeVisible(); + }); + // * Check that All teams is also visible + await expect(teamSelector.getByText('All teams')).toBeVisible(); + + // because there's more than 4 teams, the filter input should be visible + await expect(teamSelector.getByLabel('Search teams')).toBeVisible(); + + // # Type the name of the first team + await page.getByLabel('Search teams').fill(teams[3].display_name); + + // * Check that the team selector is visible + await expect(teamSelector).toBeVisible(); + + // * Noew team [0] and [3] should be visible - 0 is visible because it was currently selected. + await expect(teamSelector.getByText(teams[0].display_name)).toBeVisible(); + await expect(teamSelector.getByText(teams[3].display_name)).toBeVisible(); + // * Check that All teams is also visible + await expect(teamSelector.getByText('All teams')).toBeVisible(); + // * Check that the other teams are not visible + teams.slice(1, 3).forEach(async (t) => { + await expect(teamSelector.getByText(t.display_name)).not.toBeVisible(); + }); +}); diff --git a/webapp/channels/src/actions/channel_actions.ts b/webapp/channels/src/actions/channel_actions.ts index 3dbbf6b707..cfb4e41b1b 100644 --- a/webapp/channels/src/actions/channel_actions.ts +++ b/webapp/channels/src/actions/channel_actions.ts @@ -154,6 +154,22 @@ export function autocompleteChannelsForSearch(term: string, success?: (channels: }; } +export function autocompleteChannelsForSearchInTeam(term: string, teamId: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void): ActionFuncAsync { + return async (dispatch) => { + if (!teamId) { + return {data: false}; + } + + const {data, error: err} = await dispatch(ChannelActions.autocompleteChannelsForSearch(teamId, term)); + if (data && success) { + success(data); + } else if (err && error) { + error({id: err.server_error_id, ...err}); + } + return {data: true}; + }; +} + export function addUsersToChannel(channelId: Channel['id'], userIds: Array): ActionFuncAsync { return async (dispatch) => { const error = await dispatch(ChannelActions.addChannelMembers(channelId, userIds)); diff --git a/webapp/channels/src/actions/user_actions.ts b/webapp/channels/src/actions/user_actions.ts index 021048e427..073f4fa541 100644 --- a/webapp/channels/src/actions/user_actions.ts +++ b/webapp/channels/src/actions/user_actions.ts @@ -406,7 +406,7 @@ export async function loadProfilesForDM() { await dispatch(loadCustomEmojisForCustomStatusesByUserIds(profileIds)); } -export function autocompleteUsersInTeam(username: string): ThunkActionFunc> { +export function autocompleteUsersInCurrentTeam(username: string): ThunkActionFunc> { return async (doDispatch, doGetState) => { const currentTeamId = getCurrentTeamId(doGetState()); const {data} = await doDispatch(UserActions.autocompleteUsers(username, currentTeamId)); @@ -414,6 +414,13 @@ export function autocompleteUsersInTeam(username: string): ThunkActionFunc> { + return async (doDispatch) => { + const {data} = await doDispatch(UserActions.autocompleteUsers(username, teamId)); + return data!; + }; +} + export function autocompleteUsers(username: string): ThunkActionFunc> { return async (doDispatch) => { const {data} = await doDispatch(UserActions.autocompleteUsers(username)); diff --git a/webapp/channels/src/components/dot_menu/__snapshots__/dot_menu.test.tsx.snap b/webapp/channels/src/components/dot_menu/__snapshots__/dot_menu.test.tsx.snap index c87388ca68..9d6839e0d7 100644 --- a/webapp/channels/src/components/dot_menu/__snapshots__/dot_menu.test.tsx.snap +++ b/webapp/channels/src/components/dot_menu/__snapshots__/dot_menu.test.tsx.snap @@ -345,7 +345,7 @@ exports[`components/dot_menu/DotMenu should match snapshot, on Center 1`] = ` size={16} />, "class": "post-menu__item", - "dateTestId": "PostDotMenu-Button-post_id_1", + "dataTestId": "PostDotMenu-Button-post_id_1", "id": "CENTER_button_post_id_1", } } diff --git a/webapp/channels/src/components/dot_menu/dot_menu.tsx b/webapp/channels/src/components/dot_menu/dot_menu.tsx index 791f2aecb7..e9748653c5 100644 --- a/webapp/channels/src/components/dot_menu/dot_menu.tsx +++ b/webapp/channels/src/components/dot_menu/dot_menu.tsx @@ -492,7 +492,7 @@ export class DotMenuClass extends React.PureComponent { void; children: Props['children']; onKeyDown?: MenuProps['onKeyDown']; @@ -331,6 +335,7 @@ function MenuModal(props: MenuModalProps) { component='div' aria-labelledby={props.menuButtonId} onClick={handleModalClickCapture} + className={props.className} > {props.children} diff --git a/webapp/channels/src/components/menu/menu_item.tsx b/webapp/channels/src/components/menu/menu_item.tsx index 120d36c0bc..f0a9e909d4 100644 --- a/webapp/channels/src/components/menu/menu_item.tsx +++ b/webapp/channels/src/components/menu/menu_item.tsx @@ -83,6 +83,8 @@ export interface Props extends MuiMenuItemProps { role?: AriaRole; + forceCloseOnSelect?: boolean; + /** * ONLY to support submenus. Avoid passing children to this component. Support for children is only added to support submenus. */ @@ -136,6 +138,7 @@ export function MenuItem(props: Props) { children, onClick, role = 'menuitem', + forceCloseOnSelect = false, ...otherProps } = props; @@ -147,8 +150,9 @@ export function MenuItem(props: Props) { function handleClick(event: MouseEvent | KeyboardEvent) { if (isCorrectKeyPressedOnMenuItem(event)) { // If the menu item is a checkbox or radio button, we don't want to close the menu when it is clicked. + // unless forceCloseOnSelect is set to true. // see https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ - if (isRoleCheckboxOrRadio(role)) { + if (isRoleCheckboxOrRadio(role) && !forceCloseOnSelect) { event.stopPropagation(); } else { // close submenu first if it is open diff --git a/webapp/channels/src/components/menu/menu_item_input.tsx b/webapp/channels/src/components/menu/menu_item_input.tsx new file mode 100644 index 0000000000..998b312d33 --- /dev/null +++ b/webapp/channels/src/components/menu/menu_item_input.tsx @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import styled from 'styled-components'; + +import type {InputProps} from 'components/widgets/inputs/input/input'; +import Input from 'components/widgets/inputs/input/input'; + +export interface Props extends InputProps { + type: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url'; +} + +export function MenuItemInput(props: Props) { + const { + type, + onChange, + ...otherProps + } = props; + + const changeHandler = (event: React.ChangeEvent) => { + event.stopPropagation(); + if (onChange) { + onChange(event); + } + }; + + const stopParentFromCapturingKey = (event: React.KeyboardEvent) => { + event.stopPropagation(); + }; + + return ( + + + + ); +} + +const Container = styled.div` + padding: 10px; +`; diff --git a/webapp/channels/src/components/menu/menu_title.tsx b/webapp/channels/src/components/menu/menu_title.tsx new file mode 100644 index 0000000000..a977669771 --- /dev/null +++ b/webapp/channels/src/components/menu/menu_title.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type { + ReactNode, +} from 'react'; +import React from 'react'; +import styled from 'styled-components'; + +export interface Props { + children: ReactNode; + role?: string; +} + +export function MenuTitle(props: Props) { + const { + children, + role, + } = props; + + return ( + + {children} + + ); +} + +const Title = styled.h4` + font-size: 12px; + font-weight: 600; + line-height: 16px; + color: rgba(var(--center-channel-color-rgb), 0.56); + text-transform: uppercase; + padding: 6px 20px; + margin: 0; +`; diff --git a/webapp/channels/src/components/new_search/hooks.tsx b/webapp/channels/src/components/new_search/hooks.tsx index e2a1c1daf5..afad73c2a0 100644 --- a/webapp/channels/src/components/new_search/hooks.tsx +++ b/webapp/channels/src/components/new_search/hooks.tsx @@ -8,7 +8,7 @@ import {useDispatch} from 'react-redux'; import type {Channel} from '@mattermost/types/channels'; import type {ServerError} from '@mattermost/types/errors'; -import {autocompleteChannelsForSearch} from 'actions/channel_actions'; +import {autocompleteChannelsForSearchInTeam} from 'actions/channel_actions'; import {autocompleteUsersInTeam} from 'actions/user_actions'; import type {ProviderResult} from 'components/suggestion/provider'; @@ -19,7 +19,7 @@ import SearchUserProvider from 'components/suggestion/search_user_provider'; import {SearchFileExtensionProvider} from './extension_suggestions_provider'; -const useSearchSuggestions = (searchType: string, searchTerms: string, caretPosition: number, getCaretPosition: () => number, setSelectedOption: (idx: number) => void): [ProviderResult|null, React.ReactNode] => { +const useSearchSuggestions = (searchType: string, searchTerms: string, searchTeam: string, caretPosition: number, getCaretPosition: () => number, setSelectedOption: (idx: number) => void): [ProviderResult|null, React.ReactNode] => { const dispatch = useDispatch(); const [providerResults, setProviderResults] = useState|null>(null); @@ -27,8 +27,8 @@ const useSearchSuggestions = (searchType: string, searchTerms: string, caretPosi const suggestionProviders = useRef([ new SearchDateProvider(), - new SearchChannelProvider((term: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => dispatch(autocompleteChannelsForSearch(term, success, error))), - new SearchUserProvider((username: string) => dispatch(autocompleteUsersInTeam(username))), + new SearchChannelProvider((term: string, teamId: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => dispatch(autocompleteChannelsForSearchInTeam(term, teamId, success, error))), + new SearchUserProvider((username: string, teamId: string) => dispatch(autocompleteUsersInTeam(username, teamId))), new SearchFileExtensionProvider(), ]); @@ -79,9 +79,9 @@ const useSearchSuggestions = (searchType: string, searchTerms: string, caretPosi setProviderResults(res); setSelectedOption(0); setSuggestionsHeader(headers[idx]); - }); + }, searchTeam); }); - }, [searchTerms, searchType, caretPosition]); + }, [searchTerms, searchTeam, searchType, caretPosition]); return [providerResults, suggestionsHeader]; }; diff --git a/webapp/channels/src/components/new_search/new_search.test.tsx b/webapp/channels/src/components/new_search/new_search.test.tsx index e41745cc1b..ca09ea98ed 100644 --- a/webapp/channels/src/components/new_search/new_search.test.tsx +++ b/webapp/channels/src/components/new_search/new_search.test.tsx @@ -75,7 +75,8 @@ describe('components/new_search/NewSearch', () => { expect(screen.queryByText('Messages')).not.toBeInTheDocument(); expect(mockDispatch).toHaveBeenCalledWith({searchType: 'messages', type: 'UPDATE_RHS_SEARCH_TYPE'}); expect(mockDispatch).toHaveBeenCalledWith({terms: '', type: 'UPDATE_RHS_SEARCH_TERMS'}); - expect(mockDispatch).toHaveBeenCalledTimes(3); + expect(mockDispatch).toHaveBeenCalledWith({teamId: '', type: 'UPDATE_RHS_SEARCH_TEAM'}); + expect(mockDispatch).toHaveBeenCalledTimes(4); }); test('should open the search ctrl+shift+f is press on web app', () => { diff --git a/webapp/channels/src/components/new_search/new_search.tsx b/webapp/channels/src/components/new_search/new_search.tsx index 9810e4be8e..4d6edd71a3 100644 --- a/webapp/channels/src/components/new_search/new_search.tsx +++ b/webapp/channels/src/components/new_search/new_search.tsx @@ -7,10 +7,11 @@ import {useSelector, useDispatch} from 'react-redux'; import styled from 'styled-components'; import {getCurrentChannelNameForSearchShortcut} from 'mattermost-redux/selectors/entities/channels'; +import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; -import {updateSearchTerms, showSearchResults, updateSearchType} from 'actions/views/rhs'; +import {updateSearchTerms, showSearchResults, updateSearchType, updateSearchTeam} from 'actions/views/rhs'; import {getSearchButtons} from 'selectors/plugins'; -import {getSearchTerms, getSearchType} from 'selectors/rhs'; +import {getSearchTeam, getSearchTerms, getSearchType} from 'selectors/rhs'; import Popover from 'components/widgets/popover'; @@ -21,6 +22,8 @@ import * as Keyboard from 'utils/keyboard'; import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version'; import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent'; +import type {GlobalState} from 'types/store'; + import SearchBox from './search_box'; const PopoverStyled = styled(Popover)` @@ -106,7 +109,9 @@ const NewSearch = (): JSX.Element => { const currentChannelName = useSelector(getCurrentChannelNameForSearchShortcut); const searchTerms = useSelector(getSearchTerms) || ''; const searchType = useSelector(getSearchType) || ''; + const searchTeam = useSelector(getSearchTeam); const pluginSearch = useSelector(getSearchButtons); + const crossTeamSearchEnabled = useSelector((state: GlobalState) => getFeatureFlagValue(state, 'ExperimentalCrossTeamSearch')) === 'true'; const dispatch = useDispatch(); const [focused, setFocused] = useState(false); @@ -156,6 +161,11 @@ const NewSearch = (): JSX.Element => { const handleClick = (e: MouseEvent) => { if (searchBoxRef.current) { if (e.target !== searchBoxRef.current && !searchBoxRef.current.contains(e.target as Node)) { + // allow click on team selector menu + if (isTargetTeamSelectorMenu(e)) { + return; + } + setFocused(false); setCurrentChannel(''); } @@ -210,9 +220,10 @@ const NewSearch = (): JSX.Element => { ); const runSearch = useCallback( - (searchType: string, searchTerms: string) => { + (searchType: string, searchTeam: string, searchTerms: string) => { dispatch(updateSearchType(searchType)); dispatch(updateSearchTerms(searchTerms)); + dispatch(updateSearchTeam(searchTeam)); if (searchType === '' || searchType === 'messages' || searchType === 'files') { dispatch(showSearchResults(false)); @@ -300,6 +311,8 @@ const NewSearch = (): JSX.Element => { onSearch={runSearch} initialSearchTerms={currentChannel ? `in:${currentChannel} ` : searchTerms} initialSearchType={searchType} + initialSearchTeam={searchTeam} + crossTeamSearchEnabled={crossTeamSearchEnabled} /> )} @@ -307,4 +320,14 @@ const NewSearch = (): JSX.Element => { ); }; +// The team selector dropdown is in fact a small modal rendered outside the search box +// this allows to keep the searchbox open when the user interacts with the team selector +function isTargetTeamSelectorMenu(event: MouseEvent) { + if (!document.getElementsByClassName('MuiModal-root')[0]) { + return false; + } + + return document.getElementsByClassName('MuiModal-root')[0].contains(event.target as Node); +} + export default NewSearch; diff --git a/webapp/channels/src/components/new_search/search_box.test.tsx b/webapp/channels/src/components/new_search/search_box.test.tsx index 6c4c2d5e91..354db5fcac 100644 --- a/webapp/channels/src/components/new_search/search_box.test.tsx +++ b/webapp/channels/src/components/new_search/search_box.test.tsx @@ -17,6 +17,8 @@ describe('components/new_search/SearchBox', () => { onSearch: jest.fn(), initialSearchTerms: '', initialSearchType: 'messages', + initialSearchTeam: 'teamId', + crossTeamSearchEnabled: true, }; test('should have the focus on the input field', () => { diff --git a/webapp/channels/src/components/new_search/search_box.tsx b/webapp/channels/src/components/new_search/search_box.tsx index 617faaa43f..a24847e1e6 100644 --- a/webapp/channels/src/components/new_search/search_box.tsx +++ b/webapp/channels/src/components/new_search/search_box.tsx @@ -14,14 +14,17 @@ import SearchBoxHints from './search_box_hints'; import SearchInput from './search_box_input'; import SearchSuggestions from './search_box_suggestions'; import SearchTypeSelector from './search_box_type_selector'; +import SelectTeam from './select_team'; const {KeyCodes} = Constants; type Props = { onClose: () => void; - onSearch: (searchType: string, searchTerms: string) => void; + onSearch: (searchType: string, searchTeam: string, searchTerms: string) => void; initialSearchTerms: string; initialSearchType: string; + initialSearchTeam: string; + crossTeamSearchEnabled: boolean; }; const SearchBoxContainer = styled.div` @@ -58,19 +61,33 @@ const CloseIcon = styled.button` z-index: 1; `; +const SearchBoxHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const SearchTeamSelector = styled.div` + margin: 20px 65px 0 0; +`; + const SearchBox = forwardRef( ( - {onClose, onSearch, initialSearchTerms, initialSearchType}: Props, + {onClose, onSearch, initialSearchTerms, initialSearchType, initialSearchTeam, crossTeamSearchEnabled}: Props, ref: React.Ref, ): JSX.Element => { const intl = useIntl(); const [caretPosition, setCaretPosition] = useState(0); const [searchTerms, setSearchTerms] = useState(initialSearchTerms); + const [searchTeam, setSearchTeam] = useState(initialSearchTeam); const [searchType, setSearchType] = useState(initialSearchType || 'messages'); const [selectedOption, setSelectedOption] = useState(-1); const inputRef = useRef(null); + const [showFilterHaveBeenReset, setShowFilterHaveBeenReset] = useState(false); + const filterResetTimeout = useRef(); + const getCaretPosition = useCallback(() => { return inputRef.current?.selectionEnd || 0; }, []); @@ -124,6 +141,7 @@ const SearchBox = forwardRef( const [providerResults, suggestionsHeader] = useSearchSuggestions( searchType, searchTerms, + searchTeam, caretPosition, getCaretPosition, setSelectedOption, @@ -195,7 +213,7 @@ const SearchBox = forwardRef( e.stopPropagation(); e.preventDefault(); if (!providerResults || providerResults?.items.length === 0 || selectedOption === -1) { - onSearch(searchType, searchTerms); + onSearch(searchType, searchTeam, searchTerms); } else { const matchedPretext = providerResults?.matchedPretext; const value = providerResults?.terms[selectedOption]; @@ -204,9 +222,28 @@ const SearchBox = forwardRef( } } }, - [providerResults, onClose, selectedOption, onSearch, searchType, searchTerms, updateSearchValue], + [providerResults, onClose, selectedOption, onSearch, searchType, searchTeam, searchTerms, updateSearchValue], ); + const changeSearchTeam = (selectedTeam: string) => { + const newTerms = searchTerms. + replace(/\bin:[^\s]*/gi, '').replace(/\s{2,}/g, ' '). + replace(/\bfrom:[^\s]*/gi, '').replace(/\s{2,}/g, ' '); + + if (newTerms !== searchTerms) { + clearTimeout(filterResetTimeout.current); + + setShowFilterHaveBeenReset(true); + filterResetTimeout.current = setTimeout(() => { + setShowFilterHaveBeenReset(false); + }, 2500); + } + + setSearchTerms(newTerms); + setSearchTeam(selectedTeam); + inputRef.current?.focus(); + }; + const closeHandler = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); @@ -239,10 +276,20 @@ const SearchBox = forwardRef( > - + + + {crossTeamSearchEnabled && ( + + + + )} + diff --git a/webapp/channels/src/components/new_search/search_box_hints.test.tsx b/webapp/channels/src/components/new_search/search_box_hints.test.tsx index 6d1f23d1ce..8a979e487b 100644 --- a/webapp/channels/src/components/new_search/search_box_hints.test.tsx +++ b/webapp/channels/src/components/new_search/search_box_hints.test.tsx @@ -39,6 +39,8 @@ describe('components/new_search/SearchBoxHints', () => { const baseProps = { searchType: 'messages', searchTerms: '', + searchTeam: 'teamId', + showFilterHaveBeenReset: false, setSearchTerms: jest.fn(), focus: jest.fn(), selectedOption: -1, diff --git a/webapp/channels/src/components/new_search/search_box_hints.tsx b/webapp/channels/src/components/new_search/search_box_hints.tsx index 60bfce3d30..c68c5edbc0 100644 --- a/webapp/channels/src/components/new_search/search_box_hints.tsx +++ b/webapp/channels/src/components/new_search/search_box_hints.tsx @@ -15,14 +15,16 @@ import SearchHints from './search_hint'; type Props = { searchTerms: string; + searchTeam: string; setSearchTerms: (searchTerms: string) => void; searchType: string; selectedOption: number; providerResults: ProviderResult|null; focus: (pos: number) => void; + showFilterHaveBeenReset: boolean; } -const SearchBoxHints = ({searchTerms, setSearchTerms, searchType, providerResults, selectedOption, focus}: Props) => { +const SearchBoxHints = ({searchTerms, searchTeam, setSearchTerms, searchType, providerResults, selectedOption, focus, showFilterHaveBeenReset}: Props) => { const filterSelectedCallback = useCallback((filter: string) => { if (searchTerms.endsWith(' ') || searchTerms.length === 0) { setSearchTerms(searchTerms + filter); @@ -47,7 +49,9 @@ const SearchBoxHints = ({searchTerms, setSearchTerms, searchType, providerResult onSelectFilter={filterSelectedCallback} searchType={searchType} searchTerms={searchTerms} + searchTeam={searchTeam} hasSelectedOption={Boolean(providerResults && providerResults.items.length > 0 && selectedOption !== -1)} + showFilterHaveBeenReset={showFilterHaveBeenReset} isDate={providerResults?.component === SearchDateSuggestion} /> ); diff --git a/webapp/channels/src/components/new_search/search_box_suggestions.test.tsx b/webapp/channels/src/components/new_search/search_box_suggestions.test.tsx index 28e2cc2bf5..2b243fe54e 100644 --- a/webapp/channels/src/components/new_search/search_box_suggestions.test.tsx +++ b/webapp/channels/src/components/new_search/search_box_suggestions.test.tsx @@ -40,6 +40,7 @@ describe('components/new_search/SearchBoxSuggestions', () => { const baseProps = { searchType: 'messages', searchTerms: '', + searchTeam: 'teamId', selectedOption: -1, setSelectedOption: jest.fn(), suggestionsHeader:

{'Test Header'}

, @@ -133,6 +134,6 @@ describe('components/new_search/SearchBoxSuggestions', () => { }, ); screen.getByText('onRunSearch').click(); - expect(baseProps.onSearch).toHaveBeenCalledWith('test-id', 'something from:t'); + expect(baseProps.onSearch).toHaveBeenCalledWith('test-id', 'teamId', 'something from:t'); }); }); diff --git a/webapp/channels/src/components/new_search/search_box_suggestions.tsx b/webapp/channels/src/components/new_search/search_box_suggestions.tsx index a5a1fe01de..2008d4b09e 100644 --- a/webapp/channels/src/components/new_search/search_box_suggestions.tsx +++ b/webapp/channels/src/components/new_search/search_box_suggestions.tsx @@ -31,19 +31,20 @@ const SuggestionsBody = styled.div` type Props = { searchType: string; + searchTeam: string; searchTerms: string; selectedOption: number; setSelectedOption: (idx: number) => void; suggestionsHeader: React.ReactNode; providerResults: ProviderResult | null; - onSearch: (searchType: string, searchTerms: string) => void; + onSearch: (searchType: string, searchTeam: string, searchTerms: string) => void; onSuggestionSelected: (value: string, matchedPretext: string) => void; } -const SearchSuggestions = ({searchType, searchTerms, suggestionsHeader, providerResults, selectedOption, setSelectedOption, onSearch, onSuggestionSelected}: Props) => { +const SearchSuggestions = ({searchType, searchTeam, searchTerms, suggestionsHeader, providerResults, selectedOption, setSelectedOption, onSearch, onSuggestionSelected}: Props) => { const runSearch = useCallback((searchTerms: string) => { - onSearch(searchType, searchTerms); - }, [onSearch, searchType]); + onSearch(searchType, searchTeam, searchTerms); + }, [onSearch, searchTeam, searchType]); const searchPluginSuggestions = useSelector(getSearchPluginSuggestions); diff --git a/webapp/channels/src/components/new_search/search_hint.test.tsx b/webapp/channels/src/components/new_search/search_hint.test.tsx index 9ce91ee35a..ea6894d6ff 100644 --- a/webapp/channels/src/components/new_search/search_hint.test.tsx +++ b/webapp/channels/src/components/new_search/search_hint.test.tsx @@ -15,8 +15,10 @@ describe('components/new_search/SearchHint', () => { onSelectFilter: jest.fn(), searchType: 'messages', searchTerms: '', + searchTeam: 'teamId', hasSelectedOption: false, isDate: false, + showFilterHaveBeenReset: false, }; test('should have the right hint options on search messages empty string', () => { @@ -58,6 +60,13 @@ describe('components/new_search/SearchHint', () => { expect(screen.getByText('Ext:')).toBeInTheDocument(); }); + test('should not have From: and In: where the searchTeam is set to all teams (\'\')', () => { + const props = {...baseProps, searchTeam: '', searchTerms: 'test '}; + renderWithContext(); + expect(screen.queryByText('From:')).not.toBeInTheDocument(); + expect(screen.queryByText('In:')).not.toBeInTheDocument(); + }); + test('should be empty on search if is date', () => { const props = {...baseProps, isDate: true}; const {asFragment} = renderWithContext(); @@ -75,4 +84,10 @@ describe('components/new_search/SearchHint', () => { screen.getByText('From:').click(); expect(baseProps.onSelectFilter).toHaveBeenCalledWith('From:'); }); + + test('Shows the filter reset message when instructed', () => { + const props = {...baseProps, showFilterHaveBeenReset: true}; + renderWithContext(); + expect(screen.getByText('Your filters were reset because you chose a different team')).toBeInTheDocument(); + }); }); diff --git a/webapp/channels/src/components/new_search/search_hint.tsx b/webapp/channels/src/components/new_search/search_hint.tsx index ff3c2efc7a..5823d34516 100644 --- a/webapp/channels/src/components/new_search/search_hint.tsx +++ b/webapp/channels/src/components/new_search/search_hint.tsx @@ -11,6 +11,8 @@ type Props = { onSelectFilter: (filter: string) => void; searchType: string; searchTerms: string; + searchTeam: string; + showFilterHaveBeenReset: boolean; hasSelectedOption: boolean; isDate: boolean; } @@ -41,17 +43,34 @@ const SearchFilter = styled.button` } `; -const SearchHints = ({onSelectFilter, searchType, searchTerms, hasSelectedOption, isDate}: Props): JSX.Element => { +const SearchHints = ({onSelectFilter, searchType, searchTerms, searchTeam, hasSelectedOption, isDate, showFilterHaveBeenReset}: Props): JSX.Element => { const intl = useIntl(); let filters = searchHintOptions.filter((filter) => filter.searchTerm !== '-' && filter.searchTerm !== '""'); if (searchType === 'files') { filters = searchFilesHintOptions.filter((filter) => filter.searchTerm !== '-' && filter.searchTerm !== '""'); } + // if search team is '' (all teams), remove "from" and "in" filters + if (!searchTeam) { + filters = filters.filter((filter) => filter.searchTerm !== 'From:' && filter.searchTerm !== 'In:'); + } + if (isDate) { return <>; } + if (showFilterHaveBeenReset) { + return ( + + + + + ); + } + if (hasSelectedOption) { return ( diff --git a/webapp/channels/src/components/new_search/select_team.scss b/webapp/channels/src/components/new_search/select_team.scss new file mode 100644 index 0000000000..ce7efc4d93 --- /dev/null +++ b/webapp/channels/src/components/new_search/select_team.scss @@ -0,0 +1,58 @@ +.search-teams-selector-menu-button { + display: flex; + max-width: 250px; + align-items: center; + padding: 0 6px 0 8px; + border: 0; + border-radius: var(--radius-s, 4px); + background-color: transparent; + font-size: 12px; + font-weight: 600; + line-height: 24px; + + &:hover { + background-color: var(--center-channel-color-8, rgba(63, 67, 80, 0.08)); + } + + + & > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + + &.search-teams-selector-menu-button__different-team, &[aria-expanded="true"] { + background: var(--button-bg-8, rgba(28, 88, 217, 0.08)); + color: var(--button-bg, #1c58d9); + + &:hover { + background-color: var(--center-channel-color-8, rgba(63, 67, 80, 0.08)); + } + } +} + +.search-teams-selector-current-team.MuiMenuItem-root { + margin-bottom: 8px; +} + +.select-team-mui-menu { + min-width: 220px; +} + +.select-team-mui-menu>.MuiMenuItem-root { + justify-content: space-between!important; +} + +.select-team-mui-menu>.MuiMenuItem-root>.label-elements { + overflow: hidden; + flex-basis: auto!important; + flex-grow: 0!important; + flex-shrink: 1!important; + text-overflow: ellipsis; + white-space: nowrap; +} + +.select-team-mui-menu>.MuiMenuItem-root>.trailing-elements { + margin-left: 16px; +} diff --git a/webapp/channels/src/components/new_search/select_team.tsx b/webapp/channels/src/components/new_search/select_team.tsx new file mode 100644 index 0000000000..11fcf5027d --- /dev/null +++ b/webapp/channels/src/components/new_search/select_team.tsx @@ -0,0 +1,156 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {CheckIcon, ChevronDownIcon, MagnifyIcon as SearchIcon} from '@mattermost/compass-icons/components'; + +import {get} from 'mattermost-redux/selectors/entities/preferences'; +import {getCurrentTeamId, getMyTeams} from 'mattermost-redux/selectors/entities/teams'; + +import {getCurrentLocale} from 'selectors/i18n'; + +import * as Menu from 'components/menu'; + +import {Preferences} from 'utils/constants'; +import {filterAndSortTeamsByDisplayName} from 'utils/team_utils'; + +import type {GlobalState} from 'types/store'; +import './select_team.scss'; + +interface Props { + selectedTeamId: string; + onTeamSelected: (team: string) => void; +} + +const SelectTeam = (props: Props) => { + const intl = useIntl(); + const myTeams = useSelector(getMyTeams); + const locale = useSelector(getCurrentLocale); + const userTeamsOrderPreference = useSelector((state: GlobalState) => get(state, Preferences.TEAMS_ORDER, '', '')); + const currentTeamId = useSelector(getCurrentTeamId); + const [filter, setFilter] = React.useState(''); + + const currentlySelectedTeam = myTeams.find((t) => t.id === props.selectedTeamId); + const isDifferentTeamSelected = props.selectedTeamId !== currentTeamId; + + const allTeamsText = intl.formatMessage({ + id: 'search_teams_selector.all_teams', + defaultMessage: 'All Teams', + }); + + let menuButtonText = allTeamsText; + if (props.selectedTeamId) { + const team = myTeams.find((t) => t.id === props.selectedTeamId); + if (team) { + menuButtonText = team.display_name; + } + } + + const button = ( + <>{menuButtonText} + ); + + const onFilterChange = (e: React.ChangeEvent) => { + setFilter(e.target.value); + }; + + const handleTeamChange = (teamId: string) => { + props.onTeamSelected(teamId); + setFilter(''); + }; + + // we only show the filter input and separator if there's more than 4 teams + const showFilter = myTeams.length > 4; + + const teams = React.useMemo(() => { + // if we show the filter, exclude the current team from the list + const myTeamsList = showFilter ? myTeams.filter((team) => team.id !== props.selectedTeamId) : myTeams; + + let filteredTeams = filterAndSortTeamsByDisplayName(myTeamsList, locale, userTeamsOrderPreference); + if (filter) { + filteredTeams = filteredTeams.filter((team) => team.display_name.toLowerCase().includes(filter.toLowerCase())); + } + return filteredTeams; + }, [myTeams, locale, userTeamsOrderPreference, filter, showFilter, props.selectedTeamId]); + + const renderTeam = (teamId: string, teamName: string, elementId: string, className: string = '') => { + return ( + handleTeamChange(teamId)} + labels={{teamName}} + trailingElements={(teamId === props.selectedTeamId && ( + + ))} + className={className} + /> + ); + }; + + // MUI Menu doesn't support fragments, and the recommended alternative is to use an array. + const renderFilterArea = () => { + const elements = [ + } + value={filter} + onChange={onFilterChange} + />, + ]; + if (currentlySelectedTeam) { + elements.push( + renderTeam(currentlySelectedTeam.id, currentlySelectedTeam.display_name, currentlySelectedTeam.id, 'search-teams-selector-current-team'), + + + , + ); + } + return elements; + }; + + return ( + + {renderTeam('', allTeamsText, 'all_teams')} + + {showFilter && renderFilterArea()} + {teams.map((team) => renderTeam(team.id, team.display_name, team.id))} + + ); +}; + +export default SelectTeam; diff --git a/webapp/channels/src/components/search/index.tsx b/webapp/channels/src/components/search/index.tsx index 4d4e579aef..1fa6daa6a0 100644 --- a/webapp/channels/src/components/search/index.tsx +++ b/webapp/channels/src/components/search/index.tsx @@ -5,12 +5,15 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; 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 {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; import {autocompleteChannelsForSearch} from 'actions/channel_actions'; -import {autocompleteUsersInTeam} from 'actions/user_actions'; +import {autocompleteUsersInCurrentTeam} from 'actions/user_actions'; import { updateSearchTerms, updateSearchTeam, @@ -41,18 +44,13 @@ function mapStateToProps(state: GlobalState) { const isMobileView = getIsMobileView(state); const isRhsOpen = getIsRhsOpen(state); - let searchTeam = getSearchTeam(state); - if (!searchTeam) { - searchTeam = currentChannel?.team_id || ''; - } - return { currentChannel, isRhsExpanded: getIsRhsExpanded(state), isRhsOpen, isSearchingTerm: getIsSearchingTerm(state), searchTerms: getSearchTerms(state), - searchTeam, + searchTeam: getSearchTeam(state), searchType: getSearchType(state), searchVisible: rhsState !== null && (![ RHSStates.PLUGIN, @@ -71,6 +69,10 @@ function mapStateToProps(state: GlobalState) { } function mapDispatchToProps(dispatch: Dispatch) { + const autocompleteChannels = (term: string, teamId: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void): void => { + autocompleteChannelsForSearch(term, success, error); + }; + return { actions: bindActionCreators({ updateSearchTerms, @@ -83,8 +85,8 @@ function mapDispatchToProps(dispatch: Dispatch) { showFlaggedPosts, setRhsExpanded, closeRightHandSide, - autocompleteChannelsForSearch, - autocompleteUsersInTeam, + autocompleteChannelsForSearch: autocompleteChannels, + autocompleteUsersInTeam: autocompleteUsersInCurrentTeam, updateRhsState, getMorePostsForSearch, openRHSSearch, diff --git a/webapp/channels/src/components/search/search.tsx b/webapp/channels/src/components/search/search.tsx index 57215f198c..df9bc18bde 100644 --- a/webapp/channels/src/components/search/search.tsx +++ b/webapp/channels/src/components/search/search.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; -import React, {useEffect, useState, useRef} from 'react'; +import React, {useEffect, useState, useRef, useCallback} from 'react'; import type {ChangeEvent, MouseEvent, FormEvent} from 'react'; import {useIntl} from 'react-intl'; import {useSelector} from 'react-redux'; @@ -96,6 +96,7 @@ const Search: React.FC = (props: Props): JSX.Element => { isMobileView, searchTerms, searchType, + searchTeam, hideMobileSearchBarInRHS, } = props; @@ -166,6 +167,14 @@ const Search: React.FC = (props: Props): JSX.Element => { } }, [isMobileView, searchTerms]); + const getMorePostsForSearch = useCallback(() => { + props.actions.getMorePostsForSearch(searchTeam); + }, [searchTeam, props.actions]); + + const getMoreFilesForSearch = useCallback(() => { + props.actions.getMoreFilesForSearch(searchTeam); + }, [searchTeam, props.actions]); + // handle cloding of rhs-flyout const handleClose = (): void => actions.closeRightHandSide(); @@ -207,8 +216,16 @@ const Search: React.FC = (props: Props): JSX.Element => { handleUpdateSearchTerms(pretextArray.join(' ')); }; - const handleUpdateSearchTeam = async (teamId: string) => { + const handleUpdateSearchTeamFromResult = async (teamId: string) => { actions.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()) { + actions.updateSearchTerms(newTerms); + } + handleSearch().then(() => { setKeepInputFocused(false); setFocused(false); @@ -553,11 +570,11 @@ const Search: React.FC = (props: Props): JSX.Element => { channelDisplayName={props.channelDisplayName} isOpened={props.isSideBarRightOpen} updateSearchTerms={handleAddSearchTerm} - updateSearchTeam={handleUpdateSearchTeam} + updateSearchTeam={handleUpdateSearchTeamFromResult} handleSearchHintSelection={handleSearchHintSelection} isSideBarExpanded={props.isRhsExpanded} - getMorePostsForSearch={props.actions.getMorePostsForSearch} - getMoreFilesForSearch={props.actions.getMoreFilesForSearch} + getMorePostsForSearch={getMorePostsForSearch} + getMoreFilesForSearch={getMoreFilesForSearch} setSearchFilterType={handleSetSearchFilter} searchFilterType={searchFilterType} setSearchType={(value: SearchType) => actions.updateSearchType(value)} diff --git a/webapp/channels/src/components/search/types.ts b/webapp/channels/src/components/search/types.ts index c357444309..f0af6c7ccd 100644 --- a/webapp/channels/src/components/search/types.ts +++ b/webapp/channels/src/components/search/types.ts @@ -52,12 +52,12 @@ export type DispatchProps = { showFlaggedPosts: () => void; setRhsExpanded: (expanded: boolean) => Action; closeRightHandSide: () => void; - autocompleteChannelsForSearch: (term: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => void; + autocompleteChannelsForSearch: (term: string, teamId: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => void; autocompleteUsersInTeam: (username: string) => Promise; updateRhsState: (rhsState: string) => void; - getMorePostsForSearch: () => void; + getMorePostsForSearch: (teamId: string) => void; openRHSSearch: () => void; - getMoreFilesForSearch: () => void; + getMoreFilesForSearch: (teamId: string) => void; filterFilesSearchByExt: (extensions: string[]) => void; }; } diff --git a/webapp/channels/src/components/search_results/index.tsx b/webapp/channels/src/components/search_results/index.tsx index 02c85d6a5a..78961db55d 100644 --- a/webapp/channels/src/components/search_results/index.tsx +++ b/webapp/channels/src/components/search_results/index.tsx @@ -10,7 +10,6 @@ import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import {getSearchFilesResults} from 'mattermost-redux/selectors/entities/files'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getSearchMatches, getSearchResults} from 'mattermost-redux/selectors/entities/posts'; -import {getCurrentSearchForCurrentTeam} from 'mattermost-redux/selectors/entities/search'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import { @@ -20,6 +19,7 @@ import { getIsSearchingFlaggedPost, getIsSearchingPinnedPost, getIsSearchGettingMore, + getCurrentSearchForSearchTeam, } from 'selectors/rhs'; import type {GlobalState} from 'types/store'; @@ -77,7 +77,7 @@ function makeMapStateToProps() { // this is basically a hack to make ts compiler happy // add correct type when it is known what exactly is returned from the function - const currentSearch = (getCurrentSearchForCurrentTeam(state) as unknown as Record) || {}; + const currentSearch = (getCurrentSearchForSearchTeam(state) as unknown as Record) || {}; const currentTeamName = getCurrentTeam(state)?.name ?? ''; return { diff --git a/webapp/channels/src/components/search_results/messages_or_files_selector.scss b/webapp/channels/src/components/search_results/messages_or_files_selector.scss index 7c4b3bb29e..688969d7bb 100644 --- a/webapp/channels/src/components/search_results/messages_or_files_selector.scss +++ b/webapp/channels/src/components/search_results/messages_or_files_selector.scss @@ -1,7 +1,7 @@ .MessagesOrFilesSelector { display: flex; align-items: center; - padding: 7px; + padding: 7px 16px 7px 14px; border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); .buttons-container { diff --git a/webapp/channels/src/components/search_results/messages_or_files_selector.tsx b/webapp/channels/src/components/search_results/messages_or_files_selector.tsx index b6e7f3b242..3c816aaeb7 100644 --- a/webapp/channels/src/components/search_results/messages_or_files_selector.tsx +++ b/webapp/channels/src/components/search_results/messages_or_files_selector.tsx @@ -5,10 +5,9 @@ import React, {useRef} from 'react'; import {FormattedMessage} from 'react-intl'; import {useSelector} from 'react-redux'; -import {getMyTeams} from 'mattermost-redux/selectors/entities/teams'; - import {getSearchTeam} from 'selectors/rhs'; +import SelectTeam from 'components/new_search/select_team'; import type {SearchFilterType} from 'components/search/types'; import type {A11yFocusEventDetail} from 'utils/constants'; @@ -39,22 +38,12 @@ type Props = { type DataSearchLiteral = typeof DataSearchTypes[keyof typeof DataSearchTypes]; export default function MessagesOrFilesSelector(props: Props): JSX.Element { - const teams = useSelector((state: GlobalState) => getMyTeams(state)); const searchTeam = useSelector((state: GlobalState) => getSearchTeam(state)); // REFS to the tabs so there is ability to pass the custom A11y focus event const messagesTabRef = useRef(null); const filesTabRef = useRef(null); - const options = [{value: '', label: 'All teams', selected: searchTeam === ''}]; - for (const team of teams) { - options.push({value: team.id, label: team.display_name, selected: searchTeam === team.id}); - } - - const onTeamChange = (e: React.ChangeEvent) => { - props.onTeamChange(e.target.value); - }; - // Enhanced arrow key handling to focus the new select tab and also send the a11y custom event const handleTabKeyDown = ( e: React.KeyboardEvent, @@ -143,19 +132,10 @@ export default function MessagesOrFilesSelector(props: Props): JSX.Element { {props.crossTeamSearchEnabled && (
- +
)} {props.selected === DataSearchTypes.FILES_SEARCH_TYPE && ( diff --git a/webapp/channels/src/components/suggestion/provider.tsx b/webapp/channels/src/components/suggestion/provider.tsx index e4ddd2e803..7d6579c704 100644 --- a/webapp/channels/src/components/suggestion/provider.tsx +++ b/webapp/channels/src/components/suggestion/provider.tsx @@ -36,7 +36,7 @@ export default abstract class Provider { this.forceDispatch = false; } - abstract handlePretextChanged(pretext: string, callback: (res: ProviderResult) => void): boolean; + abstract handlePretextChanged(pretext: string, callback: (res: ProviderResult) => void, teamId?: string): boolean; resetRequest() { this.requestStarted = false; diff --git a/webapp/channels/src/components/suggestion/search_channel_provider.tsx b/webapp/channels/src/components/suggestion/search_channel_provider.tsx index 65d0e1c3e7..f7b11d7890 100644 --- a/webapp/channels/src/components/suggestion/search_channel_provider.tsx +++ b/webapp/channels/src/components/suggestion/search_channel_provider.tsx @@ -20,7 +20,7 @@ import SearchChannelSuggestion from './search_channel_suggestion'; const getState = store.getState; const dispatch = store.dispatch; -type SearchChannelAutocomplete = (term: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => void; +type SearchChannelAutocomplete = (term: string, teamId: string, success?: (channels: Channel[]) => void, error?: (err: ServerError) => void) => void; export default class SearchChannelProvider extends Provider { autocompleteChannelsForSearch: SearchChannelAutocomplete; @@ -30,7 +30,7 @@ export default class SearchChannelProvider extends Provider { this.autocompleteChannelsForSearch = channelSearchFunc; } - handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { + handlePretextChanged(pretext: string, resultsCallback: ResultsCallback, teamId: string) { const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext.toLowerCase()); if (!captured) { return false; @@ -43,6 +43,7 @@ export default class SearchChannelProvider extends Provider { this.autocompleteChannelsForSearch( prefix, + teamId, async (data: Channel[]) => { if (this.shouldCancelDispatch(prefix)) { return; diff --git a/webapp/channels/src/components/suggestion/search_user_provider.tsx b/webapp/channels/src/components/suggestion/search_user_provider.tsx index ff734de4cc..043cbbe040 100644 --- a/webapp/channels/src/components/suggestion/search_user_provider.tsx +++ b/webapp/channels/src/components/suggestion/search_user_provider.tsx @@ -64,21 +64,21 @@ export const SearchUserSuggestion = React.forwardRef Promise; - constructor(userSearchFunc: (username: string) => Promise) { + private autocompleteUsersInTeam: (username: string, teamId: string) => Promise; + constructor(userSearchFunc: (username: string, teamId: string) => Promise) { super(); this.autocompleteUsersInTeam = userSearchFunc; } - handlePretextChanged(pretext: string, resultsCallback: ResultsCallback) { + handlePretextChanged(pretext: string, resultsCallback: ResultsCallback, teamId: string) { const captured = (/\bfrom:\s*(\S*)$/i).exec(pretext.toLowerCase()); - this.doAutocomplete(captured, resultsCallback); + this.doAutocomplete(captured, teamId, resultsCallback); return Boolean(captured); } - async doAutocomplete(captured: RegExpExecArray | null, resultsCallback: ResultsCallback) { + async doAutocomplete(captured: RegExpExecArray | null, teamId: string, resultsCallback: ResultsCallback) { if (!captured) { return; } @@ -87,7 +87,7 @@ export default class SearchUserProvider extends Provider { this.startNewRequest(usernamePrefix); - const data = await this.autocompleteUsersInTeam(usernamePrefix); + const data = await this.autocompleteUsersInTeam(usernamePrefix, teamId); if (this.shouldCancelDispatch(usernamePrefix)) { return; diff --git a/webapp/channels/src/components/widgets/inputs/input/input.tsx b/webapp/channels/src/components/widgets/inputs/input/input.tsx index f59e2db961..fc337018ca 100644 --- a/webapp/channels/src/components/widgets/inputs/input/input.tsx +++ b/webapp/channels/src/components/widgets/inputs/input/input.tsx @@ -22,7 +22,7 @@ export enum SIZE { export type CustomMessageInputType = {type?: 'info' | 'error' | 'warning' | 'success'; value: React.ReactNode} | null; -interface InputProps extends Omit, 'placeholder'> { +export interface InputProps extends Omit, 'placeholder'> { required?: boolean; hasError?: boolean; addon?: React.ReactElement; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 9baf47447a..0c6f9f9029 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4972,6 +4972,7 @@ "search_hint.enter_to_search": "Press Enter to search", "search_hint.enter_to_select": "Press Enter to select", "search_hint.filter": "Filter your search with:", + "search_hint.reset_filters": "Your filters were reset because you chose a different team", "search_item.channelArchived": "Archived", "search_item.direct": "Direct Message (with {username})", "search_item.file_tag.direct_message": "Direct Message", @@ -4987,6 +4988,9 @@ "search_list_option.on": "Messages on a date", "search_list_option.phrases": "Messages with phrases", "search_results.channel-files-header": "Recent files", + "search_teams_selector.all_teams": "All Teams", + "search_teams_selector.search_teams": "Search teams", + "search_teams_selector.your_teams": "Your teams", "sectionNotice.dismiss": "Dismiss notice", "select_team.icon": "Select Team Icon", "select_team.join.icon": "Join Team Icon", diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/search.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/search.ts index 9775d59c63..1cca445afe 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/search.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/search.ts @@ -9,7 +9,6 @@ import type {SearchParameter} from '@mattermost/types/search'; import {SearchTypes} from 'mattermost-redux/action_types'; import {Client4} from 'mattermost-redux/client'; -import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import type {ActionResult, ActionFuncAsync, ThunkActionFunc} from 'mattermost-redux/types/actions'; @@ -117,10 +116,9 @@ export function searchPosts(teamId: string, terms: string, isOrSearch: boolean, return searchPostsWithParams(teamId, {terms, is_or_search: isOrSearch, include_deleted_channels: includeDeletedChannels, page: 0, per_page: WEBAPP_SEARCH_PER_PAGE}); } -export function getMorePostsForSearch(): ActionFuncAsync { +export function getMorePostsForSearch(teamId: string): ActionFuncAsync { return async (dispatch, getState) => { - const teamId = getCurrentTeamId(getState()); - const {params, isEnd} = getState().entities.search.current[teamId]; + const {params, isEnd} = getState().entities.search.current[teamId || 'ALL_TEAMS']; if (!isEnd) { const newParams = Object.assign({}, params); newParams.page += 1; @@ -182,10 +180,9 @@ export function searchFilesWithParams(teamId: string, params: SearchParameter): }; } -export function getMoreFilesForSearch(): ActionFuncAsync { +export function getMoreFilesForSearch(teamId: string): ActionFuncAsync { return async (dispatch, getState) => { - const teamId = getCurrentTeamId(getState()); - const {params, isFilesEnd} = getState().entities.search.current[teamId]; + const {params, isFilesEnd} = getState().entities.search.current[teamId || 'ALL_TEAMS']; if (!isFilesEnd) { const newParams = Object.assign({}, params); newParams.page += 1; diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/search.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/search.ts index b717aad430..b5326a3322 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/search.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/search.ts @@ -207,7 +207,8 @@ function current(state: any = {}, action: MMReduxAction) { switch (action.type) { case SearchTypes.RECEIVED_SEARCH_TERM: { const nextState = {...state}; - const {teamId, params, isEnd, isFilesEnd} = action.data; + const {params, isEnd, isFilesEnd} = action.data; + const teamId = action.data.teamId || 'ALL_TEAMS'; return { ...nextState, [teamId]: { diff --git a/webapp/channels/src/sass/components/_search.scss b/webapp/channels/src/sass/components/_search.scss index 4823920a1c..eeae75609e 100644 --- a/webapp/channels/src/sass/components/_search.scss +++ b/webapp/channels/src/sass/components/_search.scss @@ -239,6 +239,8 @@ -webkit-overflow-scrolling: touch; &.no-results { + display: flex; + justify-content: center; padding-top: 0; } } diff --git a/webapp/channels/src/selectors/rhs.ts b/webapp/channels/src/selectors/rhs.ts index e82eea96f6..534553f78b 100644 --- a/webapp/channels/src/selectors/rhs.ts +++ b/webapp/channels/src/selectors/rhs.ts @@ -109,6 +109,15 @@ export const getSelectedPost = createSelector( }, ); +export const getCurrentSearchForSearchTeam: (state: GlobalState) => Record = createSelector( + 'getCurrentSearchForSearchTeam', + (state: GlobalState) => state.entities.search.current, + getSearchTeam, + (current, teamId) => { + return current[teamId || 'ALL_TEAMS']; + }, +); + export function getRhsState(state: GlobalState): RhsState { return state.views.rhs.rhsState; }