Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[GS] adding tags UI to search results #85084

Merged
merged 1 commit into from
Dec 10, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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