@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <input> 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();
|
||||
});
|
||||
});
|
||||
@@ -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<UserProfile['id']>): ActionFuncAsync {
|
||||
return async (dispatch) => {
|
||||
const error = await dispatch(ChannelActions.addChannelMembers(channelId, userIds));
|
||||
|
||||
@@ -406,7 +406,7 @@ export async function loadProfilesForDM() {
|
||||
await dispatch(loadCustomEmojisForCustomStatusesByUserIds(profileIds));
|
||||
}
|
||||
|
||||
export function autocompleteUsersInTeam(username: string): ThunkActionFunc<Promise<UserAutocomplete>> {
|
||||
export function autocompleteUsersInCurrentTeam(username: string): ThunkActionFunc<Promise<UserAutocomplete>> {
|
||||
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<Promi
|
||||
};
|
||||
}
|
||||
|
||||
export function autocompleteUsersInTeam(username: string, teamId: string): ThunkActionFunc<Promise<UserAutocomplete>> {
|
||||
return async (doDispatch) => {
|
||||
const {data} = await doDispatch(UserActions.autocompleteUsers(username, teamId));
|
||||
return data!;
|
||||
};
|
||||
}
|
||||
|
||||
export function autocompleteUsers(username: string): ThunkActionFunc<Promise<UserAutocomplete>> {
|
||||
return async (doDispatch) => {
|
||||
const {data} = await doDispatch(UserActions.autocompleteUsers(username));
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,7 +492,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
|
||||
<Menu.Container
|
||||
menuButton={{
|
||||
id: `${this.props.location}_button_${this.props.post.id}`,
|
||||
dateTestId: `PostDotMenu-Button-${this.props.post.id}`,
|
||||
dataTestId: `PostDotMenu-Button-${this.props.post.id}`,
|
||||
class: classNames('post-menu__item', {
|
||||
'post-menu__item--active': this.props.isMenuOpen,
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,8 @@ import './menu.scss';
|
||||
export {Menu as Container} from './menu';
|
||||
export {SubMenu} from './sub_menu';
|
||||
export {MenuItem as Item} from './menu_item';
|
||||
export {MenuItemInput as Input} from './menu_item_input';
|
||||
export {MenuTitle as Title} from './menu_title';
|
||||
export type {FirstMenuItemProps} from './menu_item';
|
||||
export {MenuItemSeparator as Separator} from './menu_item_separator';
|
||||
export {openMenu, dismissMenu} from './menu_utils';
|
||||
|
||||
@@ -41,7 +41,7 @@ const MENU_CLOSE_ANIMATION_DURATION = 100;
|
||||
|
||||
type MenuButtonProps = {
|
||||
id: string;
|
||||
dateTestId?: string;
|
||||
dataTestId?: string;
|
||||
'aria-label'?: string;
|
||||
'aria-describedby'?: string;
|
||||
disabled?: boolean;
|
||||
@@ -64,6 +64,7 @@ type MenuProps = {
|
||||
*/
|
||||
id: string;
|
||||
'aria-label'?: string;
|
||||
className?: string;
|
||||
'aria-labelledby'?: string;
|
||||
|
||||
/**
|
||||
@@ -168,6 +169,7 @@ export function Menu(props: Props) {
|
||||
menuButtonId: props.menuButton.id,
|
||||
menuId: props.menu.id,
|
||||
menuAriaLabel: props.menu?.['aria-label'] ?? '',
|
||||
className: props.menu.className,
|
||||
onModalClose: handleMenuModalClose,
|
||||
children: props.children,
|
||||
onKeyDown: props.menu.onKeyDown,
|
||||
@@ -191,7 +193,7 @@ export function Menu(props: Props) {
|
||||
const triggerElement = (
|
||||
<MenuButtonComponent
|
||||
id={props.menuButton.id}
|
||||
data-testid={props.menuButton.dateTestId}
|
||||
data-testid={props.menuButton.dataTestId}
|
||||
aria-controls={props.menu.id}
|
||||
aria-haspopup={true}
|
||||
aria-expanded={isMenuOpen}
|
||||
@@ -251,6 +253,7 @@ export function Menu(props: Props) {
|
||||
disableAutoFocusItem={disableAutoFocusItem} // This is not anti-pattern, see handleMenuButtonMouseDown
|
||||
MenuListProps={{
|
||||
id: props.menu.id,
|
||||
className: props.menu.className,
|
||||
'aria-label': props.menu?.['aria-label'],
|
||||
'aria-labelledby': props.menu?.['aria-labelledby'],
|
||||
style: {
|
||||
@@ -285,6 +288,7 @@ interface MenuModalProps {
|
||||
menuButtonId: MenuButtonProps['id'];
|
||||
menuId: MenuProps['id'];
|
||||
menuAriaLabel: MenuProps['aria-label'];
|
||||
className: MenuProps['className'];
|
||||
onModalClose: (modalId: MenuProps['id']) => 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}
|
||||
</MuiMenuList>
|
||||
|
||||
@@ -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<HTMLLIElement> | KeyboardEvent<HTMLLIElement>) {
|
||||
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
|
||||
|
||||
47
webapp/channels/src/components/menu/menu_item_input.tsx
Обычный файл
47
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<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
if (onChange) {
|
||||
onChange(event);
|
||||
}
|
||||
};
|
||||
|
||||
const stopParentFromCapturingKey = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Input
|
||||
type={type}
|
||||
onChange={changeHandler}
|
||||
onKeyUp={stopParentFromCapturingKey}
|
||||
onKeyDown={stopParentFromCapturingKey}
|
||||
{...otherProps}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 10px;
|
||||
`;
|
||||
36
webapp/channels/src/components/menu/menu_title.tsx
Обычный файл
36
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 (
|
||||
<Title role={role}>
|
||||
{children}
|
||||
</Title>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
`;
|
||||
@@ -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<unknown>|null, React.ReactNode] => {
|
||||
const useSearchSuggestions = (searchType: string, searchTerms: string, searchTeam: string, caretPosition: number, getCaretPosition: () => number, setSelectedOption: (idx: number) => void): [ProviderResult<unknown>|null, React.ReactNode] => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [providerResults, setProviderResults] = useState<ProviderResult<unknown>|null>(null);
|
||||
@@ -27,8 +27,8 @@ const useSearchSuggestions = (searchType: string, searchTerms: string, caretPosi
|
||||
|
||||
const suggestionProviders = useRef<Provider[]>([
|
||||
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];
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<boolean>(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}
|
||||
/>
|
||||
</PopoverStyled>
|
||||
)}
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<HTMLDivElement>,
|
||||
): JSX.Element => {
|
||||
const intl = useIntl();
|
||||
const [caretPosition, setCaretPosition] = useState<number>(0);
|
||||
const [searchTerms, setSearchTerms] = useState<string>(initialSearchTerms);
|
||||
const [searchTeam, setSearchTeam] = useState<string>(initialSearchTeam);
|
||||
const [searchType, setSearchType] = useState<string>(initialSearchType || 'messages');
|
||||
const [selectedOption, setSelectedOption] = useState<number>(-1);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [showFilterHaveBeenReset, setShowFilterHaveBeenReset] = useState(false);
|
||||
const filterResetTimeout = useRef<NodeJS.Timeout>();
|
||||
|
||||
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(
|
||||
>
|
||||
<i className='icon icon-close'/>
|
||||
</CloseIcon>
|
||||
<SearchTypeSelector
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
/>
|
||||
<SearchBoxHeader>
|
||||
<SearchTypeSelector
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
/>
|
||||
{crossTeamSearchEnabled && (
|
||||
<SearchTeamSelector>
|
||||
<SelectTeam
|
||||
selectedTeamId={searchTeam}
|
||||
onTeamSelected={changeSearchTeam}
|
||||
/>
|
||||
</SearchTeamSelector>
|
||||
)}
|
||||
</SearchBoxHeader>
|
||||
<SearchInput
|
||||
ref={inputRef}
|
||||
searchTerms={searchTerms}
|
||||
@@ -253,6 +300,7 @@ const SearchBox = forwardRef(
|
||||
/>
|
||||
<SearchSuggestions
|
||||
searchType={searchType}
|
||||
searchTeam={searchTeam}
|
||||
searchTerms={searchTerms}
|
||||
suggestionsHeader={suggestionsHeader}
|
||||
providerResults={providerResults}
|
||||
@@ -263,10 +311,12 @@ const SearchBox = forwardRef(
|
||||
/>
|
||||
<SearchBoxHints
|
||||
searchTerms={searchTerms}
|
||||
searchTeam={searchTeam}
|
||||
setSearchTerms={addSearchHint}
|
||||
searchType={searchType}
|
||||
providerResults={providerResults}
|
||||
selectedOption={selectedOption}
|
||||
showFilterHaveBeenReset={showFilterHaveBeenReset}
|
||||
focus={focus}
|
||||
/>
|
||||
</SearchBoxContainer>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<unknown>|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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('components/new_search/SearchBoxSuggestions', () => {
|
||||
const baseProps = {
|
||||
searchType: 'messages',
|
||||
searchTerms: '',
|
||||
searchTeam: 'teamId',
|
||||
selectedOption: -1,
|
||||
setSelectedOption: jest.fn(),
|
||||
suggestionsHeader: <p>{'Test Header'}</p>,
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<unknown> | 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);
|
||||
|
||||
|
||||
@@ -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(<SearchHint {...props}/>);
|
||||
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(<SearchHint {...props}/>);
|
||||
@@ -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(<SearchHint {...props}/>);
|
||||
expect(screen.getByText('Your filters were reset because you chose a different team')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<SearchHintsContainer id='searchHints'>
|
||||
<i className='icon icon-refresh'/>
|
||||
<FormattedMessage
|
||||
id='search_hint.reset_filters'
|
||||
defaultMessage='Your filters were reset because you chose a different team'
|
||||
/>
|
||||
</SearchHintsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasSelectedOption) {
|
||||
return (
|
||||
<SearchHintsContainer id='searchHints'>
|
||||
|
||||
58
webapp/channels/src/components/new_search/select_team.scss
Обычный файл
58
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;
|
||||
}
|
||||
156
webapp/channels/src/components/new_search/select_team.tsx
Обычный файл
156
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 = (
|
||||
<><span>{menuButtonText}</span> <ChevronDownIcon size={12}/></>
|
||||
);
|
||||
|
||||
const onFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Menu.Item
|
||||
id={elementId}
|
||||
role='menuitemradio'
|
||||
forceCloseOnSelect={true}
|
||||
aria-checked={teamId === props.selectedTeamId}
|
||||
key={'team-' + teamId}
|
||||
onClick={() => handleTeamChange(teamId)}
|
||||
labels={<span>{teamName}</span>}
|
||||
trailingElements={(teamId === props.selectedTeamId && (
|
||||
<CheckIcon
|
||||
size={14}
|
||||
color='var(--button-bg, #1c58d9)'
|
||||
/>
|
||||
))}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// MUI Menu doesn't support fragments, and the recommended alternative is to use an array.
|
||||
const renderFilterArea = () => {
|
||||
const elements = [
|
||||
<Menu.Input
|
||||
key='filter_teams'
|
||||
id='search_teams'
|
||||
type='text'
|
||||
placeholder={intl.formatMessage({id: 'search_teams_selector.search_teams', defaultMessage: 'Search teams'})}
|
||||
className='search-teams-selector-search'
|
||||
inputPrefix={<SearchIcon size={12}/>}
|
||||
value={filter}
|
||||
onChange={onFilterChange}
|
||||
/>,
|
||||
];
|
||||
if (currentlySelectedTeam) {
|
||||
elements.push(
|
||||
renderTeam(currentlySelectedTeam.id, currentlySelectedTeam.display_name, currentlySelectedTeam.id, 'search-teams-selector-current-team'),
|
||||
<Menu.Title
|
||||
key='your-team-title'
|
||||
role='separator'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='search_teams_selector.your_teams'
|
||||
defaultMessage='Your teams'
|
||||
/>
|
||||
</Menu.Title>,
|
||||
);
|
||||
}
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu.Container
|
||||
menuButton={{
|
||||
id: 'searchTeamsSelectorMenuButton',
|
||||
class: classNames('search-teams-selector-menu-button', {'search-teams-selector-menu-button__different-team': isDifferentTeamSelected}),
|
||||
children: button,
|
||||
dataTestId: 'searchTeamsSelectorMenuButton',
|
||||
}}
|
||||
menu={{
|
||||
id: 'searchTeamSelectorMenu',
|
||||
'aria-label': 'Select team',
|
||||
className: 'select-team-mui-menu',
|
||||
}}
|
||||
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
|
||||
transformOrigin={{vertical: 'top', horizontal: 'right'}}
|
||||
>
|
||||
{renderTeam('', allTeamsText, 'all_teams')}
|
||||
<Menu.Separator/>
|
||||
{showFilter && renderFilterArea()}
|
||||
{teams.map((team) => renderTeam(team.id, team.display_name, team.id))}
|
||||
</Menu.Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectTeam;
|
||||
@@ -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,
|
||||
|
||||
@@ -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: Props): JSX.Element => {
|
||||
isMobileView,
|
||||
searchTerms,
|
||||
searchType,
|
||||
searchTeam,
|
||||
hideMobileSearchBarInRHS,
|
||||
} = props;
|
||||
|
||||
@@ -166,6 +167,14 @@ const Search: React.FC<Props> = (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: 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: 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)}
|
||||
|
||||
@@ -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<UserAutocomplete>;
|
||||
updateRhsState: (rhsState: string) => void;
|
||||
getMorePostsForSearch: () => void;
|
||||
getMorePostsForSearch: (teamId: string) => void;
|
||||
openRHSSearch: () => void;
|
||||
getMoreFilesForSearch: () => void;
|
||||
getMoreFilesForSearch: (teamId: string) => void;
|
||||
filterFilesSearchByExt: (extensions: string[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, any>) || {};
|
||||
const currentSearch = (getCurrentSearchForSearchTeam(state) as unknown as Record<string, any>) || {};
|
||||
const currentTeamName = getCurrentTeam(state)?.name ?? '';
|
||||
|
||||
return {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const filesTabRef = useRef<HTMLButtonElement>(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<HTMLSelectElement>) => {
|
||||
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<HTMLButtonElement>,
|
||||
@@ -143,19 +132,10 @@ export default function MessagesOrFilesSelector(props: Props): JSX.Element {
|
||||
</div>
|
||||
{props.crossTeamSearchEnabled && (
|
||||
<div className='team-selector-container'>
|
||||
<select
|
||||
value={searchTeam}
|
||||
onChange={onTeamChange}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTeam
|
||||
selectedTeamId={searchTeam}
|
||||
onTeamSelected={props.onTeamChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{props.selected === DataSearchTypes.FILES_SEARCH_TYPE && (
|
||||
|
||||
@@ -36,7 +36,7 @@ export default abstract class Provider {
|
||||
this.forceDispatch = false;
|
||||
}
|
||||
|
||||
abstract handlePretextChanged(pretext: string, callback: (res: ProviderResult<unknown>) => void): boolean;
|
||||
abstract handlePretextChanged(pretext: string, callback: (res: ProviderResult<unknown>) => void, teamId?: string): boolean;
|
||||
|
||||
resetRequest() {
|
||||
this.requestStarted = false;
|
||||
|
||||
@@ -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<Channel>) {
|
||||
handlePretextChanged(pretext: string, resultsCallback: ResultsCallback<Channel>, 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;
|
||||
|
||||
@@ -64,21 +64,21 @@ export const SearchUserSuggestion = React.forwardRef<HTMLLIElement, SuggestionPr
|
||||
SearchUserSuggestion.displayName = 'SearchUserSuggestion';
|
||||
|
||||
export default class SearchUserProvider extends Provider {
|
||||
private autocompleteUsersInTeam: (username: string) => Promise<UserAutocomplete>;
|
||||
constructor(userSearchFunc: (username: string) => Promise<UserAutocomplete>) {
|
||||
private autocompleteUsersInTeam: (username: string, teamId: string) => Promise<UserAutocomplete>;
|
||||
constructor(userSearchFunc: (username: string, teamId: string) => Promise<UserAutocomplete>) {
|
||||
super();
|
||||
this.autocompleteUsersInTeam = userSearchFunc;
|
||||
}
|
||||
|
||||
handlePretextChanged(pretext: string, resultsCallback: ResultsCallback<UserProfile>) {
|
||||
handlePretextChanged(pretext: string, resultsCallback: ResultsCallback<UserProfile>, 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<UserProfile>) {
|
||||
async doAutocomplete(captured: RegExpExecArray | null, teamId: string, resultsCallback: ResultsCallback<UserProfile>) {
|
||||
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;
|
||||
|
||||
@@ -22,7 +22,7 @@ export enum SIZE {
|
||||
|
||||
export type CustomMessageInputType = {type?: 'info' | 'error' | 'warning' | 'success'; value: React.ReactNode} | null;
|
||||
|
||||
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'placeholder'> {
|
||||
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'placeholder'> {
|
||||
required?: boolean;
|
||||
hasError?: boolean;
|
||||
addon?: React.ReactElement;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
@@ -239,6 +239,8 @@
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
&.no-results {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,15 @@ export const getSelectedPost = createSelector(
|
||||
},
|
||||
);
|
||||
|
||||
export const getCurrentSearchForSearchTeam: (state: GlobalState) => Record<string, any> = 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;
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user