[GS] adding tags UI to search results (#85084)

Co-authored-by: Ryan Keairns <contactryank@gmail.com>
This commit is contained in:
Michail Yasonik 2020-12-10 12:16:21 -05:00 committed by GitHub
parent 7757fa06c6
commit 36525954a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 172 additions and 22 deletions

View file

@ -73,6 +73,7 @@ const createApiUiMock = () => {
getTagIdsFromReferences: jest.fn(),
getTagIdFromName: jest.fn(),
updateTagsReferences: jest.fn(),
getTag: jest.fn(),
};
return mock;

View file

@ -71,6 +71,13 @@ export type SavedObjectTagDecoratorTypeGuard = SavedObjectsTaggingApiUi['hasTagD
* @public
*/
export interface SavedObjectsTaggingApiUi {
/**
* Return a Tag from an ID
*
* @param tagId
*/
getTag(tagId: string): Tag | undefined;
/**
* Type-guard to safely manipulate tag-enhanced `SavedObject` from the `savedObject` plugin.
*

View file

@ -1,10 +1,32 @@
//TODO add these overrides to EUI so that search behaves the same globally
.kbnSearchOption__tagsList {
display: inline-block; // Horizontally aligns the tag list to the 'Go to' badge when row is focused
line-height: $euiFontSizeM !important;
.kbnSearchOption__tagsListItem {
display: inline-block;
max-width: 80px;
margin-right: $euiSizeS;
}
}
.euiSelectableListItem-isFocused .kbnSearchOption__tagsList {
margin-right: $euiSizeXS;
border-right: $euiBorderThin; // Adds divider between the tag list and 'Go to' badge
}
//TODO add these overrides to EUI so that search behaves the same globally (eui/issues/4363)
.kbnSearchBar {
width: 400px;
max-width: 100%;
will-change: width;
}
@include euiBreakpoint('xs', 's') {
.kbnSearchOption__tagsList {
display: none;
}
}
@include euiBreakpoint('l', 'xl') {
.kbnSearchBar:focus {
animation: kbnAnimateSearchBar $euiAnimSpeedFast forwards;

View file

@ -15,12 +15,14 @@ import {
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, { useCallback, useRef, useState, useEffect } from 'react';
import React, { ReactNode, 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';
@ -30,10 +32,9 @@ import {
GlobalSearchResult,
GlobalSearchFindParams,
} from '../../../global_search/public';
import { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public';
import { SavedObjectTaggingPluginStart, Tag } from '../../../saved_objects_tagging/public';
import { parseSearchParams } from '../search_syntax';
import { getSuggestions, SearchSuggestion } from '../suggestions';
import './search_bar.scss';
interface Props {
@ -75,8 +76,64 @@ const sortByTitle = (a: GlobalSearchResult, b: GlobalSearchResult): number => {
return 0;
};
const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewideOption => {
const { id, title, url, icon, type, meta } = result;
const TagListWrapper = ({ children }: { children: ReactNode }) => (
<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>
);
};
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
const useIcon = type === 'application';
const option: EuiSelectableTemplateSitewideOption = {
@ -88,10 +145,13 @@ const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewi
'data-test-subj': `nav-search-option`,
};
if (type === 'application') {
option.meta = [{ text: (meta?.categoryLabel as string) ?? '' }];
} else {
option.meta = [{ text: cleanMeta(type) }];
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;
@ -120,11 +180,13 @@ export function SearchBar({
}: Props) {
const isMounted = useMountedState();
const [searchValue, setSearchValue] = useState<string>('');
const [searchTerm, setSearchTerm] = useState<string>('');
const [searchRef, setSearchRef] = useState<HTMLInputElement | null>(null);
const [buttonRef, setButtonRef] = useState<HTMLDivElement | null>(null);
const searchSubscription = useRef<Subscription | null>(null);
const [options, _setOptions] = useState<EuiSelectableTemplateSitewideOption[]>([]);
const [searchableTypes, setSearchableTypes] = useState<string[]>([]);
const UNKNOWN_TAG_ID = '__unknown__';
useEffect(() => {
const fetch = async () => {
@ -135,9 +197,9 @@ export function SearchBar({
}, [globalSearch]);
const loadSuggestions = useCallback(
(searchTerm: string) => {
(term: string) => {
return getSuggestions({
searchTerm,
searchTerm: term,
searchableTypes,
tagCache: taggingApi?.cache,
});
@ -146,13 +208,27 @@ export function SearchBar({
);
const setOptions = useCallback(
(_options: GlobalSearchResult[], suggestions: SearchSuggestion[]) => {
(
_options: GlobalSearchResult[],
suggestions: SearchSuggestion[],
searchTagIds: string[] = []
) => {
if (!isMounted()) {
return;
}
_setOptions([...suggestions.map(suggestionToOption), ..._options.map(resultToOption)]);
_setOptions([
...suggestions.map(suggestionToOption),
..._options.map((option) =>
resultToOption(
option,
searchTagIds?.filter((id) => id !== UNKNOWN_TAG_ID) ?? [],
taggingApi?.ui.getTag
)
),
]);
},
[isMounted, _setOptions]
[isMounted, _setOptions, taggingApi]
);
useDebounce(
@ -174,7 +250,7 @@ export function SearchBar({
const tagIds =
taggingApi && rawParams.filters.tags
? rawParams.filters.tags.map(
(tagName) => taggingApi.ui.getTagIdFromName(tagName) ?? '__unknown__'
(tagName) => taggingApi.ui.getTagIdFromName(tagName) ?? UNKNOWN_TAG_ID
)
: undefined;
const searchParams: GlobalSearchFindParams = {
@ -182,12 +258,17 @@ export function SearchBar({
types: rawParams.filters.types,
tags: tagIds,
};
// TODO technically a subtle bug here
// this term won't be set until the next time the debounce is fired
// so the SearchOption won't highlight anything if only one call is fired
// in practice, this is hard to spot, unlikely to happen, and is a negligible issue
setSearchTerm(rawParams.term ?? '');
searchSubscription.current = globalSearch.find(searchParams, {}).subscribe({
next: ({ results }) => {
if (searchValue.length > 0) {
aggregatedResults = [...results, ...aggregatedResults].sort(sortByScore);
setOptions(aggregatedResults, suggestions);
setOptions(aggregatedResults, suggestions, searchParams.tags);
return;
}
@ -196,7 +277,7 @@ export function SearchBar({
aggregatedResults = [...results, ...aggregatedResults].sort(sortByTitle);
setOptions(aggregatedResults, suggestions);
setOptions(aggregatedResults, suggestions, searchParams.tags);
},
error: () => {
// Not doing anything on error right now because it'll either just show the previous
@ -304,6 +385,7 @@ export function SearchBar({
options={options}
popoverButtonBreakpoints={['xs', 's']}
singleSelection={true}
renderOption={(option) => euiSelectableTemplateSitewideRenderOptions(option, searchTerm)}
popoverButton={
<EuiHeaderSectionItemButton
aria-label={i18n.translate(

View file

@ -11,6 +11,7 @@ import {
Capabilities,
} from 'src/core/server';
import { mapToResult, mapToResults } from './map_object_to_result';
import { SavedObjectReference } from 'src/core/types';
const createType = (props: Partial<SavedObjectsType>): SavedObjectsType => {
return {
@ -24,12 +25,13 @@ const createType = (props: Partial<SavedObjectsType>): SavedObjectsType => {
const createObject = <T>(
props: Partial<SavedObjectsFindResult>,
attributes: T
attributes: T,
references: SavedObjectReference[] = []
): SavedObjectsFindResult<T> => {
return {
id: 'id',
type: 'dashboard',
references: [],
references,
score: 100,
...props,
attributes,
@ -65,6 +67,7 @@ describe('mapToResult', () => {
url: '/dashboard/dash1',
icon: 'dashboardApp',
score: 42,
meta: { tagIds: [] },
});
});
@ -198,7 +201,12 @@ describe('mapToResults', () => {
{
excerpt: 'titleC',
title: 'foo',
}
},
[
{ name: 'tag A', type: 'tag', id: '1' },
{ name: 'tag B', type: 'tag', id: '2' },
{ name: 'not-tag', type: 'not-tag', id: '1' },
]
),
createObject(
{
@ -220,6 +228,7 @@ describe('mapToResults', () => {
type: 'typeA',
url: '/type-a/resultA',
score: 100,
meta: { tagIds: [] },
},
{
id: 'resultC',
@ -227,6 +236,7 @@ describe('mapToResults', () => {
type: 'typeC',
url: '/type-c/resultC',
score: 42,
meta: { tagIds: ['1', '2'] },
},
{
id: 'resultB',
@ -234,6 +244,7 @@ describe('mapToResults', () => {
type: 'typeB',
url: '/type-b/resultB',
score: 69,
meta: { tagIds: [] },
},
]);
});
@ -271,6 +282,7 @@ describe('mapToResults', () => {
type: 'typeA',
url: '/type-a/resultA',
score: 100,
meta: { tagIds: [] },
},
]);
});

View file

@ -53,5 +53,8 @@ export const mapToResult = (
icon: type.management?.icon ?? undefined,
url: getInAppUrl(object).path,
score: object.score,
meta: {
tagIds: object.references.filter((ref) => ref.type === 'tag').map(({ id }) => id),
},
};
};

View file

@ -201,6 +201,7 @@ describe('savedObjectsResultProvider', () => {
type: 'typeA',
url: '/type-a/resultA',
score: 50,
meta: { tagIds: [] },
},
{
id: 'resultB',
@ -208,6 +209,7 @@ describe('savedObjectsResultProvider', () => {
type: 'typeB',
url: '/type-b/resultB',
score: 78,
meta: { tagIds: [] },
},
]);
});

View file

@ -8,6 +8,7 @@ import { PluginInitializerContext } from '../../../../src/core/public';
import { SavedObjectTaggingPlugin } from './plugin';
export { SavedObjectTaggingPluginStart } from './types';
export { Tag } from '../common';
export const plugin = (initializerContext: PluginInitializerContext) =>
new SavedObjectTaggingPlugin(initializerContext);

View file

@ -8,7 +8,12 @@ import { OverlayStart } from 'src/core/public';
import { SavedObjectsTaggingApiUi } from '../../../../../src/plugins/saved_objects_tagging_oss/public';
import { TagsCapabilities } from '../../common';
import { ITagsCache, ITagInternalClient } from '../services';
import { getTagIdsFromReferences, updateTagsReferences, convertTagNameToId } from '../utils';
import {
getTagIdsFromReferences,
updateTagsReferences,
convertTagNameToId,
getTag,
} from '../utils';
import { getComponents } from './components';
import { buildGetTableColumnDefinition } from './get_table_column_definition';
import { buildGetSearchBarFilter } from './get_search_bar_filter';
@ -41,5 +46,6 @@ export const getUiApi = ({
getTagIdsFromReferences,
getTagIdFromName: (tagName: string) => convertTagNameToId(tagName, cache.getState()),
updateTagsReferences,
getTag: (tagId: string) => getTag(tagId, cache.getState()),
};
};

View file

@ -10,6 +10,7 @@ import {
convertTagNameToId,
byNameTagSorter,
getTagIdsFromReferences,
getTag,
} from './utils';
const createTag = (id: string, name: string = id) => ({
@ -71,6 +72,15 @@ describe('convertTagNameToId', () => {
});
});
describe('getTag', () => {
it('returns the tag for the given id', () => {
expect(getTag('id-2', allTags)).toEqual(tag2);
});
it('returns undefined if no tag was found', () => {
expect(getTag('id-4', allTags)).toBeUndefined();
});
});
describe('byNameTagSorter', () => {
it('sorts tags by name', () => {
const tags = [

View file

@ -49,6 +49,10 @@ export const byNameTagSorter = (tagA: Tag, tagB: Tag): number => {
return tagA.name.localeCompare(tagB.name);
};
export const getTag = (tagId: string, allTags: Tag[]): Tag | undefined => {
return allTags.find(({ id }) => id === tagId);
};
export const testSubjFriendly = (name: string) => {
return name.replace(' ', '_');
};