Initial version of the new search interface (#27301)

* Initial version of the new search interface

* Fixing CI

* style tweaks

* tweaks to date picker in search

* change search input to button

* Addressing a lot of small problems

* Addressing current search visualization

* Fixing problem on filter autocompletion

* Adding some more fixes

* Tiny style ajustment

* Removing unnecessary slice call

* Adding search extensibility

* Improving the extensibility

* Fixing and refactoring a bit

* On clear click, clear the current search

* splitting components for simplicity

* tweaks to file ext search

* Addressing PR review comments and adding color to the extensions search icons

* Improving a bit the extension search

* Fixing CI

* Fixing some bugs

* Keep the focus on search input whenever we change the search type

* Adding SearchHint tests

* Adding search_box_type_selector tests

* Adding tests for the SearchBox component

* Adding tests for search box suggestions

* Adding SearchBoxInput tests

* Splitting extension suggestion component and provider

* ExtensionSuggestion tests

* Removing unneeded property

* Adding SearchBoxhints tests

* Adding tests for NewSearch component

* Adding tests for the extension suggestion provider

* Fixing linter errors

* Fixing tests

* Fixing linter errors

* Fixing linter errors

* Adding license check for plugins

* Addressing PR review comments

* Addressing PR review comments

* Addressing PR review comments

* Fixing tests

* Fixing some cypress tests

* More work around cypress tests for search

* Some accessibility changes

* more work on cypress tests

* fixing more cypress tests

* Fixing linter errors

* Fixing tests

* Fixing ctrl+f6 switching

* Fixing scrolling on small windows wheneve you are searching

* Improve editing in the middle of the search text

* Addressing some PR review comments

* Addressing some PR review comments

* Addressing some PR review comments

* Addressing some PR review comments

* Fixing tests and linter errors

* Fixingls tests and linter errors

* Fix type checks

* Clearer logic to render plugin or builtin suggestion

* Addressing PR review changes

* Addressing PR review changes

* Using selectors for plugin components

* Changing uppercase to lowercase on lists of components

* Updating tests

* Updating snapshots

* minor css tweaks

* minor css tweak

* Adding the search type and the clear button

* Fixing problem with the filetype selector

* fixing linter errors

* Some fixes

* More linter fixes

* Reverting prettier auto-formating

* Fixing CI

* Fixing CI

* Fixing chrome problem

* Fixing CI

* Addressing PR review comments

---------

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
Co-authored-by: Christopher Speller <crspeller@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Jesús Espino
2024-10-14 12:56:35 +02:00
коммит произвёл GitHub
родитель b7ccd745c8
Коммит 5f8bdba459
71 изменённых файлов: 2503 добавлений и 291 удалений

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

@@ -129,6 +129,7 @@ const initialState = {
rhs: {
rhsState: null,
searchTerms: '',
searchType: '',
},
},
};
@@ -150,6 +151,7 @@ describe('executeCommand', () => {
{type: 'UPDATE_RHS_SEARCH_TERMS', terms: 'foo bar'},
{type: 'UPDATE_RHS_STATE', state: 'search'},
{type: 'UPDATE_RHS_SEARCH_RESULTS_TERMS', terms: ''},
{type: 'UPDATE_RHS_SEARCH_RESULTS_TYPE', searchType: ''},
{type: 'SEARCH_POSTS_REQUEST', isGettingMore: false},
{type: 'SEARCH_FILES_REQUEST', isGettingMore: false},
]);

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

@@ -192,6 +192,7 @@ describe('Actions.Posts', () => {
},
rhs: {
searchTerms: '',
searchType: '',
filesSearchExtFilter: [],
},
},
@@ -344,6 +345,7 @@ describe('Actions.Posts', () => {
{terms: 'hello', type: 'UPDATE_RHS_SEARCH_TERMS'},
{state: 'search', type: 'UPDATE_RHS_STATE'},
{terms: '', type: 'UPDATE_RHS_SEARCH_RESULTS_TERMS'},
{searchType: '', type: 'UPDATE_RHS_SEARCH_RESULTS_TYPE'},
{isGettingMore: false, type: 'SEARCH_POSTS_REQUEST'},
{isGettingMore: false, type: 'SEARCH_FILES_REQUEST'},
]);

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

