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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
b7ccd745c8
Коммит
5f8bdba459
@@ -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');
|
||||
});
|
||||
});
|
||||
131
webapp/channels/src/components/new_search/extension_suggestions.tsx
Обычный файл
131
webapp/channels/src/components/new_search/extension_suggestions.tsx
Обычный файл
@@ -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';
|
||||
}
|
||||
}
|
||||
89
webapp/channels/src/components/new_search/hooks.tsx
Обычный файл
89
webapp/channels/src/components/new_search/hooks.tsx
Обычный файл
@@ -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;
|
||||
89
webapp/channels/src/components/new_search/new_search.test.tsx
Обычный файл
89
webapp/channels/src/components/new_search/new_search.test.tsx
Обычный файл
@@ -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();
|
||||
});
|
||||
});
|
||||
280
webapp/channels/src/components/new_search/new_search.tsx
Обычный файл
280
webapp/channels/src/components/new_search/new_search.tsx
Обычный файл
@@ -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;
|
||||
73
webapp/channels/src/components/new_search/search_box.test.tsx
Обычный файл
73
webapp/channels/src/components/new_search/search_box.test.tsx
Обычный файл
@@ -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');
|
||||
});
|
||||
});
|
||||
268
webapp/channels/src/components/new_search/search_box.tsx
Обычный файл
268
webapp/channels/src/components/new_search/search_box.tsx
Обычный файл
@@ -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;
|
||||
110
webapp/channels/src/components/new_search/search_box_hints.test.tsx
Обычный файл
110
webapp/channels/src/components/new_search/search_box_hints.test.tsx
Обычный файл
@@ -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);
|
||||
});
|
||||
});
|
||||
80
webapp/channels/src/components/new_search/search_box_hints.tsx
Обычный файл
80
webapp/channels/src/components/new_search/search_box_hints.tsx
Обычный файл
@@ -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);
|
||||
});
|
||||
});
|
||||
136
webapp/channels/src/components/new_search/search_box_input.tsx
Обычный файл
136
webapp/channels/src/components/new_search/search_box_input.tsx
Обычный файл
@@ -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');
|
||||
});
|
||||
});
|
||||
142
webapp/channels/src/components/new_search/search_box_suggestions.tsx
Обычный файл
142
webapp/channels/src/components/new_search/search_box_suggestions.tsx
Обычный файл
@@ -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;
|
||||
78
webapp/channels/src/components/new_search/search_hint.test.tsx
Обычный файл
78
webapp/channels/src/components/new_search/search_hint.test.tsx
Обычный файл
@@ -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:');
|
||||
});
|
||||
});
|
||||
101
webapp/channels/src/components/new_search/search_hint.tsx
Обычный файл
101
webapp/channels/src/components/new_search/search_hint.tsx
Обычный файл
@@ -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,
|
||||
|
||||
|
||||
Ссылка в новой задаче
Block a user