Skip to content

Commit

Permalink
[GS] adding tags UI to search results (elastic#85084)
Browse files Browse the repository at this point in the history
Co-authored-by: Ryan Keairns <contactryank@gmail.com>
  • Loading branch information
Michail Yasonik and ryankeairns committed Dec 10, 2020
1 parent 36f8a64 commit c57991e
Show file tree
Hide file tree
Showing 11 changed files with 172 additions and 22 deletions.
1 change: 1 addition & 0 deletions src/plugins/saved_objects_tagging_oss/public/api.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const createApiUiMock = () => {
getTagIdsFromReferences: jest.fn(),
getTagIdFromName: jest.fn(),
updateTagsReferences: jest.fn(),
getTag: jest.fn(),
};

return mock;
Expand Down
7 changes: 7 additions & 0 deletions src/plugins/saved_objects_tagging_oss/public/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
116 changes: 99 additions & 17 deletions x-pack/plugins/global_search_bar/public/components/search_bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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 = {
Expand All @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand All @@ -135,9 +197,9 @@ export function SearchBar({
}, [globalSearch]);

const loadSuggestions = useCallback(
(searchTerm: string) => {
(term: string) => {
return getSuggestions({
searchTerm,
searchTerm: term,
searchableTypes,
tagCache: taggingApi?.cache,
});
Expand All @@ -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(
Expand All @@ -174,20 +250,25 @@ 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 = {
term: rawParams.term,
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;
}

Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -65,6 +67,7 @@ describe('mapToResult', () => {
url: '/dashboard/dash1',
icon: 'dashboardApp',
score: 42,
meta: { tagIds: [] },
});
});

Expand Down Expand Up @@ -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(
{
Expand All @@ -220,20 +228,23 @@ describe('mapToResults', () => {
type: 'typeA',
url: '/type-a/resultA',
score: 100,
meta: { tagIds: [] },
},
{
id: 'resultC',
title: 'titleC',
type: 'typeC',
url: '/type-c/resultC',
score: 42,
meta: { tagIds: ['1', '2'] },
},
{
id: 'resultB',
title: 'titleB',
type: 'typeB',
url: '/type-b/resultB',
score: 69,
meta: { tagIds: [] },
},
]);
});
Expand Down Expand Up @@ -271,6 +282,7 @@ describe('mapToResults', () => {
type: 'typeA',
url: '/type-a/resultA',
score: 100,
meta: { tagIds: [] },
},
]);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,15 @@ describe('savedObjectsResultProvider', () => {
type: 'typeA',
url: '/type-a/resultA',
score: 50,
meta: { tagIds: [] },
},
{
id: 'resultB',
title: 'titleB',
type: 'typeB',
url: '/type-b/resultB',
score: 78,
meta: { tagIds: [] },
},
]);
});
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/saved_objects_tagging/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Loading

0 comments on commit c57991e

Please sign in to comment.