@@ -125,6 +125,7 @@ describe('rhs view actions', () => {
rhs: {
rhsState: null,
filesSearchExtFilter: [] as string[],
searchType: '',
},
posts: {
editingPost: {
@@ -219,7 +220,12 @@ describe('rhs view actions', () => {
describe('performSearch', () => {
// timezone offset in seconds
const timeZoneOffset = getBrowserUtcOffset() * 60;
let timeZoneOffset = getBrowserUtcOffset() * 60;
// Avoid problems with negative cero
if (timeZoneOffset === 0) {
timeZoneOffset = 0;
}
test('it dispatches searchPosts correctly', () => {
const terms = '@here test search';
@@ -276,6 +282,7 @@ describe('rhs view actions', () => {
views: {
rhs: {
searchTerms: terms,
searchType: 'messages',
filesSearchExtFilter: [] as string[],
},
},
@@ -292,6 +299,10 @@ describe('rhs view actions', () => {
type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TERMS,
terms,
});
compareStore.dispatch({
type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TYPE,
searchType: 'messages',
});
compareStore.dispatch(performSearch(terms));
expect(store.getActions()).toEqual(compareStore.getActions());
@@ -861,6 +872,7 @@ describe('rhs view actions', () => {
views: {
rhs: {
searchTerms: terms,
searchType: 'messages',
filesSearchExtFilter: [] as string[],
},
},
@@ -875,6 +887,10 @@ describe('rhs view actions', () => {
type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TERMS,
terms,
});
compareStore.dispatch({
type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TYPE,
searchType: 'messages',
});
compareStore.dispatch(performSearch(terms));
expect(store.getActions()).toEqual(compareStore.getActions());

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

@@ -26,7 +26,14 @@ import {getCurrentUser, getCurrentUserMentionKeys} from 'mattermost-redux/select
import type {ActionFunc, ActionFuncAsync, ThunkActionFunc} from 'mattermost-redux/types/actions';
import {trackEvent} from 'actions/telemetry_actions.jsx';
import {getSearchTerms, getRhsState, getPluggableId, getFilesSearchExtFilter, getPreviousRhsState} from 'selectors/rhs';
import {
getSearchType,
getSearchTerms,
getRhsState,
getPluggableId,
getFilesSearchExtFilter,
getPreviousRhsState,
} from 'selectors/rhs';
import {SidebarSize} from 'components/resizable_sidebar/constants';
@@ -187,6 +194,13 @@ function updateSearchResultsTerms(terms: string) {
};
}
function updateSearchResultsType(searchType: string) {
return {
type: ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TYPE,
searchType,
};
}
export function performSearch(terms: string, isMentionSearch?: boolean): ThunkActionFunc<unknown, GlobalState> {
return (dispatch, getState) => {
let searchTerms = terms;
@@ -240,6 +254,7 @@ export function showSearchResults(isMentionSearch = false): ThunkActionFunc<unkn
const state = getState();
const searchTerms = getSearchTerms(state);
const searchType = getSearchType(state);
if (isMentionSearch) {
dispatch(updateRhsState(RHSStates.MENTION));
@@ -247,6 +262,7 @@ export function showSearchResults(isMentionSearch = false): ThunkActionFunc<unkn
dispatch(updateRhsState(RHSStates.SEARCH));
}
dispatch(updateSearchResultsTerms(searchTerms));
dispatch(updateSearchResultsType(searchType));
return dispatch(performSearch(searchTerms));
};

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

@@ -9,7 +9,7 @@ import Flex from '@mattermost/compass-components/utilities/layout/Flex'; // esli
import {closeRightHandSide, showMentions} from 'actions/views/rhs';
import {getRhsState} from 'selectors/rhs';
import Search from 'components/search';
import NewSearch from 'components/new_search/new_search';
import {
Constants,
@@ -50,9 +50,7 @@ const GlobalSearchNav = (): JSX.Element => {
flex={1}
alignment='center'
>
<Search
enableFindShortcut={true}
/>
<NewSearch/>
</Flex>
);
};

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

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {
renderWithContext,
screen,
} from 'tests/react_testing_utils';
import ExtensionSuggestion from './extension_suggestions';
describe('components/new_search/ExtensionSuggestion', () => {
const baseProps = {
item: {type: 'test-type', label: 'test-label', value: 'test-value'},
term: 'test',
matchedPretext: 'test',
isSelection: false,
onClick: jest.fn(),
onMouseMove: jest.fn(),
};
test('should show the file-type as label and the value as extension whenever is not a know filetype', () => {
renderWithContext(<ExtensionSuggestion {...baseProps}/>);
expect(screen.getByText('test-type')).toBeInTheDocument();
expect(screen.getByText('(.test-value)')).toBeInTheDocument();
});
test('should show the name of the type of file whenever it knows the filetype', () => {
const props = {...baseProps, item: {...baseProps.item, type: 'pdf', value: 'pdf'}};
renderWithContext(<ExtensionSuggestion {...props}/>);
expect(screen.getByText('Acrobat')).toBeInTheDocument();
expect(screen.getByText('(.pdf)')).toBeInTheDocument();
});
test('should pass the right data on click', () => {
const props = {...baseProps};
renderWithContext(<ExtensionSuggestion {...props}/>);
screen.getByText('test-type').click();
expect(props.onClick).toHaveBeenCalledWith('test-value', 'test');
});
test('should have selected class whenever is selected', () => {
const props = {...baseProps, isSelection: true};
renderWithContext(<ExtensionSuggestion {...props}/>);
expect(screen.getByText('test-type')).toHaveClass('selected');
});
});

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

@@ -0,0 +1,131 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useCallback} from 'react';
import type {MessageDescriptor} from 'react-intl';
import {useIntl, defineMessages} from 'react-intl';
import styled from 'styled-components';
import type {SuggestionProps} from 'components/suggestion/suggestion';
import {getCompassIconClassName} from 'utils/utils';
import type {ExtensionItem} from './extension_suggestions_provider';
const SearchFileExtensionSuggestionContainer = styled.div`
display: flex;
align-items: center;
padding: 8px 2.4rem;
&.selected,
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.08);
}
.file-icon {
background-size: 16px 20px;
width: 24px;
height: 24px;
font-size: 18px;
margin-right: 12px;
display: flex;
align-items: center;
&.icon-file-excel-outline {
color: #339970;
}
&.icon-file-powerpoint-outline {
color: #e07315;
}
&.icon-file-pdf-outline {
color: #c43133;
}
&.icon-file-image-outline,
&.icon-file-audio-outline,
&.icon-file-video-outline,
&.icon-file-word-outline {
color: #5d89ea;
}
}
`;
const ExtensionText = styled.span`
margin-left: 4px;
`;
const messages: Record<string, MessageDescriptor> =
defineMessages({
pdf: {
id: 'file_type.pdf',
defaultMessage: 'Acrobat',
},
word: {
id: 'file_type.word',
defaultMessage: 'Word Document',
},
image: {
id: 'file_type.image',
defaultMessage: 'Image',
},
audio: {
id: 'file_type.audio',
defaultMessage: 'Audio',
},
video: {
id: 'file_type.video',
defaultMessage: 'Video',
},
presentation: {
id: 'file_type.presentation',
defaultMessage: 'Powerpoint Presentation',
},
spreadsheet: {
id: 'file_type.spreadsheet',
defaultMessage: 'Excel spreadsheet',
},
code: {
id: 'file_type.code',
defaultMessage: 'Code file',
},
patch: {
id: 'file_type.patch',
defaultMessage: 'Patch file',
},
svg: {
id: 'file_type.svg',
defaultMessage: 'Vector graphics',
},
text: {
id: 'file_type.text',
defaultMessage: 'Text file',
},
});
const SearchFileExtensionSuggestion = React.forwardRef<
HTMLDivElement,
SuggestionProps<ExtensionItem>
>(({item, onClick, matchedPretext, isSelection}, ref) => {
const intl = useIntl();
const optionClicked = useCallback(() => {
onClick(item.value, matchedPretext);
}, [onClick, item.value, matchedPretext]);
const labelName = messages[item.type] ?
intl.formatMessage(messages[item.type]) :
item.type;
return (
<SearchFileExtensionSuggestionContainer
ref={ref}
className={classNames({selected: isSelection})}
onClick={optionClicked}
>
<div className={classNames('file-icon', getCompassIconClassName(item.type))}/>
{labelName}
<ExtensionText>{`(.${item.value})`}</ExtensionText>
</SearchFileExtensionSuggestionContainer>
);
});
SearchFileExtensionSuggestion.displayName = 'SearchFileExtensionSuggestion';
export default SearchFileExtensionSuggestion;

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

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {SearchFileExtensionProvider} from './extension_suggestions_provider';
describe('components/new_search/SearchFileExtensionProvider', () => {
test('should not provide any file extensions whenever there is not pretext with ext: in it', () => {
const provider = new SearchFileExtensionProvider();
const callback = jest.fn();
provider.handlePretextChanged('', callback);
expect(callback).not.toHaveBeenCalled();
});
test('should provide a predetermined file extensions whenever there is not pretext', () => {
const provider = new SearchFileExtensionProvider();
const callback = jest.fn();
provider.handlePretextChanged('ext:', callback);
expect(callback).toHaveBeenCalledWith({
component: expect.any(Object),
items: [
{label: 'text', type: 'text', value: 'txt'},
{label: 'word', type: 'word', value: 'docx'},
{label: 'spreadsheet', type: 'spreadsheet', value: 'xlsx'},
{label: 'presentation', type: 'presentation', value: 'pptx'},
{label: 'pdf', type: 'pdf', value: 'pdf'},
{label: 'image', type: 'image', value: 'png'},
{label: 'image', type: 'image', value: 'jpg'},
{label: 'audio', type: 'audio', value: 'mp3'},
{label: 'video', type: 'video', value: 'mp4'},
],
matchedPretext: '',
terms: ['txt', 'docx', 'xlsx', 'pptx', 'pdf', 'png', 'jpg', 'mp3', 'mp4'],
});
});
test('should provide a filtered set of file extensions whenever there is pretext', () => {
const provider = new SearchFileExtensionProvider();
const callback = jest.fn();
provider.handlePretextChanged('ext:t', callback);
expect(callback).toHaveBeenCalledWith({
component: expect.any(Object),
items: [
{label: 'tex', type: 'code', value: 'tex'},
{label: 'thor', type: 'code', value: 'thor'},
{label: 'tif', type: 'image', value: 'tif'},
{label: 'tiff', type: 'image', value: 'tiff'},
{label: 'txt', type: 'text', value: 'txt'},
],
matchedPretext: 't',
terms: ['tex', 'thor', 'tif', 'tiff', 'txt'],
});
});
});

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

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ResultsCallback} from 'components/suggestion/provider';
import Provider from 'components/suggestion/provider';
import Constants from 'utils/constants';
import SearchFileExtensionSuggestion from './extension_suggestions';
export type ExtensionItem = {
label: string;
type: string;
value: string;
};
const globalExtensions: ExtensionItem[] = [];
Constants.TEXT_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'text', value: extension}));
Constants.IMAGE_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'image', value: extension}));
Constants.AUDIO_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'audio', value: extension}));
Constants.VIDEO_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'video', value: extension}));
Constants.PRESENTATION_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'presentation', value: extension}));
Constants.SPREADSHEET_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'spreadsheet', value: extension}));
Constants.WORD_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'word', value: extension}));
Constants.CODE_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'code', value: extension}));
Constants.PDF_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'pdf', value: extension}));
Constants.PATCH_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'patch', value: extension}));
Constants.SVG_TYPES.forEach((extension) => globalExtensions.push({label: extension, type: 'svg', value: extension}));
globalExtensions.sort((a, b) => a.label.localeCompare(b.label));
export class SearchFileExtensionProvider extends Provider {
handlePretextChanged(pretext: string, resultsCallback: ResultsCallback<ExtensionItem>) {
const captured = (/\b(?:ext):\s*(\S*)$/i).exec(pretext.toLowerCase());
if (captured) {
const matchedPretext = captured[1];
let extensions: ExtensionItem[] = [...globalExtensions];
if (matchedPretext.length > 0) {
extensions = extensions.filter((extension) => extension.label.startsWith(matchedPretext.toLowerCase()));
extensions = extensions.slice(0, 10);
} else {
extensions = [
{label: 'text', type: 'text', value: 'txt'},
{label: 'word', type: 'word', value: 'docx'},
{label: 'spreadsheet', type: 'spreadsheet', value: 'xlsx'},
{label: 'presentation', type: 'presentation', value: 'pptx'},
{label: 'pdf', type: 'pdf', value: 'pdf'},
{label: 'image', type: 'image', value: 'png'},
{label: 'image', type: 'image', value: 'jpg'},
{label: 'audio', type: 'audio', value: 'mp3'},
{label: 'video', type: 'video', value: 'mp4'},
];
}
const terms = extensions.map((extension) => extension.value);
resultsCallback({
matchedPretext,
terms,
items: extensions,
component: SearchFileExtensionSuggestion,
});
}
return Boolean(captured);
}
allowDividers() {
return false;
}
presentationType() {
return 'text';
}
}

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

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useRef, useEffect, useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
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 {autocompleteUsersInTeam} from 'actions/user_actions';
import type {ProviderResult} from 'components/suggestion/provider';
import type Provider from 'components/suggestion/provider';
import SearchChannelProvider from 'components/suggestion/search_channel_provider';
import SearchDateProvider from 'components/suggestion/search_date_provider';
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 dispatch = useDispatch();
const [providerResults, setProviderResults] = useState<ProviderResult<unknown>|null>(null);
const [suggestionsHeader, setSuggestionsHeader] = useState<React.ReactNode>(<span/>);
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 SearchFileExtensionProvider(),
]);
const headers = useMemo<React.ReactNode[]>(() => [
<span key={1}/>,
<FormattedMessage
id='search_bar.channels'
defaultMessage='Channels'
key={2}
/>,
<FormattedMessage
id='search_bar.users'
defaultMessage='Users'
key={3}
/>,
<FormattedMessage
id='search_bar.file_types'
defaultMessage='File types'
key={4}
/>,
], []);
useEffect(() => {
setProviderResults(null);
if (searchType !== '' && searchType !== 'messages' && searchType !== 'files') {
return;
}
const partialSearchTerms = searchTerms.slice(0, caretPosition);
if (searchTerms.length > caretPosition && searchTerms[caretPosition] !== ' ') {
return;
}
if (caretPosition > 0 && searchTerms[caretPosition - 1] === ' ') {
return;
}
suggestionProviders.current.forEach((provider, idx) => {
provider.handlePretextChanged(partialSearchTerms, (res: ProviderResult<unknown>) => {
if (idx === 3 && searchType !== 'files') {
return;
}
if (caretPosition !== getCaretPosition()) {
return;
}
res.items = res.items.slice(0, 10);
res.terms = res.terms.slice(0, 10);
setProviderResults(res);
setSelectedOption(0);
setSuggestionsHeader(headers[idx]);
});
});
}, [searchTerms, searchType, caretPosition]);
return [providerResults, suggestionsHeader];
};
export default useSearchSuggestions;

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

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {
renderWithContext,
screen,
fireEvent,
act,
} from 'tests/react_testing_utils';
import NewSearch from './new_search';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux') as typeof import('react-redux'),
useDispatch: () => mockDispatch,
}));
describe('components/new_search/NewSearch', () => {
test('should open the search box on click search', () => {
renderWithContext(<NewSearch/>);
expect(screen.queryByText('Messages')).not.toBeInTheDocument();
screen.getByText('Search').click();
expect(screen.getByText('Messages')).toBeInTheDocument();
});
test('should open the search box when pressing any key differnt than tab', () => {
renderWithContext(<NewSearch/>);
expect(screen.queryByText('Messages')).not.toBeInTheDocument();
fireEvent.keyDown(screen.getByText('Search'), {key: 'Tab', code: 'Tab', keyCode: 9, charCode: 9});
expect(screen.queryByText('Messages')).not.toBeInTheDocument();
fireEvent.keyDown(screen.getByText('Search'), {key: 'a', code: 'KeyA', keyCode: 65, charCode: 65});
expect(screen.getByText('Messages')).toBeInTheDocument();
});
test('should close the search box on click outside the searchbox', () => {
renderWithContext(<div><NewSearch/>{'Outside'}</div>);
expect(screen.queryByText('Messages')).not.toBeInTheDocument();
act(() => {
screen.getByText('Search').click();
});
expect(screen.getByText('Messages')).toBeInTheDocument();
act(() => {
screen.getByText('Outside').click();
});
expect(screen.queryByText('Messages')).not.toBeInTheDocument();
});
test('should close the search box on Esc key is pressed', () => {
renderWithContext(<NewSearch/>);
expect(screen.queryByText('Messages')).not.toBeInTheDocument();
act(() => {
screen.getByText('Search').click();
});
expect(screen.getByText('Messages')).toBeInTheDocument();
act(() => {
fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Escape', code: 'Escape', keyCode: 27, charCode: 27});
});
expect(screen.queryByText('Messages')).not.toBeInTheDocument();
});
test('should close on search after calling dispatch', () => {
renderWithContext(<NewSearch/>);
expect(screen.queryByText('Messages')).not.toBeInTheDocument();
act(() => {
screen.getByText('Search').click();
});
expect(screen.getByText('Messages')).toBeInTheDocument();
act(() => {
fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Enter', code: 'Enter', keyCode: 13, charCode: 13});
});
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);
});
test('should open the search ctrl+shift+f is press on web app', () => {
renderWithContext(<div><NewSearch/>{'Outside'}</div>);
expect(screen.queryByText('Messages')).not.toBeInTheDocument();
act(() => {
fireEvent.keyDown(screen.getByText('Outside'), {key: 'f', code: 'KeyF', keyCode: 70, charCode: 70, ctrlKey: true, shiftKey: true});
});
expect(screen.getByText('Messages')).toBeInTheDocument();
});
});

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

