mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
* [navSearch] use the type's displayName in the `savedObjects` result provider * add unit tests * update documentation * adapt unit tests * address review comments and start to cleanup searchbar component * wrap onChange with useCallback * add unit tests for resultToOption Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
f166d18c72
commit
c8d3d52061
15 changed files with 632 additions and 297 deletions
|
@ -234,7 +234,7 @@ To get the most from the search feature, follow these tips:
|
|||
|Search by type
|
||||
|`type:dashboard`
|
||||
|
||||
Available types: `application`, `canvas-workpad`, `dashboard`, `index-pattern`, `lens`, `maps`, `query`, `search`, `visualization`
|
||||
Available types: `application`, `canvas-workpad`, `dashboard`, `data-view`, `lens`, `maps`, `query`, `search`, `visualization`
|
||||
|
||||
|Search by tag
|
||||
|`tag:mytagname` +
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { EuiCode, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
interface PopoverFooterProps {
|
||||
isMac: boolean;
|
||||
}
|
||||
|
||||
export const PopoverFooter: FC<PopoverFooterProps> = ({ isMac }) => {
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
justifyContent="spaceBetween"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
wrap
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiText color="subdued" size="xs">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.helpText.helpTextPrefix"
|
||||
defaultMessage="Filter by"
|
||||
/>
|
||||
|
||||
<EuiCode>type:</EuiCode>
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.helpText.helpTextConjunction"
|
||||
defaultMessage="or"
|
||||
/>
|
||||
|
||||
<EuiCode>tag:</EuiCode>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText color="subdued" size="xs">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.shortcutDescription.shortcutDetail"
|
||||
defaultMessage="{shortcutDescription} {commandDescription}"
|
||||
values={{
|
||||
shortcutDescription: (
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.shortcutDescription.shortcutInstructionDescription"
|
||||
defaultMessage="Shortcut"
|
||||
/>
|
||||
),
|
||||
commandDescription: (
|
||||
<EuiCode>
|
||||
{isMac ? (
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.shortcutDescription.macCommandDescription"
|
||||
defaultMessage="Command + /"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.shortcutDescription.windowsCommandDescription"
|
||||
defaultMessage="Control + /"
|
||||
/>
|
||||
)}
|
||||
</EuiCode>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { EuiImage, EuiSelectableMessage, EuiText } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
interface PopoverPlaceholderProps {
|
||||
darkMode: boolean;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export const PopoverPlaceholder: FC<PopoverPlaceholderProps> = ({ basePath, darkMode }) => {
|
||||
return (
|
||||
<EuiSelectableMessage style={{ minHeight: 300 }} data-test-subj="nav-search-no-results">
|
||||
<EuiImage
|
||||
alt={i18n.translate('xpack.globalSearchBar.searchBar.noResultsImageAlt', {
|
||||
defaultMessage: 'Illustration of black hole',
|
||||
})}
|
||||
size="fullWidth"
|
||||
url={`${basePath}illustration_product_no_search_results_${darkMode ? 'dark' : 'light'}.svg`}
|
||||
/>
|
||||
<EuiText size="m">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.noResultsHeading"
|
||||
defaultMessage="No results found"
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.noResults"
|
||||
defaultMessage="Try searching for applications, dashboards, visualizations, and more."
|
||||
/>
|
||||
</p>
|
||||
</EuiSelectableMessage>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiBadge } from '@elastic/eui';
|
||||
import type { Tag } from '../../../saved_objects_tagging/public';
|
||||
|
||||
const MAX_TAGS_TO_SHOW = 3;
|
||||
|
||||
const TagListWrapper: FC = ({ children }) => (
|
||||
<ul
|
||||
className="kbnSearchOption__tagsList"
|
||||
aria-label={i18n.translate('xpack.globalSearchBar.searchBar.optionTagListAriaLabel', {
|
||||
defaultMessage: 'Tags',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
|
||||
const buildListItem = ({ color, name, id }: Tag) => {
|
||||
return (
|
||||
<li className="kbnSearchOption__tagsListItem" key={id}>
|
||||
<EuiBadge color={color}>{name}</EuiBadge>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
interface ResultTagListProps {
|
||||
tags: Tag[];
|
||||
searchTagIds: string[];
|
||||
}
|
||||
|
||||
export const ResultTagList: FC<ResultTagListProps> = ({ tags, searchTagIds }) => {
|
||||
const showOverflow = tags.length > MAX_TAGS_TO_SHOW;
|
||||
|
||||
if (!showOverflow) {
|
||||
return <TagListWrapper>{tags.map(buildListItem)}</TagListWrapper>;
|
||||
}
|
||||
|
||||
// float searched tags to the start of the list, actual order doesn't matter
|
||||
tags.sort((a) => {
|
||||
if (searchTagIds.find((id) => id === a.id)) return -1;
|
||||
return 1;
|
||||
});
|
||||
|
||||
const overflowList = tags.splice(MAX_TAGS_TO_SHOW);
|
||||
const overflowMessage = i18n.translate('xpack.globalSearchBar.searchbar.overflowTagsAriaLabel', {
|
||||
defaultMessage: '{n} more {n, plural, one {tag} other {tags}}: {tags}',
|
||||
values: {
|
||||
n: overflowList.length,
|
||||
// @ts-ignore-line
|
||||
tags: overflowList.map(({ name }) => name),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TagListWrapper>
|
||||
{tags.map(buildListItem)}
|
||||
<li className="kbnSearchOption__tagsListItem" aria-label={overflowMessage}>
|
||||
<EuiBadge title={overflowMessage}>+{overflowList.length}</EuiBadge>
|
||||
</li>
|
||||
</TagListWrapper>
|
||||
);
|
||||
};
|
|
@ -5,48 +5,34 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiCode,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHeaderSectionItemButton,
|
||||
EuiIcon,
|
||||
EuiImage,
|
||||
EuiSelectableMessage,
|
||||
EuiSelectableTemplateSitewide,
|
||||
EuiSelectableTemplateSitewideOption,
|
||||
EuiText,
|
||||
EuiBadge,
|
||||
euiSelectableTemplateSitewideRenderOptions,
|
||||
} from '@elastic/eui';
|
||||
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { ApplicationStart } from 'kibana/public';
|
||||
import React, { ReactNode, useCallback, useRef, useState, useEffect } from 'react';
|
||||
import React, { FC, useCallback, useRef, useState, useEffect } from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import useEvent from 'react-use/lib/useEvent';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
import { Subscription } from 'rxjs';
|
||||
import {
|
||||
EuiHeaderSectionItemButton,
|
||||
EuiIcon,
|
||||
EuiSelectableTemplateSitewide,
|
||||
EuiSelectableTemplateSitewideOption,
|
||||
euiSelectableTemplateSitewideRenderOptions,
|
||||
} from '@elastic/eui';
|
||||
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { ApplicationStart } from 'kibana/public';
|
||||
import type {
|
||||
GlobalSearchPluginStart,
|
||||
GlobalSearchResult,
|
||||
GlobalSearchFindParams,
|
||||
} from '../../../global_search/public';
|
||||
import { SavedObjectTaggingPluginStart, Tag } from '../../../saved_objects_tagging/public';
|
||||
import type { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public';
|
||||
import { parseSearchParams } from '../search_syntax';
|
||||
import { getSuggestions, SearchSuggestion } from '../suggestions';
|
||||
import { resultToOption, suggestionToOption } from '../lib';
|
||||
import { PopoverFooter } from './popover_footer';
|
||||
import { PopoverPlaceholder } from './popover_placeholder';
|
||||
import './search_bar.scss';
|
||||
|
||||
interface Props {
|
||||
globalSearch: GlobalSearchPluginStart;
|
||||
navigateToUrl: ApplicationStart['navigateToUrl'];
|
||||
trackUiMetric: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
|
||||
taggingApi?: SavedObjectTaggingPluginStart;
|
||||
basePathUrl: string;
|
||||
darkMode: boolean;
|
||||
}
|
||||
|
||||
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
|
||||
|
||||
const setFieldValue = (field: HTMLInputElement, value: string) => {
|
||||
|
@ -60,7 +46,6 @@ const setFieldValue = (field: HTMLInputElement, value: string) => {
|
|||
|
||||
const clearField = (field: HTMLInputElement) => setFieldValue(field, '');
|
||||
|
||||
const cleanMeta = (str: string) => (str.charAt(0).toUpperCase() + str.slice(1)).replace(/-/g, ' ');
|
||||
const blurEvent = new FocusEvent('blur');
|
||||
|
||||
const sortByScore = (a: GlobalSearchResult, b: GlobalSearchResult): number => {
|
||||
|
@ -77,108 +62,23 @@ const sortByTitle = (a: GlobalSearchResult, b: GlobalSearchResult): number => {
|
|||
return 0;
|
||||
};
|
||||
|
||||
const TagListWrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ul
|
||||
className="kbnSearchOption__tagsList"
|
||||
aria-label={i18n.translate('xpack.globalSearchBar.searchBar.optionTagListAriaLabel', {
|
||||
defaultMessage: 'Tags',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
interface SearchBarProps {
|
||||
globalSearch: GlobalSearchPluginStart;
|
||||
navigateToUrl: ApplicationStart['navigateToUrl'];
|
||||
trackUiMetric: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
|
||||
taggingApi?: SavedObjectTaggingPluginStart;
|
||||
basePathUrl: string;
|
||||
darkMode: boolean;
|
||||
}
|
||||
|
||||
const buildListItem = ({ color, name, id }: Tag) => {
|
||||
return (
|
||||
<li className="kbnSearchOption__tagsListItem" key={id}>
|
||||
<EuiBadge color={color}>{name}</EuiBadge>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const tagList = (tags: Tag[], searchTagIds: string[]) => {
|
||||
const TAGS_TO_SHOW = 3;
|
||||
const showOverflow = tags.length > TAGS_TO_SHOW;
|
||||
|
||||
if (!showOverflow) return <TagListWrapper>{tags.map(buildListItem)}</TagListWrapper>;
|
||||
|
||||
// float searched tags to the start of the list, actual order doesn't matter
|
||||
tags.sort((a) => {
|
||||
if (searchTagIds.find((id) => id === a.id)) return -1;
|
||||
return 1;
|
||||
});
|
||||
|
||||
const overflowList = tags.splice(TAGS_TO_SHOW);
|
||||
const overflowMessage = i18n.translate('xpack.globalSearchBar.searchbar.overflowTagsAriaLabel', {
|
||||
defaultMessage: '{n} more {n, plural, one {tag} other {tags}}: {tags}',
|
||||
values: {
|
||||
n: overflowList.length,
|
||||
// @ts-ignore-line
|
||||
tags: overflowList.map(({ name }) => name),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TagListWrapper>
|
||||
{tags.map(buildListItem)}
|
||||
<li className="kbnSearchOption__tagsListItem" aria-label={overflowMessage}>
|
||||
<EuiBadge title={overflowMessage}>+{overflowList.length}</EuiBadge>
|
||||
</li>
|
||||
</TagListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const resultToOption = (
|
||||
result: GlobalSearchResult,
|
||||
searchTagIds: string[],
|
||||
getTag?: SavedObjectTaggingPluginStart['ui']['getTag']
|
||||
): EuiSelectableTemplateSitewideOption => {
|
||||
const { id, title, url, icon, type, meta = {} } = result;
|
||||
const { tagIds = [], categoryLabel = '' } = meta as { tagIds: string[]; categoryLabel: string };
|
||||
// only displaying icons for applications and integrations
|
||||
const useIcon = type === 'application' || type === 'integration';
|
||||
const option: EuiSelectableTemplateSitewideOption = {
|
||||
key: id,
|
||||
label: title,
|
||||
url,
|
||||
type,
|
||||
icon: { type: useIcon && icon ? icon : 'empty' },
|
||||
'data-test-subj': `nav-search-option`,
|
||||
};
|
||||
|
||||
if (type === 'application') option.meta = [{ text: categoryLabel }];
|
||||
else option.meta = [{ text: cleanMeta(type) }];
|
||||
|
||||
if (getTag && tagIds.length) {
|
||||
// TODO #85189 - refactor to use TagList instead of getTag
|
||||
// Casting to Tag[] because we know all our IDs will be valid here, no need to check for undefined
|
||||
option.append = tagList(tagIds.map(getTag) as Tag[], searchTagIds);
|
||||
}
|
||||
|
||||
return option;
|
||||
};
|
||||
|
||||
const suggestionToOption = (suggestion: SearchSuggestion): EuiSelectableTemplateSitewideOption => {
|
||||
const { key, label, description, icon, suggestedSearch } = suggestion;
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
type: '__suggestion__',
|
||||
icon: { type: icon },
|
||||
suggestion: suggestedSearch,
|
||||
meta: [{ text: description }],
|
||||
'data-test-subj': `nav-search-option`,
|
||||
};
|
||||
};
|
||||
|
||||
export function SearchBar({
|
||||
export const SearchBar: FC<SearchBarProps> = ({
|
||||
globalSearch,
|
||||
taggingApi,
|
||||
navigateToUrl,
|
||||
trackUiMetric,
|
||||
basePathUrl,
|
||||
darkMode,
|
||||
}: Props) {
|
||||
}) => {
|
||||
const isMounted = useMountedState();
|
||||
const [initialLoad, setInitialLoad] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
|
@ -312,79 +212,59 @@ export function SearchBar({
|
|||
[buttonRef, searchRef, trackUiMetric]
|
||||
);
|
||||
|
||||
const onChange = (selection: EuiSelectableTemplateSitewideOption[]) => {
|
||||
const selected = selection.find(({ checked }) => checked === 'on');
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore - ts error is "union type is too complex to express"
|
||||
const { url, type, suggestion } = selected;
|
||||
|
||||
// if the type is a suggestion, we change the query on the input and trigger a new search
|
||||
// by setting the searchValue (only setting the field value does not trigger a search)
|
||||
if (type === '__suggestion__') {
|
||||
setFieldValue(searchRef!, suggestion);
|
||||
setSearchValue(suggestion);
|
||||
return;
|
||||
}
|
||||
|
||||
// errors in tracking should not prevent selection behavior
|
||||
try {
|
||||
if (type === 'application') {
|
||||
const key = selected.keys ?? 'unknown';
|
||||
trackUiMetric(METRIC_TYPE.CLICK, [
|
||||
'user_navigated_to_application',
|
||||
`user_navigated_to_application_${key.toLowerCase().replaceAll(' ', '_')}`, // which application
|
||||
]);
|
||||
} else {
|
||||
trackUiMetric(METRIC_TYPE.CLICK, [
|
||||
'user_navigated_to_saved_object',
|
||||
`user_navigated_to_saved_object_${type}`, // which type of saved object
|
||||
]);
|
||||
const onChange = useCallback(
|
||||
(selection: EuiSelectableTemplateSitewideOption[]) => {
|
||||
const selected = selection.find(({ checked }) => checked === 'on');
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Error trying to track searchbar metrics', e);
|
||||
}
|
||||
|
||||
navigateToUrl(url);
|
||||
// @ts-ignore - ts error is "union type is too complex to express"
|
||||
const { url, type, suggestion } = selected;
|
||||
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
if (searchRef) {
|
||||
clearField(searchRef);
|
||||
searchRef.dispatchEvent(blurEvent);
|
||||
}
|
||||
};
|
||||
// if the type is a suggestion, we change the query on the input and trigger a new search
|
||||
// by setting the searchValue (only setting the field value does not trigger a search)
|
||||
if (type === '__suggestion__') {
|
||||
setFieldValue(searchRef!, suggestion);
|
||||
setSearchValue(suggestion);
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyMessage = (
|
||||
<EuiSelectableMessage style={{ minHeight: 300 }} data-test-subj="nav-search-no-results">
|
||||
<EuiImage
|
||||
alt={i18n.translate('xpack.globalSearchBar.searchBar.noResultsImageAlt', {
|
||||
defaultMessage: 'Illustration of black hole',
|
||||
})}
|
||||
size="fullWidth"
|
||||
url={`${basePathUrl}illustration_product_no_search_results_${
|
||||
darkMode ? 'dark' : 'light'
|
||||
}.svg`}
|
||||
/>
|
||||
<EuiText size="m">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.noResultsHeading"
|
||||
defaultMessage="No results found"
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.noResults"
|
||||
defaultMessage="Try searching for applications, dashboards, visualizations, and more."
|
||||
/>
|
||||
</p>
|
||||
</EuiSelectableMessage>
|
||||
// errors in tracking should not prevent selection behavior
|
||||
try {
|
||||
if (type === 'application') {
|
||||
const key = selected.keys ?? 'unknown';
|
||||
trackUiMetric(METRIC_TYPE.CLICK, [
|
||||
'user_navigated_to_application',
|
||||
`user_navigated_to_application_${key.toLowerCase().replaceAll(' ', '_')}`, // which application
|
||||
]);
|
||||
} else {
|
||||
trackUiMetric(METRIC_TYPE.CLICK, [
|
||||
'user_navigated_to_saved_object',
|
||||
`user_navigated_to_saved_object_${type}`, // which type of saved object
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Error trying to track searchbar metrics', e);
|
||||
}
|
||||
|
||||
navigateToUrl(url);
|
||||
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
if (searchRef) {
|
||||
clearField(searchRef);
|
||||
searchRef.dispatchEvent(blurEvent);
|
||||
}
|
||||
},
|
||||
[trackUiMetric, navigateToUrl, searchRef]
|
||||
);
|
||||
|
||||
const emptyMessage = <PopoverPlaceholder darkMode={darkMode} basePath={basePathUrl} />;
|
||||
const placeholderText = i18n.translate('xpack.globalSearchBar.searchBar.placeholder', {
|
||||
defaultMessage: 'Search Elastic',
|
||||
});
|
||||
|
||||
useEvent('keydown', onKeyDown);
|
||||
|
||||
return (
|
||||
|
@ -395,6 +275,27 @@ export function SearchBar({
|
|||
popoverButtonBreakpoints={['xs', 's']}
|
||||
singleSelection={true}
|
||||
renderOption={(option) => euiSelectableTemplateSitewideRenderOptions(option, searchTerm)}
|
||||
searchProps={{
|
||||
onInput: (e: React.UIEvent<HTMLInputElement>) => setSearchValue(e.currentTarget.value),
|
||||
'data-test-subj': 'nav-search-input',
|
||||
inputRef: setSearchRef,
|
||||
compressed: true,
|
||||
className: 'kbnSearchBar',
|
||||
'aria-label': placeholderText,
|
||||
placeholder: placeholderText,
|
||||
onFocus: () => {
|
||||
trackUiMetric(METRIC_TYPE.COUNT, 'search_focus');
|
||||
setInitialLoad(true);
|
||||
},
|
||||
}}
|
||||
emptyMessage={emptyMessage}
|
||||
noMatchesMessage={emptyMessage}
|
||||
popoverProps={{
|
||||
'data-test-subj': 'nav-search-popover',
|
||||
panelClassName: 'navSearch__panel',
|
||||
repositionOnScroll: true,
|
||||
buttonRef: setButtonRef,
|
||||
}}
|
||||
popoverButton={
|
||||
<EuiHeaderSectionItemButton
|
||||
aria-label={i18n.translate(
|
||||
|
@ -405,92 +306,7 @@ export function SearchBar({
|
|||
<EuiIcon type="search" size="m" />
|
||||
</EuiHeaderSectionItemButton>
|
||||
}
|
||||
searchProps={{
|
||||
onInput: (e: React.UIEvent<HTMLInputElement>) => setSearchValue(e.currentTarget.value),
|
||||
'data-test-subj': 'nav-search-input',
|
||||
inputRef: setSearchRef,
|
||||
compressed: true,
|
||||
className: 'kbnSearchBar',
|
||||
'aria-label': i18n.translate('xpack.globalSearchBar.searchBar.placeholder', {
|
||||
defaultMessage: 'Search Elastic',
|
||||
}),
|
||||
placeholder: i18n.translate('xpack.globalSearchBar.searchBar.placeholder', {
|
||||
defaultMessage: 'Search Elastic',
|
||||
}),
|
||||
onFocus: () => {
|
||||
trackUiMetric(METRIC_TYPE.COUNT, 'search_focus');
|
||||
setInitialLoad(true);
|
||||
},
|
||||
}}
|
||||
popoverProps={{
|
||||
'data-test-subj': 'nav-search-popover',
|
||||
panelClassName: 'navSearch__panel',
|
||||
repositionOnScroll: true,
|
||||
buttonRef: setButtonRef,
|
||||
}}
|
||||
emptyMessage={emptyMessage}
|
||||
noMatchesMessage={emptyMessage}
|
||||
popoverFooter={
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
justifyContent="spaceBetween"
|
||||
gutterSize="s"
|
||||
responsive={false}
|
||||
wrap
|
||||
>
|
||||
<EuiFlexItem>
|
||||
<EuiText color="subdued" size="xs">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.helpText.helpTextPrefix"
|
||||
defaultMessage="Filter by"
|
||||
/>
|
||||
|
||||
<EuiCode>type:</EuiCode>
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.helpText.helpTextConjunction"
|
||||
defaultMessage="or"
|
||||
/>
|
||||
|
||||
<EuiCode>tag:</EuiCode>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText color="subdued" size="xs">
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.shortcutDescription.shortcutDetail"
|
||||
defaultMessage="{shortcutDescription} {commandDescription}"
|
||||
values={{
|
||||
shortcutDescription: (
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.shortcutDescription.shortcutInstructionDescription"
|
||||
defaultMessage="Shortcut"
|
||||
/>
|
||||
),
|
||||
commandDescription: (
|
||||
<EuiCode>
|
||||
{isMac ? (
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.shortcutDescription.macCommandDescription"
|
||||
defaultMessage="Command + /"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.globalSearchBar.searchBar.shortcutDescription.windowsCommandDescription"
|
||||
defaultMessage="Control + /"
|
||||
/>
|
||||
)}
|
||||
</EuiCode>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
popoverFooter={<PopoverFooter isMac={isMac} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
9
x-pack/plugins/global_search_bar/public/lib/index.ts
Normal file
9
x-pack/plugins/global_search_bar/public/lib/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { resultToOption } from './result_to_option';
|
||||
export { suggestionToOption } from './suggestion_to_option';
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { GlobalSearchResult } from '../../../global_search/common/types';
|
||||
import { resultToOption } from './result_to_option';
|
||||
|
||||
const createSearchResult = (parts: Partial<GlobalSearchResult> = {}): GlobalSearchResult => ({
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
type: 'application',
|
||||
icon: 'some-icon',
|
||||
score: 100,
|
||||
url: '/url',
|
||||
meta: {},
|
||||
...parts,
|
||||
});
|
||||
|
||||
describe('resultToOption', () => {
|
||||
it('converts the result to the expected format', () => {
|
||||
const input = createSearchResult({});
|
||||
expect(resultToOption(input, [])).toEqual({
|
||||
key: input.id,
|
||||
label: input.title,
|
||||
url: input.url,
|
||||
type: input.type,
|
||||
icon: { type: expect.any(String) },
|
||||
'data-test-subj': expect.any(String),
|
||||
meta: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses icon for `application` type', () => {
|
||||
const input = createSearchResult({ type: 'application', icon: 'app-icon' });
|
||||
expect(resultToOption(input, [])).toEqual(
|
||||
expect.objectContaining({
|
||||
icon: { type: 'app-icon' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses icon for `integration` type', () => {
|
||||
const input = createSearchResult({ type: 'integration', icon: 'integ-icon' });
|
||||
expect(resultToOption(input, [])).toEqual(
|
||||
expect.objectContaining({
|
||||
icon: { type: 'integ-icon' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not use icon for other types', () => {
|
||||
const input = createSearchResult({ type: 'dashboard', icon: 'dash-icon' });
|
||||
expect(resultToOption(input, [])).toEqual(
|
||||
expect.objectContaining({
|
||||
icon: { type: 'empty' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the category label as meta for `application` type', () => {
|
||||
const input = createSearchResult({ type: 'application', meta: { categoryLabel: 'category' } });
|
||||
expect(resultToOption(input, [])).toEqual(
|
||||
expect.objectContaining({
|
||||
meta: [{ text: 'category' }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the type as meta for non-`application` type', () => {
|
||||
const input = createSearchResult({ type: 'dashboard', meta: { categoryLabel: 'category' } });
|
||||
expect(resultToOption(input, [])).toEqual(
|
||||
expect.objectContaining({
|
||||
meta: [{ text: 'Dashboard' }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the displayName as meta for non-`application` type when provided', () => {
|
||||
const input = createSearchResult({
|
||||
type: 'dashboard',
|
||||
meta: { categoryLabel: 'category', displayName: 'foo' },
|
||||
});
|
||||
expect(resultToOption(input, [])).toEqual(
|
||||
expect.objectContaining({
|
||||
meta: [{ text: 'Foo' }],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { EuiSelectableTemplateSitewideOption } from '@elastic/eui';
|
||||
import type { GlobalSearchResult } from '../../../global_search/common/types';
|
||||
import type { SavedObjectTaggingPluginStart, Tag } from '../../../saved_objects_tagging/public';
|
||||
import { ResultTagList } from '../components/result_tag_list';
|
||||
|
||||
const cleanMeta = (str: string) => (str.charAt(0).toUpperCase() + str.slice(1)).replace(/-/g, ' ');
|
||||
|
||||
export const resultToOption = (
|
||||
result: GlobalSearchResult,
|
||||
searchTagIds: string[],
|
||||
getTag?: SavedObjectTaggingPluginStart['ui']['getTag']
|
||||
): EuiSelectableTemplateSitewideOption => {
|
||||
const { id, title, url, icon, type, meta = {} } = result;
|
||||
const { tagIds = [], categoryLabel = '' } = meta as { tagIds: string[]; categoryLabel: string };
|
||||
// only displaying icons for applications and integrations
|
||||
const useIcon = type === 'application' || type === 'integration';
|
||||
const option: EuiSelectableTemplateSitewideOption = {
|
||||
key: id,
|
||||
label: title,
|
||||
url,
|
||||
type,
|
||||
icon: { type: useIcon && icon ? icon : 'empty' },
|
||||
'data-test-subj': `nav-search-option`,
|
||||
};
|
||||
|
||||
option.meta =
|
||||
type === 'application'
|
||||
? [{ text: categoryLabel }]
|
||||
: [{ text: cleanMeta((meta.displayName as string) ?? type) }];
|
||||
|
||||
if (getTag && tagIds.length) {
|
||||
// TODO #85189 - refactor to use TagList instead of getTag
|
||||
// Casting to Tag[] because we know all our IDs will be valid here, no need to check for undefined
|
||||
option.append = (
|
||||
<ResultTagList tags={tagIds.map(getTag) as Tag[]} searchTagIds={searchTagIds} />
|
||||
);
|
||||
}
|
||||
|
||||
return option;
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiSelectableTemplateSitewideOption } from '@elastic/eui';
|
||||
import { SearchSuggestion } from '../suggestions';
|
||||
|
||||
export const suggestionToOption = (
|
||||
suggestion: SearchSuggestion
|
||||
): EuiSelectableTemplateSitewideOption => {
|
||||
const { key, label, description, icon, suggestedSearch } = suggestion;
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
type: '__suggestion__',
|
||||
icon: { type: icon },
|
||||
suggestion: suggestedSearch,
|
||||
meta: [{ text: description }],
|
||||
'data-test-subj': `nav-search-option`,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SavedObjectTypeRegistry } from '../../../../../../src/core/server';
|
||||
import { getSearchableTypes } from './get_searchable_types';
|
||||
|
||||
describe('getSearchableTypes', () => {
|
||||
let registry: SavedObjectTypeRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new SavedObjectTypeRegistry();
|
||||
});
|
||||
|
||||
const registerType = ({
|
||||
name,
|
||||
displayName,
|
||||
hidden = false,
|
||||
noSearchField = false,
|
||||
noGetInAppUrl = false,
|
||||
}: {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
hidden?: boolean;
|
||||
noSearchField?: boolean;
|
||||
noGetInAppUrl?: boolean;
|
||||
}) => {
|
||||
registry.registerType({
|
||||
name,
|
||||
hidden,
|
||||
management: {
|
||||
displayName,
|
||||
defaultSearchField: noSearchField ? undefined : 'title',
|
||||
getInAppUrl: noGetInAppUrl
|
||||
? undefined
|
||||
: () => ({ path: 'path', uiCapabilitiesPath: 'uiCapabilitiesPath' }),
|
||||
},
|
||||
namespaceType: 'multiple',
|
||||
mappings: { properties: {} },
|
||||
});
|
||||
};
|
||||
|
||||
it('returns registered types that match', () => {
|
||||
registerType({ name: 'foo' });
|
||||
registerType({ name: 'bar' });
|
||||
registerType({ name: 'dolly' });
|
||||
|
||||
const matching = getSearchableTypes(registry, ['foo', 'dolly']).map((type) => type.name);
|
||||
expect(matching).toEqual(['foo', 'dolly']);
|
||||
});
|
||||
|
||||
it('ignores hidden types', () => {
|
||||
registerType({ name: 'foo', hidden: true });
|
||||
registerType({ name: 'bar' });
|
||||
registerType({ name: 'dolly' });
|
||||
|
||||
const matching = getSearchableTypes(registry, ['foo', 'dolly']).map((type) => type.name);
|
||||
expect(matching).toEqual(['dolly']);
|
||||
});
|
||||
|
||||
it('ignores types without `defaultSearchField`', () => {
|
||||
registerType({ name: 'foo' });
|
||||
registerType({ name: 'bar' });
|
||||
registerType({ name: 'dolly', noSearchField: true });
|
||||
|
||||
const matching = getSearchableTypes(registry, ['foo', 'dolly']).map((type) => type.name);
|
||||
expect(matching).toEqual(['foo']);
|
||||
});
|
||||
|
||||
it('ignores types without `getInAppUrl`', () => {
|
||||
registerType({ name: 'foo' });
|
||||
registerType({ name: 'bar' });
|
||||
registerType({ name: 'dolly', noGetInAppUrl: true });
|
||||
|
||||
const matching = getSearchableTypes(registry, ['foo', 'dolly']).map((type) => type.name);
|
||||
expect(matching).toEqual(['foo']);
|
||||
});
|
||||
|
||||
it('matches ignoring case', () => {
|
||||
registerType({ name: 'foo' });
|
||||
registerType({ name: 'bar' });
|
||||
registerType({ name: 'dolly' });
|
||||
|
||||
const matching = getSearchableTypes(registry, ['FOO', 'DolLy']).map((type) => type.name);
|
||||
expect(matching).toEqual(['foo', 'dolly']);
|
||||
});
|
||||
|
||||
it('matches against the display name when provided', () => {
|
||||
registerType({ name: 'foo' });
|
||||
registerType({ name: 'bar', displayName: 'display' });
|
||||
registerType({ name: 'dolly', displayName: 'name' });
|
||||
|
||||
const matching = getSearchableTypes(registry, ['display', 'name']).map((type) => type.name);
|
||||
expect(matching).toEqual(['bar', 'dolly']);
|
||||
});
|
||||
|
||||
it('ignores cases against the display name', () => {
|
||||
registerType({ name: 'foo' });
|
||||
registerType({ name: 'bar', displayName: 'display' });
|
||||
registerType({ name: 'dolly', displayName: 'name' });
|
||||
|
||||
const matching = getSearchableTypes(registry, ['DISPLAY', 'NaMe']).map((type) => type.name);
|
||||
expect(matching).toEqual(['bar', 'dolly']);
|
||||
});
|
||||
|
||||
it('replaces whitespaces with dashes when matching against the display name', () => {
|
||||
registerType({ name: 'dashboard' });
|
||||
registerType({ name: 'index-pattern', displayName: 'data view' });
|
||||
registerType({ name: 'map', displayName: 'my super display name' });
|
||||
|
||||
const matching = getSearchableTypes(registry, ['data-view', 'my-super-display-name']).map(
|
||||
(type) => type.name
|
||||
);
|
||||
expect(matching).toEqual(['index-pattern', 'map']);
|
||||
});
|
||||
|
||||
it('replaces whitespaces with dashes when matching against the name', () => {
|
||||
registerType({ name: 'dashboard' });
|
||||
registerType({ name: 'index-pattern' });
|
||||
registerType({ name: 'new-map' });
|
||||
|
||||
const matching = getSearchableTypes(registry, ['index pattern', 'new map']).map(
|
||||
(type) => type.name
|
||||
);
|
||||
expect(matching).toEqual(['index-pattern', 'new-map']);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ISavedObjectTypeRegistry, SavedObjectsType } from 'src/core/server';
|
||||
|
||||
export const getSearchableTypes = (typeRegistry: ISavedObjectTypeRegistry, types?: string[]) => {
|
||||
const typeFilter = types
|
||||
? (type: SavedObjectsType) => {
|
||||
if (type.management?.displayName && isTypeMatching(types, type.management.displayName)) {
|
||||
return true;
|
||||
}
|
||||
return isTypeMatching(types, type.name);
|
||||
}
|
||||
: () => true;
|
||||
|
||||
return typeRegistry
|
||||
.getVisibleTypes()
|
||||
.filter(typeFilter)
|
||||
.filter((type) => type.management?.defaultSearchField && type.management?.getInAppUrl);
|
||||
};
|
||||
|
||||
const isTypeMatching = (list: string[], item: string) =>
|
||||
list.some((e) => toCompareFormat(e) === toCompareFormat(item));
|
||||
|
||||
const toCompareFormat = (str: string) => str.toLowerCase().replace(/\s/g, '-');
|
|
@ -44,6 +44,7 @@ describe('mapToResult', () => {
|
|||
const type = createType({
|
||||
name: 'dashboard',
|
||||
management: {
|
||||
displayName: 'dashDisplayName',
|
||||
defaultSearchField: 'title',
|
||||
icon: 'dashboardApp',
|
||||
getInAppUrl: (obj) => ({ path: `/dashboard/${obj.id}`, uiCapabilitiesPath: '' }),
|
||||
|
@ -68,7 +69,7 @@ describe('mapToResult', () => {
|
|||
url: '/dashboard/dash1',
|
||||
icon: 'dashboardApp',
|
||||
score: 42,
|
||||
meta: { tagIds: [] },
|
||||
meta: { tagIds: [], displayName: 'dashDisplayName' },
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -140,6 +141,7 @@ describe('mapToResults', () => {
|
|||
createType({
|
||||
name: 'typeB',
|
||||
management: {
|
||||
displayName: 'typeBDisplayName',
|
||||
defaultSearchField: 'description',
|
||||
getInAppUrl: (obj) => ({ path: `/type-b/${obj.id}`, uiCapabilitiesPath: 'test.typeB' }),
|
||||
},
|
||||
|
@ -229,7 +231,7 @@ describe('mapToResults', () => {
|
|||
type: 'typeA',
|
||||
url: '/type-a/resultA',
|
||||
score: 100,
|
||||
meta: { tagIds: [] },
|
||||
meta: { tagIds: [], displayName: 'typeA' },
|
||||
},
|
||||
{
|
||||
id: 'resultC',
|
||||
|
@ -237,7 +239,7 @@ describe('mapToResults', () => {
|
|||
type: 'typeC',
|
||||
url: '/type-c/resultC',
|
||||
score: 42,
|
||||
meta: { tagIds: ['1', '2'] },
|
||||
meta: { tagIds: ['1', '2'], displayName: 'typeC' },
|
||||
},
|
||||
{
|
||||
id: 'resultB',
|
||||
|
@ -245,7 +247,7 @@ describe('mapToResults', () => {
|
|||
type: 'typeB',
|
||||
url: '/type-b/resultB',
|
||||
score: 69,
|
||||
meta: { tagIds: [] },
|
||||
meta: { tagIds: [], displayName: 'typeBDisplayName' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -283,7 +285,7 @@ describe('mapToResults', () => {
|
|||
type: 'typeA',
|
||||
url: '/type-a/resultA',
|
||||
score: 100,
|
||||
meta: { tagIds: [] },
|
||||
meta: { tagIds: [], displayName: 'typeA' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -56,6 +56,7 @@ export const mapToResult = (
|
|||
score: object.score,
|
||||
meta: {
|
||||
tagIds: object.references.filter((ref) => ref.type === 'tag').map(({ id }) => id),
|
||||
displayName: type.management?.displayName ?? object.type,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -202,7 +202,7 @@ describe('savedObjectsResultProvider', () => {
|
|||
type: 'typeA',
|
||||
url: '/type-a/resultA',
|
||||
score: 50,
|
||||
meta: { tagIds: [] },
|
||||
meta: { tagIds: [], displayName: 'typeA' },
|
||||
},
|
||||
{
|
||||
id: 'resultB',
|
||||
|
@ -210,7 +210,7 @@ describe('savedObjectsResultProvider', () => {
|
|||
type: 'typeB',
|
||||
url: '/type-b/resultB',
|
||||
score: 78,
|
||||
meta: { tagIds: [] },
|
||||
meta: { tagIds: [], displayName: 'typeB' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -7,9 +7,10 @@
|
|||
|
||||
import { from, combineLatest, of } from 'rxjs';
|
||||
import { map, takeUntil, first } from 'rxjs/operators';
|
||||
import { SavedObjectsFindOptionsReference, ISavedObjectTypeRegistry } from 'src/core/server';
|
||||
import { SavedObjectsFindOptionsReference } from 'src/core/server';
|
||||
import { GlobalSearchResultProvider } from '../../../../global_search/server';
|
||||
import { mapToResults } from './map_object_to_result';
|
||||
import { getSearchableTypes } from './get_searchable_types';
|
||||
|
||||
export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider => {
|
||||
return {
|
||||
|
@ -58,13 +59,4 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider =
|
|||
};
|
||||
};
|
||||
|
||||
const getSearchableTypes = (typeRegistry: ISavedObjectTypeRegistry, types?: string[]) =>
|
||||
typeRegistry
|
||||
.getVisibleTypes()
|
||||
.filter(types ? (type) => includeIgnoreCase(types, type.name) : () => true)
|
||||
.filter((type) => type.management?.defaultSearchField && type.management?.getInAppUrl);
|
||||
|
||||
const uniq = <T>(values: T[]): T[] => [...new Set(values)];
|
||||
|
||||
const includeIgnoreCase = (list: string[], item: string) =>
|
||||
list.find((e) => e.toLowerCase() === item.toLowerCase()) !== undefined;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue