[MM-60407] Add team selector to search bar (#29389)

Automatic Merge
Этот коммит содержится в:
Julien Tant
2025-01-30 09:50:08 -07:00
коммит произвёл GitHub
родитель 369b9fa96b
Коммит 1fcb6a49a8
39 изменённых файлов: 657 добавлений и 95 удалений

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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;
`;

Просмотреть файл

@@ -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'>

Просмотреть файл

@@ -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;
}

Просмотреть файл

@@ -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;
}