@@ -0,0 +1,280 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState, useRef, useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import styled from 'styled-components';
import {getCurrentChannelNameForSearchShortcut} from 'mattermost-redux/selectors/entities/channels';
import {updateSearchTerms, showSearchResults, updateSearchType} from 'actions/views/rhs';
import {getSearchButtons} from 'selectors/plugins';
import {getSearchTerms, getSearchType} from 'selectors/rhs';
import Popover from 'components/widgets/popover';
import Constants from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import {isServerVersionGreaterThanOrEqualTo} from 'utils/server_version';
import {isDesktopApp, getDesktopVersion, isMacApp} from 'utils/user_agent';
import SearchBox from './search_box';
const PopoverStyled = styled(Popover)`
min-width: 600px;
left: -90px;
top: -12px;
border-radius: 12px;
max-height: 90vh;
overflow-y: auto;
.popover-content {
padding: 0px;
}
`;
const SearchTypeBadge = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 2px 2px 2px 7px;
border-radius: 4px;
margin: 0 6px;
background: rgba(var(--sidebar-text-rgb), 0.08);
color: var(--sidebar-text);
font-size: 10px;
font-weight: 700;
.icon-close {
padding: 2px;
cursor: pointer;
}
&:hover {
background: rgba(v(button-bg-rgb), 0.16);
}
`;
const CloseIcon = styled.div`
position: absolute;
top: 0;
right: 2px;
display: flex;
width: 2.4rem;
height: 100%;
align-items: center;
justify-content: center;
margin: 0;
cursor: pointer;
font-size: 16px;
visibility: visible;
transition: opacity 0.12s easy-out;
`;
const NewSearchContainer = styled.div`
display: flex;
position: relative;
align-items: center;
height: 28px;
width: 100%;
background-color: rgba(var(--sidebar-text-rgb), 0.08);
color: rgba(var(--sidebar-text-rgb), 0.64);
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-s);
border: none;
padding: 4px;
cursor: pointer;
&:hover {
background-color: rgba(var(--sidebar-text-rgb), 0.16);
color: rgba(var(--sidebar-text-rgb), 0.88);
}
`;
const NewSearch = (): JSX.Element => {
const currentChannelName = useSelector(getCurrentChannelNameForSearchShortcut);
const searchTerms = useSelector(getSearchTerms) || '';
const searchType = useSelector(getSearchType) || '';
const pluginSearch = useSelector(getSearchButtons);
const dispatch = useDispatch();
const [focused, setFocused] = useState<boolean>(false);
const [currentChannel, setCurrentChannel] = useState('');
const searchBoxRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const isDesktop = isDesktopApp() && isServerVersionGreaterThanOrEqualTo(getDesktopVersion(), '4.7.0');
const handleKeyDown = (e: KeyboardEvent) => {
if (Keyboard.isKeyPressed(e, Constants.KeyCodes.ESCAPE)) {
e.preventDefault();
setCurrentChannel('');
setFocused(false);
}
if (Keyboard.cmdOrCtrlPressed(e) && Keyboard.isKeyPressed(e, Constants.KeyCodes.F6)) {
e.preventDefault();
setCurrentChannel('');
setFocused(false);
}
if (Keyboard.cmdOrCtrlPressed(e) && Keyboard.isKeyPressed(e, Constants.KeyCodes.F)) {
if (!isDesktop && !e.shiftKey) {
return;
}
// Special case for Mac Desktop xApp where Ctrl+Cmd+F triggers full screen view
if (isMacApp() && e.ctrlKey) {
return;
}
e.preventDefault();
setCurrentChannel(currentChannelName || '');
setFocused(true);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [currentChannelName]);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (searchBoxRef.current) {
if (e.target !== searchBoxRef.current && !searchBoxRef.current.contains(e.target as Node)) {
setFocused(false);
setCurrentChannel('');
}
}
};
document.addEventListener('click', handleClick, {capture: true});
return () => {
document.removeEventListener('click', handleClick);
};
}, []);
const closeSearchBox = useCallback(() => {
setFocused(false);
setCurrentChannel('');
}, []);
const openSearchBox = useCallback(() => {
setFocused(true);
}, []);
const openSearchBoxOnKeyPress = useCallback(
(e: React.KeyboardEvent) => {
if (Keyboard.isKeyPressed(e, Constants.KeyCodes.TAB)) {
return;
}
if (Keyboard.cmdOrCtrlPressed(e) && Keyboard.isKeyPressed(e, Constants.KeyCodes.F6)) {
setFocused(false);
return;
}
openSearchBox();
},
[openSearchBox],
);
const runSearch = useCallback(
(searchType: string, searchTerms: string) => {
dispatch(updateSearchType(searchType));
dispatch(updateSearchTerms(searchTerms));
if (searchType === '' || searchType === 'messages' || searchType === 'files') {
dispatch(showSearchResults(false));
} else {
pluginSearch.forEach((pluginData: any) => {
if (pluginData.pluginId === searchType) {
pluginData.action(searchTerms);
}
});
}
setFocused(false);
setCurrentChannel('');
},
[pluginSearch],
);
const onClose = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
dispatch(updateSearchType(''));
dispatch(updateSearchTerms(''));
}, []);
const clearSearchType = useCallback(() => dispatch(updateSearchType('')), []);
return (
<NewSearchContainer
tabIndex={0}
onKeyDown={openSearchBoxOnKeyPress}
onClick={openSearchBox}
id='searchFormContainer'
role='search'
className='a11y__region'
>
<i className='icon icon-magnify'/>
{(searchType === 'messages' || searchType === 'files') && (
<SearchTypeBadge>
{searchType === 'messages' && (
<FormattedMessage
id='search_bar.search_types.messages'
defaultMessage='MESSAGES'
/>
)}
{searchType === 'files' && (
<FormattedMessage
id='search_bar.search_types.files'
defaultMessage='FILES'
/>
)}
<i
className='icon icon-close icon-12'
onClick={clearSearchType}
/>
</SearchTypeBadge>
)}
{searchTerms && <span tabIndex={0}>{searchTerms}</span>}
{searchTerms && (
<CloseIcon
data-testid='input-clear'
role='button'
onClick={onClose}
>
<span
className='input-clear-x'
aria-hidden='true'
>
<i className='icon icon-close-circle'/>
</span>
</CloseIcon>
)}
{!searchTerms && (
<FormattedMessage
id='search_bar.search'
defaultMessage='Search'
/>
)}
{focused && (
<PopoverStyled
id='searchPopover'
placement='bottom'
>
<SearchBox
ref={searchBoxRef}
onClose={closeSearchBox}
onSearch={runSearch}
initialSearchTerms={currentChannel ? `in:${currentChannel} ` : searchTerms}
initialSearchType={searchType}
/>
</PopoverStyled>
)}
</NewSearchContainer>
);
};
export default NewSearch;

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

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {
renderWithContext,
screen,
fireEvent,
} from 'tests/react_testing_utils';
import SearchBox from './search_box';
describe('components/new_search/SearchBox', () => {
const baseProps = {
onClose: jest.fn(),
onSearch: jest.fn(),
initialSearchTerms: '',
initialSearchType: 'messages',
};
test('should have the focus on the input field', () => {
renderWithContext(<SearchBox {...baseProps}/>);
expect(screen.getByPlaceholderText('Search messages')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Search messages')).toHaveFocus();
});
test('should have set the initial search terms', () => {
const props = {...baseProps, initialSearchTerms: 'test'};
renderWithContext(<SearchBox {...props}/>);
expect(screen.getByPlaceholderText('Search messages')).toHaveValue('test');
});
test('should have the focus on the input field after switching search type', () => {
renderWithContext(<SearchBox {...baseProps}/>);
screen.getByText('Files').click();
expect(screen.getByPlaceholderText('Search files')).toHaveFocus();
});
test('should see files hints when i click on files', () => {
renderWithContext(<SearchBox {...baseProps}/>);
expect(screen.getByText('From:')).toBeInTheDocument();
expect(screen.queryByText('Ext:')).not.toBeInTheDocument();
screen.getByText('Files').click();
expect(screen.getByText('Ext:')).toBeInTheDocument();
});
test('should call close on esc keydown', () => {
renderWithContext(<SearchBox {...baseProps}/>);
fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Escape', code: 'Escape'});
expect(baseProps.onClose).toBeCalledTimes(1);
});
test('should call search on enter keydown', () => {
renderWithContext(<SearchBox {...baseProps}/>);
fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Enter', code: 'Enter'});
expect(baseProps.onSearch).toBeCalledTimes(1);
});
test('should be able to select with the up and down arrows', () => {
renderWithContext(<SearchBox {...baseProps}/>);
screen.getByText('Files').click();
fireEvent.change(screen.getByPlaceholderText('Search files'), {target: {value: 'ext:'}});
expect(screen.getByText('Text file')).toHaveClass('selected');
expect(screen.getByText('Word Document')).not.toHaveClass('selected');
fireEvent.keyDown(screen.getByPlaceholderText('Search files'), {key: 'ArrowDown', code: 'ArrowDown'});
expect(screen.getByText('Text file')).not.toHaveClass('selected');
expect(screen.getByText('Word Document')).toHaveClass('selected');
fireEvent.keyDown(screen.getByPlaceholderText('Search files'), {key: 'ArrowUp', code: 'ArrowUp'});
expect(screen.getByText('Text file')).toHaveClass('selected');
expect(screen.getByText('Word Document')).not.toHaveClass('selected');
});
});

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

@@ -0,0 +1,268 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useRef, forwardRef, useCallback, useEffect} from 'react';
import {useIntl} from 'react-intl';
import styled from 'styled-components';
import Constants from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import {escapeRegex} from 'utils/text_formatting';
import useSearchSuggestions from './hooks';
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';
const {KeyCodes} = Constants;
type Props = {
onClose: () => void;
onSearch: (searchType: string, searchTerms: string) => void;
initialSearchTerms: string;
initialSearchType: string;
};
const SearchBoxContainer = styled.div`
padding: 0px;
.rdp {
margin: 0;
padding: 16px 20px;
.rdp-months {
justify-content: center;
width: 100%;
}
.rdp-month {
width: 100%;
}
.rdp-table {
max-width: none;
width: 100%;
}
.rdp-tbody .rdp-button {
display: unset;
}
}
`;
const CloseIcon = styled.button`
position: absolute;
top: 18px;
right: 18px;
z-index: 1;
`;
const SearchBox = forwardRef(
(
{onClose, onSearch, initialSearchTerms, initialSearchType}: Props,
ref: React.Ref<HTMLDivElement>,
): JSX.Element => {
const intl = useIntl();
const [caretPosition, setCaretPosition] = useState<number>(0);
const [searchTerms, setSearchTerms] = useState<string>(initialSearchTerms);
const [searchType, setSearchType] = useState<string>(initialSearchType || 'messages');
const [selectedOption, setSelectedOption] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement | null>(null);
const getCaretPosition = useCallback(() => {
return inputRef.current?.selectionEnd || 0;
}, []);
const addSearchHint = useCallback((newSearchTerms: string) => {
setSearchTerms(newSearchTerms);
setTimeout(() => {
inputRef.current?.setSelectionRange(newSearchTerms.length, newSearchTerms.length);
setCaretPosition(newSearchTerms.length);
}, 0);
}, []);
useEffect(() => {
function updateCaretPosition() {
setCaretPosition(inputRef.current?.selectionEnd || 0);
}
const input = inputRef.current;
if (input) {
input.addEventListener('change', updateCaretPosition);
input.addEventListener('keypress', updateCaretPosition);
input.addEventListener('keyup', updateCaretPosition);
input.addEventListener('mousedown', updateCaretPosition);
input.addEventListener('touchstart', updateCaretPosition);
input.addEventListener('input', updateCaretPosition);
input.addEventListener('paste', updateCaretPosition);
input.addEventListener('cut', updateCaretPosition);
input.addEventListener('mousemove', updateCaretPosition);
input.addEventListener('select', updateCaretPosition);
input.addEventListener('selectstart', updateCaretPosition);
}
return () => {
if (input) {
input.removeEventListener('change', updateCaretPosition);
input.removeEventListener('keypress', updateCaretPosition);
input.removeEventListener('mousedown', updateCaretPosition);
input.removeEventListener('keyup', updateCaretPosition);
input.removeEventListener('touchstart', updateCaretPosition);
input.removeEventListener('input', updateCaretPosition);
input.removeEventListener('paste', updateCaretPosition);
input.removeEventListener('cut', updateCaretPosition);
input.removeEventListener('mousemove', updateCaretPosition);
input.removeEventListener('select', updateCaretPosition);
input.removeEventListener('selectstart', updateCaretPosition);
}
};
}, [inputRef.current]);
const [providerResults, suggestionsHeader] = useSearchSuggestions(
searchType,
searchTerms,
caretPosition,
getCaretPosition,
setSelectedOption,
);
const focus = useCallback((newposition: number) => {
if (inputRef.current) {
inputRef.current.focus();
setTimeout(() => {
inputRef.current?.setSelectionRange(newposition, newposition);
}, 0);
}
}, []);
const updateSearchValue = useCallback(
(value: string, matchedPretext: string) => {
const escapedMatchedPretext = escapeRegex(matchedPretext);
const caretPosition = getCaretPosition();
const extraSpace = caretPosition === searchTerms.length ? ' ' : '';
setSearchTerms(
searchTerms.slice(0, caretPosition).replace(new RegExp(escapedMatchedPretext + '$'), '') +
value +
extraSpace +
searchTerms.slice(caretPosition),
);
focus((caretPosition + value.length + 1) - matchedPretext.length);
},
[searchTerms, setSearchTerms, focus, getCaretPosition],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<Element>): void => {
if (Keyboard.isKeyPressed(e as any, KeyCodes.ESCAPE)) {
e.stopPropagation();
e.preventDefault();
if (!providerResults || providerResults?.items.length === 0 || selectedOption === -1) {
onClose();
} else {
setSelectedOption(-1);
}
}
if (Keyboard.isKeyPressed(e as any, KeyCodes.DOWN)) {
e.stopPropagation();
e.preventDefault();
const totalItems = providerResults?.items.length || 0;
if (selectedOption + 1 < totalItems) {
setSelectedOption(selectedOption + 1);
}
}
if (Keyboard.isKeyPressed(e as any, KeyCodes.UP)) {
e.stopPropagation();
e.preventDefault();
if (selectedOption > 0) {
setSelectedOption(selectedOption - 1);
}
}
if (Keyboard.isKeyPressed(e as any, KeyCodes.ENTER)) {
e.stopPropagation();
e.preventDefault();
if (!providerResults || providerResults?.items.length === 0 || selectedOption === -1) {
onSearch(searchType, searchTerms);
} else {
const matchedPretext = providerResults?.matchedPretext;
const value = providerResults?.terms[selectedOption];
updateSearchValue(value, matchedPretext);
setSelectedOption(-1);
}
}
},
[providerResults, onClose, selectedOption, onSearch, searchType, searchTerms, updateSearchValue],
);
const closeHandler = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onClose();
},
[onClose],
);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [searchType]);
return (
<SearchBoxContainer
ref={ref}
id='searchBox'
aria-label={intl.formatMessage({
id: 'search_bar.search',
defaultMessage: 'Search',
})}
aria-describedby='searchHints'
role='searchbox'
>
<CloseIcon
className='btn btn-icon btn-m'
onClick={closeHandler}
>
<i className='icon icon-close'/>
</CloseIcon>
<SearchTypeSelector
searchType={searchType}
setSearchType={setSearchType}
/>
<SearchInput
ref={inputRef}
searchTerms={searchTerms}
searchType={searchType}
setSearchTerms={setSearchTerms}
onKeyDown={handleKeyDown}
focus={focus}
/>
<SearchSuggestions
searchType={searchType}
searchTerms={searchTerms}
suggestionsHeader={suggestionsHeader}
providerResults={providerResults}
selectedOption={selectedOption}
setSelectedOption={setSelectedOption}
onSearch={onSearch}
onSuggestionSelected={updateSearchValue}
/>
<SearchBoxHints
searchTerms={searchTerms}
setSearchTerms={addSearchHint}
searchType={searchType}
providerResults={providerResults}
selectedOption={selectedOption}
focus={focus}
/>
</SearchBoxContainer>
);
},
);
export default SearchBox;

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

@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {
renderWithContext,
screen,
fireEvent,
} from 'tests/react_testing_utils';
import SearchBoxHints from './search_box_hints';
const TestProviderResultComponent = ({item, term, matchedPretext, isSelection, onClick, onMouseMove}: any) => {
return (
<div
onClick={() => onClick(item.username, matchedPretext)}
onMouseMove={() => onMouseMove()}
className={isSelection ? 'selected' : ''}
>
<span>{item.username}</span>
<span>{term}</span>
<span>{matchedPretext}</span>
</div>
);
};
const TestPluginProviderComponent = ({searchTerms, onChangeSearch}: any) => {
return (
<div>
<span>{'Plugin suggestion'}</span>
<span>{searchTerms}</span>
<span onClick={() => onChangeSearch('test', 't')}>{'onChangeSearch'}</span>
</div>
);
};
describe('components/new_search/SearchBoxHints', () => {
const baseProps = {
searchType: 'messages',
searchTerms: '',
setSearchTerms: jest.fn(),
focus: jest.fn(),
selectedOption: -1,
providerResults: {
matchedPretext: '',
terms: ['user1', 'user2'],
items: [{username: 'test-username1'}, {username: 'test-username2'}],
component: TestProviderResultComponent,
},
};
test('should show the hints for messages', () => {
renderWithContext(<SearchBoxHints {...baseProps}/>);
expect(screen.getByText('From:')).toBeInTheDocument();
});
test('should change the search term and focus on click', () => {
renderWithContext(<SearchBoxHints {...baseProps}/>);
fireEvent.click(screen.getByText('From:'));
expect(baseProps.setSearchTerms).toHaveBeenCalledWith('From:');
expect(baseProps.focus).toHaveBeenCalledWith(5);
});
test('should set the selected option if it is passed from the parent', () => {
const props = {...baseProps, selectedOption: 1};
renderWithContext(<SearchBoxHints {...props}/>);
expect(screen.getByText('Press Enter to select')).toBeInTheDocument();
});
test('should not show the plugin suggestions without license', () => {
const props = {...baseProps, searchType: 'test-id', searchTerms: 'test-search-terms'};
renderWithContext(
<SearchBoxHints {...props}/>,
{
plugins: {components: {SearchHints: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'false'}}},
},
);
expect(screen.queryByText('Plugin suggestion')).not.toBeInTheDocument();
expect(screen.queryByText('test-search-terms')).not.toBeInTheDocument();
});
test('should show the plugin suggestions', () => {
const props = {...baseProps, searchType: 'test-id', searchTerms: 'test-search-terms'};
renderWithContext(
<SearchBoxHints {...props}/>,
{
plugins: {components: {SearchHints: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'true'}}},
},
);
expect(screen.getByText('Plugin suggestion')).toBeInTheDocument();
expect(screen.getByText('test-search-terms')).toBeInTheDocument();
});
test('should on search change change the search term and focus', () => {
const props = {...baseProps, searchType: 'test-id', searchTerms: 'something from:t'};
renderWithContext(
<SearchBoxHints {...props}/>,
{
plugins: {components: {SearchHints: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'true'}}},
},
);
screen.getByText('onChangeSearch').click();
expect(baseProps.setSearchTerms).toHaveBeenCalledWith('something from:test ');
expect(baseProps.focus).toHaveBeenCalledWith(20);
});
});

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

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useSelector} from 'react-redux';
import {getSearchBoxHints} from 'selectors/plugins';
import type {ProviderResult} from 'components/suggestion/provider';
import SearchDateSuggestion from 'components/suggestion/search_date_suggestion';
import ErrorBoundary from 'plugins/pluggable/error_boundary';
import SearchHints from './search_hint';
type Props = {
searchTerms: string;
setSearchTerms: (searchTerms: string) => void;
searchType: string;
selectedOption: number;
providerResults: ProviderResult<unknown>|null;
focus: (pos: number) => void;
}
const SearchBoxHints = ({searchTerms, setSearchTerms, searchType, providerResults, selectedOption, focus}: Props) => {
const filterSelectedCallback = useCallback((filter: string) => {
if (searchTerms.endsWith(' ') || searchTerms.length === 0) {
setSearchTerms(searchTerms + filter);
focus(searchTerms.length + filter.length);
} else {
setSearchTerms(searchTerms + ' ' + filter);
focus(searchTerms.length + filter.length + 1);
}
}, [searchTerms, setSearchTerms, focus]);
const searchChangeCallback = useCallback((value: string, matchedPretext: string) => {
const changedValue = value.replace(matchedPretext, '');
setSearchTerms(searchTerms + changedValue + ' ');
focus(searchTerms.length + changedValue.length + 1);
}, [searchTerms, setSearchTerms, focus]);
const searchPluginHints = useSelector(getSearchBoxHints);
if (searchType === '' || searchType === 'messages' || searchType === 'files') {
return (
<SearchHints
onSelectFilter={filterSelectedCallback}
searchType={searchType}
searchTerms={searchTerms}
hasSelectedOption={Boolean(providerResults && providerResults.items.length > 0 && selectedOption !== -1)}
isDate={providerResults?.component === SearchDateSuggestion}
/>
);
}
const pluginComponentInfo = searchPluginHints.find(({pluginId}: any) => {
if (searchType === pluginId) {
return true;
}
return false;
});
if (!pluginComponentInfo) {
return null;
}
const Component: any = pluginComponentInfo.component;
return (
<ErrorBoundary>
<Component
key={pluginComponentInfo.pluginId}
onChangeSearch={searchChangeCallback}
searchTerms={searchTerms}
/>
</ErrorBoundary>
);
};
export default SearchBoxHints;

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

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {
renderWithContext,
screen,
fireEvent,
} from 'tests/react_testing_utils';
import SearchBoxInput from './search_box_input';
describe('components/new_search/SearchBoxInput', () => {
const baseProps = {
searchType: 'messages',
searchTerms: '',
setSearchTerms: jest.fn(),
onKeyDown: jest.fn(),
focus: jest.fn(),
};
test('should show the right placeholder for messages', () => {
renderWithContext(<SearchBoxInput {...baseProps}/>);
expect(screen.getByPlaceholderText('Search messages')).toBeInTheDocument();
});
test('should show the right placeholder for files', () => {
const props = {...baseProps, searchType: 'files'};
renderWithContext(<SearchBoxInput {...props}/>);
expect(screen.getByPlaceholderText('Search files')).toBeInTheDocument();
});
test('should show the right placeholder for plugins', () => {
const props = {...baseProps, searchType: 'plugin-id'};
renderWithContext(<SearchBoxInput {...props}/>);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});
test('should show the right value when the searchTerms are set', () => {
const props = {...baseProps, searchTerms: 'test-value'};
renderWithContext(<SearchBoxInput {...props}/>);
expect(screen.getByPlaceholderText('Search messages')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Search messages')).toHaveValue('test-value');
});
test('should call on key down when there is a key down event on the input field', () => {
const props = {...baseProps};
renderWithContext(<SearchBoxInput {...props}/>);
fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Enter'});
expect(props.onKeyDown).toHaveBeenCalledTimes(1);
});
test('should call on key down when there is a key down event on the input field', () => {
const props = {...baseProps};
renderWithContext(<SearchBoxInput {...props}/>);
fireEvent.keyDown(screen.getByPlaceholderText('Search messages'), {key: 'Enter'});
expect(props.onKeyDown).toHaveBeenCalledTimes(1);
});
test('should update the search term on change', () => {
const props = {...baseProps};
renderWithContext(<SearchBoxInput {...props}/>);
fireEvent.change(screen.getByPlaceholderText('Search messages'), {target: {value: 'new-value'}});
expect(props.setSearchTerms).toHaveBeenCalledWith('new-value');
});
test('should clear the terms and focus in the input whenever click the clear button', () => {
const props = {...baseProps, searchTerms: 'test-value'};
renderWithContext(<SearchBoxInput {...props}/>);
fireEvent.click(screen.getByText('Clear'));
expect(props.setSearchTerms).toHaveBeenCalledWith('');
expect(props.focus).toHaveBeenCalledWith(0);
});
});

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

@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {forwardRef, useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import styled from 'styled-components';
import {
updateSearchTerms,
updateSearchType,
} from 'actions/views/rhs';
import QuickInput from 'components/quick_input';
const SearchInputContainer = styled.div`
position: relative;
display: flex;
align-items: center;
i {
color: var(--center-channel-color-56);
display: flex;
align-items: center;
&.icon-close {
postion: absolute;
right: 10px;
}
&.icon-magnify {
position: absolute;
left: 20px;
top: 21px;
font-size: 24px;
}
}
.input-wrapper {
flex-grow: 1;
}
&& input {
padding: 20px 100px 20px 58px;
height: auto;
border-radius: 0;
border: none;
border-bottom: var(--border-default);
font-size: 20px;
line-height: 28px;
font-family: Metropolis, sans-serif;
:focus {
border: none;
border-bottom: var(--border-default);
box-shadow: none;
}
::placeholder {
color: rgba(var(--center-channel-color-rgb), 0.75);
}
}
`;
const ClearButton = styled.button`
display: flex;
position: absolute;
right: 12px;
background: none;
color: rgba(var(--center-channel-color-rgb), 0.75);
&:hover{
color: rgba(var(--center-channel-color-rgb), 0.88);
background: rgba(var(--center-channel-color-rgb), 0.08);
}
`;
type Props = {
searchTerms: string;
searchType: string;
setSearchTerms: (searchTerms: string) => void;
onKeyDown: (e: React.KeyboardEvent<Element>) => void;
focus: (newPosition: number) => void;
}
const SearchInput = ({searchTerms, searchType, setSearchTerms, onKeyDown, focus}: Props, inputRef: React.Ref<HTMLInputElement>) => {
const intl = useIntl();
let searchPlaceholder = intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'});
if (searchType === 'messages' || searchType === '') {
searchPlaceholder = intl.formatMessage({id: 'search_bar.search_messages', defaultMessage: 'Search messages'});
}
if (searchType === 'files') {
searchPlaceholder = intl.formatMessage({id: 'search_bar.search_files', defaultMessage: 'Search files'});
}
const dispatch = useDispatch();
const inputChangeCallback = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerms(e.target.value);
}, [setSearchTerms]);
const clearSearch = useCallback(() => {
setSearchTerms('');
dispatch(updateSearchTerms(''));
dispatch(updateSearchType('messages'));
focus(0);
}, [focus, setSearchTerms]);
return (
<SearchInputContainer>
<i className='icon icon-magnify'/>
<QuickInput
ref={inputRef}
className={'search-bar form-control'}
aria-describedby={'searchbar-help-popup'}
aria-label={searchPlaceholder}
placeholder={searchPlaceholder}
value={searchTerms}
onChange={inputChangeCallback}
type='search'
delayInputUpdate={true}
clearable={true}
autoFocus={true}
onKeyDown={onKeyDown}
tabIndex={0}
/>
{searchTerms.length > 0 && (
<ClearButton
className='btn btn-sm input-clear-x'
onClick={clearSearch}
>
<i className='icon icon-close-circle'/>
<FormattedMessage
id='search_bar.clear'
defaultMessage='Clear'
/>
</ClearButton>
)}
</SearchInputContainer>
);
};
export default forwardRef<HTMLInputElement, Props>(SearchInput);

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

@@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {
renderWithContext,
screen,
fireEvent,
} from 'tests/react_testing_utils';
import SearchBoxSuggestions from './search_box_suggestions';
const TestProviderResultComponent = ({item, term, matchedPretext, isSelection, onClick, onMouseMove}: any) => {
return (
<div
onClick={() => onClick(item.username, matchedPretext)}
onMouseMove={() => onMouseMove()}
className={isSelection ? 'selected' : ''}
>
<span>{item.username}</span>
<span>{term}</span>
<span>{matchedPretext}</span>
</div>
);
};
const TestPluginProviderComponent = ({searchTerms, onChangeSearch, onRunSearch}: any) => {
return (
<div>
<span>{'Plugin suggestion'}</span>
<span>{searchTerms}</span>
<span onClick={() => onChangeSearch('test', 't')}>{'onChangeSearch'}</span>
<span onClick={() => onRunSearch(searchTerms)}>{'onRunSearch'}</span>
</div>
);
};
describe('components/new_search/SearchBoxSuggestions', () => {
const baseProps = {
searchType: 'messages',
searchTerms: '',
selectedOption: -1,
setSelectedOption: jest.fn(),
suggestionsHeader: <p>{'Test Header'}</p>,
providerResults: {
matchedPretext: '',
terms: ['user1', 'user2'],
items: [{username: 'test-username1'}, {username: 'test-username2'}],
component: TestProviderResultComponent,
},
onSearch: jest.fn(),
onSuggestionSelected: jest.fn(),
};
test('should show the suggestions and the suggestion header on messages', () => {
renderWithContext(<SearchBoxSuggestions {...baseProps}/>);
expect(screen.getByText('Test Header')).toBeInTheDocument();
expect(screen.getByText('test-username1')).toBeInTheDocument();
expect(screen.getByText('user1')).toBeInTheDocument();
expect(screen.getByText('test-username2')).toBeInTheDocument();
expect(screen.getByText('user2')).toBeInTheDocument();
});
test('should call the onSuggestionSelected on click', () => {
renderWithContext(<SearchBoxSuggestions {...baseProps}/>);
fireEvent.click(screen.getByText('test-username1'));
expect(baseProps.onSuggestionSelected).toHaveBeenCalledWith('test-username1', '');
});
test('should call the onSuggestionSelected on click with matchedPretext and previous text', () => {
const props = {...baseProps, searchTerms: 'something from:test-user', providerResults: {...baseProps.providerResults, matchedPretext: 'test-user'}};
renderWithContext(<SearchBoxSuggestions {...props}/>);
fireEvent.click(screen.getByText('test-username1'));
expect(baseProps.onSuggestionSelected).toHaveBeenCalledWith('test-username1', 'test-user');
});
test('should change the selected option on mousemove', () => {
const props = {...baseProps};
renderWithContext(<SearchBoxSuggestions {...props}/>);
fireEvent.mouseMove(screen.getByText('test-username2'));
expect(baseProps.setSelectedOption).toHaveBeenCalledWith(1);
fireEvent.mouseMove(screen.getByText('test-username1'));
expect(baseProps.setSelectedOption).toHaveBeenCalledWith(0);
});
test('should not show the plugin suggestions without license', () => {
const props = {...baseProps, searchType: 'test-id', searchTerms: 'test-search-terms'};
renderWithContext(
<SearchBoxSuggestions {...props}/>,
{
plugins: {components: {SearchSuggestions: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'false'}}},
},
);
expect(screen.queryByText('Plugin suggestion')).not.toBeInTheDocument();
expect(screen.queryByText('test-search-terms')).not.toBeInTheDocument();
});
test('should show the plugin suggestions', () => {
const props = {...baseProps, searchType: 'test-id', searchTerms: 'test-search-terms'};
renderWithContext(
<SearchBoxSuggestions {...props}/>,
{
plugins: {components: {SearchSuggestions: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'true'}}},
},
);
expect(screen.getByText('Plugin suggestion')).toBeInTheDocument();
expect(screen.getByText('test-search-terms')).toBeInTheDocument();
});
test('should call the onSuggestionSelected on plugin search change', () => {
const props = {...baseProps, searchType: 'test-id', searchTerms: 'something from:t'};
renderWithContext(
<SearchBoxSuggestions {...props}/>,
{
plugins: {components: {SearchSuggestions: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'true'}}},
},
);
screen.getByText('onChangeSearch').click();
expect(baseProps.onSuggestionSelected).toHaveBeenCalledWith('test', 't');
});
test('should run search whenver onRunSearch is executed', () => {
const props = {...baseProps, searchType: 'test-id', searchTerms: 'something from:t'};
renderWithContext(
<SearchBoxSuggestions {...props}/>,
{
plugins: {components: {SearchSuggestions: [{component: TestPluginProviderComponent as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'true'}}},
},
);
screen.getByText('onRunSearch').click();
expect(baseProps.onSearch).toHaveBeenCalledWith('test-id', 'something from:t');
});
});

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

@@ -0,0 +1,142 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useSelector} from 'react-redux';
import styled from 'styled-components';
import type {UserProfile} from '@mattermost/types/users';
import {getSearchPluginSuggestions} from 'selectors/plugins';
import type {ProviderResult} from 'components/suggestion/provider';
import type {SuggestionProps} from 'components/suggestion/suggestion';
import ErrorBoundary from 'plugins/pluggable/error_boundary';
const SuggestionsHeader = styled.div`
margin-top: 16px;
padding: 8px 24px;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 12px;
line-height: 16px;
font-weight: 600;
text-transform: uppercase;
`;
const SuggestionsBody = styled.div`
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
padding-bottom: 16px;
`;
type Props = {
searchType: string;
searchTerms: string;
selectedOption: number;
setSelectedOption: (idx: number) => void;
suggestionsHeader: React.ReactNode;
providerResults: ProviderResult<unknown> | null;
onSearch: (searchType: string, searchTerms: string) => void;
onSuggestionSelected: (value: string, matchedPretext: string) => void;
}
const SearchSuggestions = ({searchType, searchTerms, suggestionsHeader, providerResults, selectedOption, setSelectedOption, onSearch, onSuggestionSelected}: Props) => {
const runSearch = useCallback((searchTerms: string) => {
onSearch(searchType, searchTerms);
}, [onSearch, searchType]);
const searchPluginSuggestions = useSelector(getSearchPluginSuggestions);
const generateLabel = (item: any) => {
let label = '';
if (item.username) {
label = item.username;
if ((item.first_name || item.last_name) && item.nickname) {
label += ` ${item.first_name} ${item.last_name} ${item.nickname}`;
} else if (item.nickname) {
label += ` ${item.nickname}`;
} else if (item.first_name || item.last_name) {
label += ` ${item.first_name} ${item.last_name}`;
}
} else if (item.type === 'D' || item.type === 'G') {
label = item.display_name;
} else if (item.type === 'P' || item.type === 'O') {
label = item.name;
} else if (item.emoji) {
label = item.name;
}
if (label) {
label = label.toLowerCase();
}
return label;
};
if (searchType === '' || searchType === 'messages' || searchType === 'files') {
if (!providerResults) {
return null;
}
return (
<SuggestionsBody>
<SuggestionsHeader>{suggestionsHeader}</SuggestionsHeader>
{providerResults.component && providerResults.items[selectedOption] && (
<div
aria-live='polite'
role='alert'
className='sr-only'
key={providerResults.terms[selectedOption]}
>
{generateLabel(providerResults.items[selectedOption])}
</div>
)}
{providerResults.items.map((item, idx) => {
if (!providerResults.component) {
return null;
}
const Component = providerResults.component as React.ComponentType<SuggestionProps<any>>;
return (
<Component
key={providerResults.terms[idx]}
item={item as UserProfile}
term={providerResults.terms[idx]}
matchedPretext={providerResults.matchedPretext}
isSelection={idx === selectedOption}
onClick={onSuggestionSelected}
onMouseMove={() => {
setSelectedOption(idx);
}}
/>
);
})}
</SuggestionsBody>
);
}
const pluginComponentInfo = searchPluginSuggestions.find(({pluginId}: any) => {
if (searchType === pluginId) {
return true;
}
return false;
});
if (!pluginComponentInfo) {
return null;
}
const Component: any = pluginComponentInfo.component;
return (
<ErrorBoundary>
<Component
key={pluginComponentInfo.pluginId}
searchTerms={searchTerms}
onChangeSearch={onSuggestionSelected}
onRunSearch={runSearch}
/>
</ErrorBoundary>
);
};
export default SearchSuggestions;

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

@@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {
renderWithContext,
screen,
} from 'tests/react_testing_utils';
import SearchBoxTypeSelector from './search_box_type_selector';
describe('components/new_search/SearchBoxTypeSelector', () => {
const baseProps = {
setSearchType: jest.fn(),
searchType: 'messages',
};
test('should have the built-in type options', () => {
renderWithContext(<SearchBoxTypeSelector {...baseProps}/>);
expect(screen.getByText('Files')).toBeInTheDocument();
expect(screen.getByText('Messages')).toBeInTheDocument();
});
test('on option clicked should call the setSearchType', () => {
renderWithContext(<SearchBoxTypeSelector {...baseProps}/>);
screen.getByText('Messages').click();
expect(baseProps.setSearchType).toHaveBeenCalledWith('messages');
});
test('should not have the plugin options without license', () => {
renderWithContext(
<SearchBoxTypeSelector {...baseProps}/>,
{
plugins: {components: {SearchButtons: [{component: (() => <pre>{'test'}</pre>) as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'false'}}},
},
);
expect(screen.queryByText('test')).not.toBeInTheDocument();
});
test('should have the plugin options', () => {
renderWithContext(
<SearchBoxTypeSelector {...baseProps}/>,
{
plugins: {components: {SearchButtons: [{component: (() => <pre>{'test'}</pre>) as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'true'}}},
},
);
expect(screen.getByText('test')).toBeInTheDocument();
});
test('on plugin option clicked should call the setSearchType', () => {
renderWithContext(
<SearchBoxTypeSelector {...baseProps}/>,
{
plugins: {components: {SearchButtons: [{component: (() => <pre>{'test'}</pre>) as React.ComponentType, pluginId: 'test-id'}]}},
entities: {general: {license: {IsLicensed: 'true'}}},
},
);
screen.getByText('test').click();
expect(baseProps.setSearchType).toHaveBeenCalledWith('test-id');
});
});

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

@@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import styled from 'styled-components';
import {getSearchButtons} from 'selectors/plugins';
import ErrorBoundary from 'plugins/pluggable/error_boundary';
const SearchTypeSelectorContainer = styled.div`
margin: 20px 20px 0px 20px;
display: flex;
align-items: center;
padding: 3px;
background-color: var(--center-channel-bg);
border-radius: var(--radius-m);
border: var(--border-default);
width: fit-content;
gap: 3px;
`;
type SearchTypeItemProps = {
selected: boolean;
};
const SearchTypeItem = styled.button<SearchTypeItemProps>`
display: flex;
cursor: pointer;
padding: 4px 10px;
background-color: ${(props) => (props.selected ? 'rgba(var(--button-bg-rgb), 0.08)' : 'transparent')};
color: ${(props) => (props.selected ? 'var(--button-bg)' : 'rgba(var(--center-channel-color-rgb), 0.75)')};
border-radius: 4px;
font-size: 12px;
line-height: 16px;
font-weight: 600;
border: none;
&:hover {
color: rgba(var(--center-channel-color-rgb), 0.88);
background: rgba(var(--center-channel-color-rgb), 0.08);
}
`;
type Props = {
searchType: string;
setSearchType: (searchType: string) => void;
}
const SearchTypeSelector = ({searchType, setSearchType}: Props) => {
const setMessagesSearchType = useCallback(() => setSearchType('messages'), [setSearchType]);
const setFilesSearchType = useCallback(() => setSearchType('files'), [setSearchType]);
const searchPluginButtons = useSelector(getSearchButtons);
return (
<SearchTypeSelectorContainer>
<SearchTypeItem
selected={searchType === 'messages'}
onClick={setMessagesSearchType}
>
<FormattedMessage
id='search_bar.usage.search_type_messages'
defaultMessage='Messages'
/>
</SearchTypeItem>
<SearchTypeItem
selected={searchType === 'files'}
onClick={setFilesSearchType}
>
<FormattedMessage
id='search_bar.usage.search_type_files'
defaultMessage='Files'
/>
</SearchTypeItem>
{searchPluginButtons.map(({component, pluginId}: any) => {
const Component = component as React.ComponentType;
return (
<SearchTypeItem
key={pluginId}
selected={searchType === pluginId}
onClick={() => setSearchType(pluginId)}
>
<ErrorBoundary>
<Component/>
</ErrorBoundary>
</SearchTypeItem>
);
})}
</SearchTypeSelectorContainer>
);
};
export default SearchTypeSelector;

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

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {
renderWithContext,
screen,
} from 'tests/react_testing_utils';
import SearchHint from './search_hint';
describe('components/new_search/SearchHint', () => {
const baseProps = {
onSelectFilter: jest.fn(),
searchType: 'messages',
searchTerms: '',
hasSelectedOption: false,
isDate: false,
};
test('should have the right hint options on search messages empty string', () => {
renderWithContext(<SearchHint {...baseProps}/>);
expect(screen.getByText('From:')).toBeInTheDocument();
expect(screen.queryByText('Ext:')).not.toBeInTheDocument();
});
test('should suggest to hit enter to search on search messages with not empty string not ended with space', () => {
const props = {...baseProps, searchTerms: 'test'};
renderWithContext(<SearchHint {...props}/>);
expect(screen.getByText('Press Enter to search')).toBeInTheDocument();
});
test('should have the right hint options on search messages with not empty string ended with space', () => {
const props = {...baseProps, searchTerms: 'test '};
renderWithContext(<SearchHint {...props}/>);
expect(screen.getByText('From:')).toBeInTheDocument();
expect(screen.queryByText('Ext:')).not.toBeInTheDocument();
});
test('should have the right hint options on search files empty string', () => {
const props = {...baseProps, searchType: 'files'};
renderWithContext(<SearchHint {...props}/>);
expect(screen.getByText('From:')).toBeInTheDocument();
expect(screen.queryByText('Ext:')).toBeInTheDocument();
});
test('should suggest to hit enter to search on search files with not empty string not ended with space', () => {
const props = {...baseProps, searchType: 'files', searchTerms: 'test'};
renderWithContext(<SearchHint {...props}/>);
expect(screen.getByText('Press Enter to search')).toBeInTheDocument();
});
test('should have the right hint options on search files with not empty string ended with space', () => {
const props = {...baseProps, searchType: 'files', searchTerms: 'test '};
renderWithContext(<SearchHint {...props}/>);
expect(screen.getByText('From:')).toBeInTheDocument();
expect(screen.getByText('Ext:')).toBeInTheDocument();
});
test('should be empty on search if is date', () => {
const props = {...baseProps, isDate: true};
const {asFragment} = renderWithContext(<SearchHint {...props}/>);
expect(asFragment()).toMatchInlineSnapshot('<DocumentFragment />');
});
test('should suggest to hit enter to select on search if has selected option', () => {
const props = {...baseProps, hasSelectedOption: true};
renderWithContext(<SearchHint {...props}/>);
expect(screen.getByText('Press Enter to select')).toBeInTheDocument();
});
test('on filter clicked should call the onSelectFilter', () => {
renderWithContext(<SearchHint {...baseProps}/>);
screen.getByText('From:').click();
expect(baseProps.onSelectFilter).toHaveBeenCalledWith('From:');
});
});

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

@@ -0,0 +1,101 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import styled from 'styled-components';
import {searchHintOptions, searchFilesHintOptions} from 'utils/constants';
type Props = {
onSelectFilter: (filter: string) => void;
searchType: string;
searchTerms: string;
hasSelectedOption: boolean;
isDate: boolean;
}
const SearchHintsContainer = styled.div`
display: flex;
padding: 20px 24px;
color: rgba(var(--center-channel-color-rgb), 0.75);
i {
margin-right: 8px;
color: var(--center-channel-color-56);
}
`;
const SearchFilter = styled.button`
display: flex;
padding: 4px 10px;
color: var(--center-channel-color);
background: rgba(var(--center-channel-color-rgb), 0.08);
border-radius: var(--radius-l);
border: none;
font-size: 10px;
font-weight: 600;
line-height: 12px;
margin-left: 8px;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.16);
}
`;
const SearchHints = ({onSelectFilter, searchType, searchTerms, hasSelectedOption, isDate}: 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 (isDate) {
return <></>;
}
if (hasSelectedOption) {
return (
<SearchHintsContainer id='searchHints'>
<i className='icon icon-keyboard-return'/>
<FormattedMessage
id='search_hint.enter_to_select'
defaultMessage='Press Enter to select'
/>
</SearchHintsContainer>
);
}
if (searchTerms.length > 0 && searchTerms[searchTerms.length - 1] !== ' ') {
return (
<SearchHintsContainer id='searchHints'>
<i className='icon icon-keyboard-return'/>
<FormattedMessage
id='search_hint.enter_to_search'
defaultMessage='Press Enter to search'
/>
</SearchHintsContainer>
);
}
return (
<SearchHintsContainer id='searchHints'>
<i className='icon icon-lightbulb-outline'/>
<FormattedMessage
id='search_hint.filter'
defaultMessage='Filter your search with:'
/>
{filters.map((filter) => (
<SearchFilter
key={filter.searchTerm}
onClick={() => onSelectFilter(filter.searchTerm)}
>
<span title={intl.formatMessage(filter.message)}>
{filter.searchTerm}
</span>
</SearchFilter>
))}
</SearchHintsContainer>
);
};
export default SearchHints;

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

@@ -65,6 +65,11 @@ export type Props = {
*/
onKeyUp?: (event: React.KeyboardEvent) => void;
/**
* Callback to handle the key down of the input
*/
onKeyDown?: (event: React.KeyboardEvent) => void;
/**
* When true, and an onClear callback is defined, show an X on the input field even if
* the input is empty.
@@ -80,6 +85,7 @@ export type Props = {
type?: string;
id?: string;
onInput?: (e?: React.FormEvent<HTMLInputElement>) => void;
tabIndex?: number;
}
// A component that can be used to make controlled inputs that function properly in certain

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

@@ -15,6 +15,7 @@ import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {
getSearchResultsTerms,
getSearchResultsType,
getIsSearchingTerm,
getIsSearchingFlaggedPost,
getIsSearchingPinnedPost,
@@ -76,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 = (getCurrentSearchForCurrentTeam(state) as unknown as Record<string, any>) || {};
const currentTeamName = getCurrentTeam(state)?.name ?? '';
return {
@@ -84,6 +85,7 @@ function makeMapStateToProps() {
fileResults: files,
matches: getSearchMatches(state),
searchTerms: getSearchResultsTerms(state),
searchSelectedType: getSearchResultsType(state),
isSearchingTerm: getIsSearchingTerm(state),
isSearchingFlaggedPost: getIsSearchingFlaggedPost(state),
isSearchingPinnedPost: getIsSearchingPinnedPost(state),

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

@@ -92,6 +92,10 @@ const SearchResults: React.FC<Props> = (props: Props): JSX.Element => {
scrollbars.current?.scrollToTop();
}, [props.searchTerms]);
useEffect(() => {
setSearchType(props.searchSelectedType);
}, [props.searchSelectedType]);
useEffect(() => {
// reset search type when switching views
setSearchType(props.searchType);

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

@@ -29,24 +29,24 @@ export type OwnProps = {
setSearchType: (searchType: SearchType) => void;
searchFilterType: SearchFilterType;
setSearchFilterType: (filterType: SearchFilterType) => void;
}
};
export type StateProps = {
results: Post[];
fileResults: FileInfo[];
matches: Record<string, string[]>;
searchTerms: string;
searchSelectedType: string;
isSearchingTerm: boolean;
isSearchingFlaggedPost: boolean;
isSearchingPinnedPost: boolean;
isSearchGettingMore: boolean;
isSearchAtEnd: boolean;
isSearchFilesAtEnd: boolean;
}
};
export type IntlProps = {
intl: IntlShape;
}
};
export type Props = OwnProps & StateProps & IntlProps;

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

@@ -17,7 +17,7 @@ import type {ResultsCallback} from './provider';
import {SuggestionContainer} from './suggestion';
import type {SuggestionProps} from './suggestion';
const SearchUserSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<UserProfile>>((props, ref) => {
export const SearchUserSuggestion = React.forwardRef<HTMLDivElement, SuggestionProps<UserProfile>>((props, ref) => {
const {item} = props;
const username = item.username;

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

@@ -674,6 +674,7 @@ exports[`components/threading/channel_threads/thread_footer should report total
day="numeric"
dispatch={[Function]}
hourCycle="h12"
timeZone="UTC"
units={
Array [
"now",
@@ -736,6 +737,7 @@ exports[`components/threading/channel_threads/thread_footer should report total
relNearest={1}
second="numeric"
style="long"
timeZone="UTC"
timeZoneName="short"
units={
Array [
@@ -1520,6 +1522,7 @@ exports[`components/threading/channel_threads/thread_footer should show unread i
day="numeric"
dispatch={[Function]}
hourCycle="h12"
timeZone="UTC"
units={
Array [
"now",
@@ -1582,6 +1585,7 @@ exports[`components/threading/channel_threads/thread_footer should show unread i
relNearest={1}
second="numeric"
style="long"
timeZone="UTC"
timeZoneName="short"
units={
Array [

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

@@ -3742,6 +3742,17 @@
"file_search_result_item.download": "Download",
"file_search_result_item.more_actions": "More Actions",
"file_search_result_item.open_in_channel": "Open in channel",
"file_type.audio": "Audio",
"file_type.code": "Code file",
"file_type.image": "Image",
"file_type.patch": "Patch file",
"file_type.pdf": "Acrobat",
"file_type.presentation": "Powerpoint Presentation",
"file_type.spreadsheet": "Excel spreadsheet",
"file_type.svg": "Vector graphics",
"file_type.text": "Text file",
"file_type.video": "Video",
"file_type.word": "Word Document",
"file_upload.disabled": "File attachments are disabled.",
"file_upload.drag_folder": "This attachment cannot be uploaded.",
"file_upload.fileAbove": "File above {max}MB could not be uploaded: {filename}",
@@ -4810,9 +4821,14 @@
"saveChangesPanel.save": "Save",
"saveChangesPanel.saved": "Settings saved",
"saveChangesPanel.tryAgain": "Try again",
"search_bar.channels": "Channels",
"search_bar.clear": "Clear",
"search_bar.file_types": "File types",
"search_bar.files_tab": "Files",
"search_bar.messages_tab": "Messages",
"search_bar.search": "Search",
"search_bar.search_files": "Search files",
"search_bar.search_messages": "Search messages",
"search_bar.search_types.files": "FILES",
"search_bar.search_types.messages": "MESSAGES",
"search_bar.searchGroupMembers": "Search group members",
@@ -4823,6 +4839,7 @@
"search_bar.usage.title": "Search options",
"search_bar.usage.title_files": "File search options",
"search_bar.usage.title_messages": "Message search options",
"search_bar.users": "Users",
"search_files_list_option.after": "Files after a date",
"search_files_list_option.before": "Files before a date",
"search_files_list_option.exclude": "Exclude search terms",
@@ -4840,6 +4857,9 @@
"search_header.title2": "Recent Mentions",
"search_header.title3": "Saved messages",
"search_header.title5": "Extra Information",
"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_item.channelArchived": "Archived",
"search_item.direct": "Direct Message (with {username})",
"search_item.file_tag.direct_message": "Direct Message",

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

@@ -21,9 +21,9 @@ export function getUserCurrentTimezone(userTimezone?: UserTimezone): string {
}
if (useAutomatic) {
return automaticTimezone;
return automaticTimezone || 'UTC';
}
return manualTimezone;
return manualTimezone || 'UTC';
}
export function getTimezoneRegion(timezone: string): string {

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

@@ -126,6 +126,25 @@ export default class PluginRegistry {
return dispatchPluginComponentAction('PostMessageAttachment', this.id, component);
});
// Register components for search.
// Accepts React components. Returns a unique identifier.
registerSearchComponents = ({buttonComponent, suggestionsComponent, hintsComponent, action}: any) => {
const id = generateId();
store.dispatch({
type: ActionTypes.RECEIVED_PLUGIN_COMPONENT,
name: 'SearchButtons',
data: {
id,
pluginId: this.id,
component: buttonComponent,
action,
},
});
dispatchPluginComponentAction('SearchSuggestions', this.id, suggestionsComponent, id);
dispatchPluginComponentAction('SearchHints', this.id, hintsComponent, id);
return id;
};
// Register a component to show as a tooltip when a user hovers on a link in a post.
// Accepts a React component. Returns a unique identifier.
// The component will be passed the following props:

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

@@ -20,6 +20,7 @@ describe('Reducers.RHS', () => {
searchTerms: '',
searchType: '',
searchResultsTerms: '',
searchResultsType: '',
size: 'medium',
pluggableId: '',
isSearchingFlaggedPost: false,
@@ -617,6 +618,7 @@ describe('Reducers.RHS', () => {
searchTerms: 'user_id',
searchType: '',
searchResultsTerms: 'user id',
searchResultsType: '',
size: 'medium',
pluggableId: 'pluggable_id',
isSearchingFlaggedPost: true,

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

@@ -255,6 +255,18 @@ function searchResultsTerms(state = '', action: AnyAction) {
}
}
function searchResultsType(state = '', action: AnyAction) {
switch (action.type) {
case ActionTypes.UPDATE_RHS_SEARCH_RESULTS_TYPE:
return action.searchType;
case UserTypes.LOGOUT_SUCCESS:
return '';
default:
return state;
}
}
function isSearchingFlaggedPost(state = false, action: AnyAction) {
switch (action.type) {
case SearchTypes.SEARCH_FLAGGED_POSTS_REQUEST:
@@ -407,6 +419,7 @@ export default combineReducers({
searchTerms,
searchType,
searchResultsTerms,
searchResultsType,
size,
pluggableId,
isSearchingFlaggedPost,

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

@@ -6,6 +6,7 @@ import type {AppBinding} from '@mattermost/types/apps';
import {Preferences} from 'mattermost-redux/constants';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {appBarEnabled, getAppBarAppBindings} from 'mattermost-redux/selectors/entities/apps';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {get} from 'mattermost-redux/selectors/entities/preferences';
import {createShallowSelector} from 'mattermost-redux/utils/helpers';
@@ -108,3 +109,39 @@ export function showNewChannelWithBoardPulsatingDot(state: GlobalState): boolean
const showPulsatingDot = pulsatingDotState !== '' && JSON.parse(pulsatingDotState)[Preferences.NEW_CHANNEL_WITH_BOARD_TOUR_SHOWED] === false;
return showPulsatingDot;
}
export const getSearchPluginSuggestions = createSelector(
'getSearchPluginSuggestions',
getLicense,
(state: GlobalState) => state.plugins.components.SearchSuggestions,
(license, components = []) => {
if (license.IsLicensed !== 'true') {
return [];
}
return components;
},
);
export const getSearchBoxHints = createSelector(
'getSearchBoxHints',
getLicense,
(state: GlobalState) => state.plugins.components.SearchHints,
(license, components = []) => {
if (license.IsLicensed !== 'true') {
return [];
}
return components;
},
);
export const getSearchButtons = createSelector(
'getSearchButtons',
getLicense,
(state: GlobalState) => state.plugins.components.SearchButtons,
(license, components = []) => {
if (license.IsLicensed !== 'true') {
return [];
}
return components;
},
);

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

@@ -81,7 +81,12 @@ export const getSelectedPost = createSelector(
getRealSelectedPost,
getSelectedChannelId,
getCurrentUserId,
(selectedPostId: Post['id'], selectedPost: Post, selectedPostChannelId: Channel['id'], currentUserId): Post|FakePost => {
(
selectedPostId: Post['id'],
selectedPost: Post,
selectedPostChannelId: Channel['id'],
currentUserId,
): Post | FakePost => {
if (selectedPost) {
return selectedPost;
}
@@ -91,7 +96,11 @@ export const getSelectedPost = createSelector(
id: selectedPostId,
exists: false,
type: PostTypes.FAKE_PARENT_DELETED as PostType,
message: localizeMessage({id: 'rhs_thread.rootPostDeletedMessage.body', defaultMessage: 'Part of this thread has been deleted due to a data retention policy. You can no longer reply to this thread.'}),
message: localizeMessage({
id: 'rhs_thread.rootPostDeletedMessage.body',
defaultMessage:
'Part of this thread has been deleted due to a data retention policy. You can no longer reply to this thread.',
}),
channel_id: selectedPostChannelId,
user_id: currentUserId,
reply_count: 0,
@@ -122,6 +131,10 @@ export function getSearchResultsTerms(state: GlobalState): string {
return state.views.rhs.searchResultsTerms;
}
export function getSearchResultsType(state: GlobalState): string {
return state.views.rhs.searchResultsType;
}
export function getIsSearchingTerm(state: GlobalState): boolean {
return state.entities.search.isSearchingTerm;
}
@@ -139,10 +152,26 @@ export function getIsSearchGettingMore(state: GlobalState): boolean {
}
export function makeGetDraft() {
let defaultDraft = {message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''};
let defaultDraft = {
message: '',
fileInfos: [],
uploadsInProgress: [],
createAt: 0,
updateAt: 0,
channelId: '',
rootId: '',
};
return (state: GlobalState, channelId: string, rootId = ''): PostDraft => {
if (defaultDraft.channelId !== channelId || defaultDraft.rootId !== rootId) {
defaultDraft = {message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId, rootId};
defaultDraft = {
message: '',
fileInfos: [],
uploadsInProgress: [],
createAt: 0,
updateAt: 0,
channelId,
rootId,
};
}
const prefix = rootId ? StoragePrefixes.COMMENT_DRAFT : StoragePrefixes.DRAFT;
const suffix = rootId || channelId;
@@ -170,7 +199,15 @@ export function makeGetDraft() {
}
export function makeGetChannelDraft() {
const defaultDraft = Object.freeze({message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''});
const defaultDraft = Object.freeze({
message: '',
fileInfos: [],
uploadsInProgress: [],
createAt: 0,
updateAt: 0,
channelId: '',
rootId: '',
});
const getDraft = makeGetGlobalItemWithDefault(defaultDraft);
return (state: GlobalState, channelId?: string): PostDraft => {
@@ -192,7 +229,15 @@ export function makeGetChannelDraft() {
}
export function getPostDraft(state: GlobalState, prefixId: string, suffixId: string): PostDraft {
const defaultDraft = {message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''};
const defaultDraft = {
message: '',
fileInfos: [],
uploadsInProgress: [],
createAt: 0,
updateAt: 0,
channelId: '',
rootId: '',
};
if (prefixId === StoragePrefixes.COMMENT_DRAFT) {
defaultDraft.rootId = suffixId;

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

@@ -34,6 +34,7 @@ export type RhsViewState = {
searchType: SearchType;
pluggableId: string;
searchResultsTerms: string;
searchResultsType: string;
isSearchingFlaggedPost: boolean;
isSearchingPinnedPost: boolean;
isSidebarOpen: boolean;

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

@@ -197,6 +197,7 @@ export const ActionTypes = keyMirror({
UPDATE_RHS_SEARCH_TERMS: null,
UPDATE_RHS_SEARCH_TYPE: null,
UPDATE_RHS_SEARCH_RESULTS_TERMS: null,
UPDATE_RHS_SEARCH_RESULTS_TYPE: null,
SET_RHS_SIZE: null,