[navSearch] handle displayName for savedObjects result provider (#119442) (#120329)

* [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:
Pierre Gayvallet 2021-12-03 11:33:13 +01:00 committed by GitHub
parent f166d18c72
commit c8d3d52061
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 632 additions and 297 deletions

View file

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

View file

@ -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"
/>
&nbsp;
<EuiCode>type:</EuiCode>&nbsp;
<FormattedMessage
id="xpack.globalSearchBar.searchBar.helpText.helpTextConjunction"
defaultMessage="or"
/>
&nbsp;
<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>
);
};

View file

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

View file

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

View file

@ -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"
/>
&nbsp;
<EuiCode>type:</EuiCode>&nbsp;
<FormattedMessage
id="xpack.globalSearchBar.searchBar.helpText.helpTextConjunction"
defaultMessage="or"
/>
&nbsp;
<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} />}
/>
);
}
};

View 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';

View file

@ -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' }],
})
);
});
});

View file

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

View file

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

View file

@ -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']);
});
});

View file

@ -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, '-');

View file

@ -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' },
},
]);
});

View file

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

View file

@ -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' },
},
]);
});

View file

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