[TableListView] Enhance tag filtering (#142108)

This commit is contained in:
Sébastien Loix 2022-11-14 21:25:19 +00:00 committed by GitHub
parent b72a9a3df2
commit a67776b365
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1319 additions and 211 deletions

8
.github/CODEOWNERS vendored
View file

@ -117,6 +117,11 @@
/docs/settings/reporting-settings.asciidoc @elastic/kibana-global-experience
/docs/setup/configuring-reporting.asciidoc @elastic/kibana-global-experience
### Global Experience Tagging
/src/plugins/saved_objects_tagging_oss @elastic/kibana-global-experience
/x-pack/plugins/saved_objects_tagging/ @elastic/kibana-global-experience
/x-pack/test/saved_object_tagging/ @elastic/kibana-global-experience
### Kibana React (to be deprecated)
/src/plugins/kibana_react/ @elastic/kibana-global-experience
/src/plugins/kibana_react/public/code_editor @elastic/kibana-global-experience @elastic/kibana-presentation
@ -302,7 +307,6 @@
# Core
/examples/hello_world/ @elastic/kibana-core
/src/core/ @elastic/kibana-core
/src/plugins/saved_objects_tagging_oss @elastic/kibana-core
/config/kibana.yml @elastic/kibana-core
/typings/ @elastic/kibana-core
/x-pack/plugins/global_search_providers @elastic/kibana-core
@ -312,9 +316,7 @@
/x-pack/plugins/global_search/ @elastic/kibana-core
/x-pack/plugins/cloud/ @elastic/kibana-core
/x-pack/plugins/cloud_integrations/ @elastic/kibana-core
/x-pack/plugins/saved_objects_tagging/ @elastic/kibana-core
/x-pack/test/saved_objects_field_count/ @elastic/kibana-core
/x-pack/test/saved_object_tagging/ @elastic/kibana-core
/src/plugins/saved_objects_management/ @elastic/kibana-core
/src/plugins/advanced_settings/ @elastic/kibana-core
/x-pack/plugins/global_search_bar/ @elastic/kibana-core

View file

@ -21,7 +21,9 @@ export const getMockServices = (overrides?: Partial<Services>) => {
currentAppId$: from('mockedApp'),
navigateToUrl: () => undefined,
TagList,
getTagList: () => [],
itemHasTags: () => true,
getTagManagementUrl: () => '',
getTagIdsFromReferences: () => [],
...overrides,
};

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { CriteriaWithPagination, Direction } from '@elastic/eui';
import type { CriteriaWithPagination, Direction, Query } from '@elastic/eui';
import type { SortColumnField } from './components';
@ -71,7 +71,10 @@ export interface ShowConfirmDeleteItemsModalAction {
/** Action to update the search bar query text */
export interface OnSearchQueryChangeAction {
type: 'onSearchQueryChange';
data: string;
data: {
query: Query;
text: string;
};
}
export type Action<T> =

View file

@ -12,5 +12,6 @@ export { ConfirmDeleteModal } from './confirm_delete_modal';
export { ListingLimitWarning } from './listing_limit_warning';
export { ItemDetails } from './item_details';
export { TableSortSelect } from './table_sort_select';
export { TagFilterPanel } from './tag_filter_panel';
export type { SortColumnField } from './table_sort_select';

View file

@ -7,11 +7,13 @@
*/
import React, { useCallback, useMemo } from 'react';
import { EuiText, EuiLink, EuiTitle, EuiSpacer } from '@elastic/eui';
import { EuiText, EuiLink, EuiTitle, EuiSpacer, EuiHighlight } from '@elastic/eui';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import type { Tag } from '../types';
import { useServices } from '../services';
import type { UserContentCommonSchema, Props as TableListViewProps } from '../table_list_view';
import { TagBadge } from './tag_badge';
type InheritedProps<T extends UserContentCommonSchema> = Pick<
TableListViewProps<T>,
@ -20,14 +22,15 @@ type InheritedProps<T extends UserContentCommonSchema> = Pick<
interface Props<T extends UserContentCommonSchema> extends InheritedProps<T> {
item: T;
searchTerm?: string;
onClickTag: (tag: Tag, isCtrlKey: boolean) => void;
}
/**
* Copied from https://stackoverflow.com/a/9310752
*/
// const escapeRegExp = (text: string) => {
// return text.replace(/[-\[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
// };
const escapeRegExp = (text: string) => {
return text.replace(/[-\[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
};
export function ItemDetails<T extends UserContentCommonSchema>({
id,
@ -35,6 +38,7 @@ export function ItemDetails<T extends UserContentCommonSchema>({
searchTerm = '',
getDetailViewLink,
onClickTitle,
onClickTag,
}: Props<T>) {
const {
references,
@ -79,7 +83,9 @@ export function ItemDetails<T extends UserContentCommonSchema>({
onClick={onClickTitleHandler}
data-test-subj={`${id}ListingTitleLink-${item.attributes.title.split(' ').join('-')}`}
>
{title}
<EuiHighlight highlightAll search={escapeRegExp(searchTerm)}>
{title}
</EuiHighlight>
</EuiLink>
</RedirectAppLinks>
);
@ -90,6 +96,7 @@ export function ItemDetails<T extends UserContentCommonSchema>({
onClickTitle,
onClickTitleHandler,
redirectAppLinksCoreStart,
searchTerm,
title,
]);
@ -100,13 +107,20 @@ export function ItemDetails<T extends UserContentCommonSchema>({
<EuiTitle size="xs">{renderTitle()}</EuiTitle>
{Boolean(description) && (
<EuiText size="s">
<p>{description!}</p>
<p>
<EuiHighlight highlightAll search={escapeRegExp(searchTerm)}>
{description!}
</EuiHighlight>
</p>
</EuiText>
)}
{hasTags && (
<>
<EuiSpacer size="s" />
<TagList references={references} />
<TagList
references={references}
tagRender={(tag) => <TagBadge key={tag.name} tag={tag} onClick={onClickTag} />}
/>
</>
)}
</div>

View file

@ -16,6 +16,8 @@ import {
PropertySort,
SearchFilterConfig,
Direction,
Query,
Ast,
} from '@elastic/eui';
import { useServices } from '../services';
@ -26,6 +28,9 @@ import type {
UserContentCommonSchema,
} from '../table_list_view';
import { TableSortSelect } from './table_sort_select';
import { TagFilterPanel } from './tag_filter_panel';
import { useTagFilterPanel } from './use_tag_filter_panel';
import type { Params as UseTagFilterPanelParams } from './use_tag_filter_panel';
import type { SortColumnField } from './table_sort_select';
type State<T extends UserContentCommonSchema> = Pick<
@ -33,7 +38,12 @@ type State<T extends UserContentCommonSchema> = Pick<
'items' | 'selectedIds' | 'searchQuery' | 'tableSort' | 'pagination'
>;
interface Props<T extends UserContentCommonSchema> extends State<T> {
type TagManagementProps = Pick<
UseTagFilterPanelParams,
'addOrRemoveIncludeTagFilter' | 'addOrRemoveExcludeTagFilter' | 'tagsToTableItemMap'
>;
interface Props<T extends UserContentCommonSchema> extends State<T>, TagManagementProps {
dispatch: Dispatch<Action<T>>;
entityName: string;
entityNamePlural: string;
@ -44,6 +54,7 @@ interface Props<T extends UserContentCommonSchema> extends State<T> {
deleteItems: TableListViewProps<T>['deleteItems'];
onSortChange: (column: SortColumnField, direction: Direction) => void;
onTableChange: (criteria: CriteriaWithPagination<T>) => void;
clearTagSelection: () => void;
}
export function Table<T extends UserContentCommonSchema>({
@ -58,12 +69,16 @@ export function Table<T extends UserContentCommonSchema>({
hasUpdatedAtMetadata,
entityName,
entityNamePlural,
tagsToTableItemMap,
deleteItems,
tableCaption,
onTableChange,
onSortChange,
addOrRemoveExcludeTagFilter,
addOrRemoveIncludeTagFilter,
clearTagSelection,
}: Props<T>) {
const { getSearchBarFilters } = useServices();
const { getTagList } = useServices();
const renderToolsLeft = useCallback(() => {
if (!deleteItems || selectedIds.length === 0) {
@ -97,8 +112,37 @@ export function Table<T extends UserContentCommonSchema>({
}
: undefined;
const searchFilters = useMemo(() => {
const tableSortSelectFilter: SearchFilterConfig = {
const {
isPopoverOpen,
isInUse,
closePopover,
onFilterButtonClick,
onSelectChange,
options,
totalActiveFilters,
} = useTagFilterPanel({
query: searchQuery.query,
getTagList,
tagsToTableItemMap,
addOrRemoveExcludeTagFilter,
addOrRemoveIncludeTagFilter,
});
const onSearchQueryChange = useCallback(
(arg: { query: Query | null; queryText: string }) => {
dispatch({
type: 'onSearchQueryChange',
data: {
query: arg.query ?? new Query(Ast.create([]), undefined, arg.queryText),
text: arg.queryText,
},
});
},
[dispatch]
);
const tableSortSelectFilter = useMemo<SearchFilterConfig>(() => {
return {
type: 'custom_component',
component: () => {
return (
@ -110,25 +154,53 @@ export function Table<T extends UserContentCommonSchema>({
);
},
};
}, [hasUpdatedAtMetadata, onSortChange, tableSort]);
return getSearchBarFilters
? [tableSortSelectFilter, ...getSearchBarFilters()]
: [tableSortSelectFilter];
}, [onSortChange, hasUpdatedAtMetadata, tableSort, getSearchBarFilters]);
const tagFilterPanel = useMemo<SearchFilterConfig>(() => {
return {
type: 'custom_component',
component: () => {
return (
<TagFilterPanel
isPopoverOpen={isPopoverOpen}
isInUse={isInUse}
closePopover={closePopover}
options={options}
totalActiveFilters={totalActiveFilters}
onFilterButtonClick={onFilterButtonClick}
onSelectChange={onSelectChange}
clearTagSelection={clearTagSelection}
/>
);
},
};
}, [
isPopoverOpen,
isInUse,
closePopover,
options,
totalActiveFilters,
onFilterButtonClick,
onSelectChange,
clearTagSelection,
]);
const searchFilters = useMemo(() => {
return [tableSortSelectFilter, tagFilterPanel];
}, [tableSortSelectFilter, tagFilterPanel]);
const search = useMemo(() => {
return {
onChange: ({ queryText }: { queryText: string }) =>
dispatch({ type: 'onSearchQueryChange', data: queryText }),
onChange: onSearchQueryChange,
toolsLeft: renderToolsLeft(),
defaultQuery: searchQuery,
query: searchQuery.query ?? undefined,
box: {
incremental: true,
'data-test-subj': 'tableListSearchBox',
},
filters: searchFilters,
};
}, [dispatch, renderToolsLeft, searchFilters, searchQuery]);
}, [onSearchQueryChange, renderToolsLeft, searchFilters, searchQuery.query]);
const noItemsMessage = (
<FormattedMessage
@ -148,6 +220,7 @@ export function Table<T extends UserContentCommonSchema>({
message={noItemsMessage}
selection={selection}
search={search}
executeQueryOptions={{ enabled: false }}
sorting={tableSort ? { sort: tableSort as PropertySort } : undefined}
onChange={onTableChange}
data-test-subj="itemsInMemTable"

View file

@ -0,0 +1,45 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC } from 'react';
import { EuiBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { Tag } from '../types';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
export interface Props {
tag: Tag;
onClick: (tag: Tag, withModifierKey: boolean) => void;
}
/**
* The badge representation of a Tag, which is the default display to be used for them.
*/
export const TagBadge: FC<Props> = ({ tag, onClick }) => {
return (
<EuiBadge
color={tag.color}
title={tag.description}
data-test-subj={`tag-${tag.id}`}
onClick={(e) => {
const withModifierKey = (isMac && e.metaKey) || (!isMac && e.ctrlKey);
onClick(tag, withModifierKey);
}}
onClickAriaLabel={i18n.translate('contentManagement.tableList.tagBadge.buttonLabel', {
defaultMessage: '{tagName} tag button.',
values: {
tagName: tag.name,
},
})}
>
{tag.name}
</EuiBadge>
);
};

View file

@ -0,0 +1,205 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import type { FC } from 'react';
import {
EuiPopover,
EuiPopoverTitle,
EuiSelectable,
EuiFilterButton,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiButtonEmpty,
EuiTextColor,
EuiSpacer,
EuiLink,
useEuiTheme,
EuiPopoverFooter,
EuiButton,
} from '@elastic/eui';
import type { EuiSelectableProps, ExclusiveUnion } from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { useServices } from '../services';
import type { TagOptionItem } from './use_tag_filter_panel';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
const modifierKeyPrefix = isMac ? '⌘' : '^';
const clearSelectionBtnCSS = css`
height: auto;
`;
const saveBtnWrapperCSS = css`
width: 100%;
`;
interface Props {
clearTagSelection: () => void;
closePopover: () => void;
isPopoverOpen: boolean;
isInUse: boolean;
options: TagOptionItem[];
totalActiveFilters: number;
onFilterButtonClick: () => void;
onSelectChange: (updatedOptions: TagOptionItem[]) => void;
}
export const TagFilterPanel: FC<Props> = ({
isPopoverOpen,
isInUse,
options,
totalActiveFilters,
onFilterButtonClick,
onSelectChange,
closePopover,
clearTagSelection,
}) => {
const { euiTheme } = useEuiTheme();
const { navigateToUrl, currentAppId$, getTagManagementUrl } = useServices();
const isSearchVisible = options.length > 10;
const searchBoxCSS = css`
padding: ${euiTheme.size.s};
border-bottom: ${euiTheme.border.thin};
`;
const popoverTitleCSS = css`
height: ${euiTheme.size.xxxl};
`;
let searchProps: ExclusiveUnion<
{ searchable: false },
{
searchable: true;
searchProps: EuiSelectableProps['searchProps'];
}
> = {
searchable: false,
};
if (isSearchVisible) {
searchProps = {
searchable: true,
searchProps: {
compressed: true,
},
};
}
return (
<>
<EuiPopover
button={
<EuiFilterButton
iconType="arrowDown"
iconSide="right"
onClick={onFilterButtonClick}
data-test-subj="tagFilterPopoverButton"
hasActiveFilters={totalActiveFilters > 0}
numActiveFilters={totalActiveFilters}
grow
>
Tags
</EuiFilterButton>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downCenter"
panelClassName="euiFilterGroup__popoverPanel"
panelStyle={isInUse ? { transition: 'none' } : undefined}
>
<EuiPopoverTitle paddingSize="m" css={popoverTitleCSS}>
<EuiFlexGroup>
<EuiFlexItem>Tags</EuiFlexItem>
<EuiFlexItem grow={false}>
{totalActiveFilters > 0 && (
<EuiButtonEmpty flush="both" onClick={clearTagSelection} css={clearSelectionBtnCSS}>
{i18n.translate(
'contentManagement.tableList.tagFilterPanel.clearSelectionButtonLabelLabel',
{
defaultMessage: 'Clear selection',
}
)}
</EuiButtonEmpty>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverTitle>
<EuiSelectable<any>
singleSelection={false}
aria-label="some aria label"
options={options}
renderOption={(option) => option.view}
emptyMessage="There aren't any tags"
noMatchesMessage="No tag matches the search"
onChange={onSelectChange}
data-test-subj="tagSelectableList"
{...searchProps}
>
{(list, search) => {
return (
<>
{isSearchVisible ? <div css={searchBoxCSS}>{search}</div> : <EuiSpacer size="s" />}
{list}
</>
);
}}
</EuiSelectable>
<EuiPopoverFooter paddingSize="m">
<EuiFlexGroup direction="column" alignItems="center" gutterSize="s">
<EuiFlexItem>
<EuiText size="xs">
<EuiTextColor color="dimgrey">
{i18n.translate(
'contentManagement.tableList.tagFilterPanel.modifierKeyHelpText',
{
defaultMessage: '{modifierKeyPrefix} + click exclude',
values: {
modifierKeyPrefix,
},
}
)}
</EuiTextColor>
</EuiText>
</EuiFlexItem>
<EuiFlexItem css={saveBtnWrapperCSS}>
<EuiButton onClick={closePopover}>Save</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<RedirectAppLinks
coreStart={{
application: {
navigateToUrl,
currentAppId$,
},
}}
>
<EuiLink href={getTagManagementUrl()} data-test-subj="manageAllTagsLink" external>
{i18n.translate(
'contentManagement.tableList.tagFilterPanel.manageAllTagsLinkLabel',
{
defaultMessage: 'Manage tags',
}
)}
</EuiLink>
</RedirectAppLinks>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverFooter>
</EuiPopover>
</>
);
};

View file

@ -0,0 +1,189 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useEffect, useState, useCallback } from 'react';
import type { MouseEvent } from 'react';
import { Query, EuiFlexGroup, EuiFlexItem, EuiText, EuiHealth, EuiBadge } from '@elastic/eui';
import type { FieldValueOptionType } from '@elastic/eui';
import type { Tag } from '../types';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
const toArray = (item: unknown) => (Array.isArray(item) ? item : [item]);
const testSubjFriendly = (name: string) => {
return name.replace(' ', '_');
};
export interface TagSelection {
[tagId: string]: 'include' | 'exclude' | undefined;
}
export interface TagOptionItem extends FieldValueOptionType {
label: string;
checked?: 'on' | 'off';
tag: Tag;
}
export interface Params {
query: Query | null;
tagsToTableItemMap: { [tagId: string]: string[] };
getTagList: () => Tag[];
addOrRemoveIncludeTagFilter: (tag: Tag) => void;
addOrRemoveExcludeTagFilter: (tag: Tag) => void;
}
export const useTagFilterPanel = ({
query,
tagsToTableItemMap,
getTagList,
addOrRemoveExcludeTagFilter,
addOrRemoveIncludeTagFilter,
}: Params) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
// When the panel is "in use" it means that it is opened and the user is interacting with it.
// When the user clicks on a tag to select it, the component is unmounted and mounted immediately, which
// creates a new EUI transition "IN" which makes the UI "flicker". To avoid that we pass this
// "isInUse" state which disable the transition.
const [isInUse, setIsInUse] = useState(false);
const [options, setOptions] = useState<TagOptionItem[]>([]);
const [tagSelection, setTagSelection] = useState<TagSelection>({});
const totalActiveFilters = Object.keys(tagSelection).length;
const onSelectChange = useCallback(
(updatedOptions: TagOptionItem[]) => {
// Note: see data flow comment in useEffect() below
const diff = updatedOptions.find((item, index) => item.checked !== options[index].checked);
if (diff) {
addOrRemoveIncludeTagFilter(diff.tag);
}
},
[options, addOrRemoveIncludeTagFilter]
);
const onOptionClick = useCallback(
(tag: Tag) => (e: MouseEvent) => {
const withModifierKey = (isMac && e.metaKey) || (!isMac && e.ctrlKey);
if (withModifierKey) {
addOrRemoveExcludeTagFilter(tag);
} else {
addOrRemoveIncludeTagFilter(tag);
}
},
[addOrRemoveIncludeTagFilter, addOrRemoveExcludeTagFilter]
);
const updateTagList = useCallback(() => {
const tags = getTagList();
const tagsToSelectOptions = tags.map((tag) => {
const { name, id, color } = tag;
let checked: 'on' | 'off' | undefined;
if (tagSelection[name]) {
checked = tagSelection[name] === 'include' ? 'on' : 'off';
}
return {
name,
label: name,
value: id ?? '',
tag,
checked,
view: (
<EuiFlexGroup gutterSize="xs" justifyContent="spaceBetween">
<EuiFlexItem>
<EuiHealth
color={color}
data-test-subj={`tag-searchbar-option-${testSubjFriendly(name)}`}
onClick={onOptionClick(tag)}
>
<EuiText>{name}</EuiText>
</EuiHealth>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color={checked !== undefined ? 'accent' : undefined}>
{tagsToTableItemMap[id ?? '']?.length ?? 0}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
),
};
});
setOptions(tagsToSelectOptions);
}, [getTagList, tagsToTableItemMap, tagSelection, onOptionClick]);
const onFilterButtonClick = useCallback(() => {
setIsPopoverOpen((prev) => !prev);
}, []);
const closePopover = useCallback(() => {
setIsPopoverOpen(false);
}, []);
useEffect(() => {
/**
* Data flow for tag filter panel state:
* When we click (or Ctrl + click) on a tag in the filter panel:
* 1. The "onSelectChange()" handler is called
* 2. It updates the Query in the parent component
* 3. Which in turns update the search bar
* 4. We receive the updated query back here
* 5. The useEffect() executes and we check which tag is "included" or "excluded"
* 6. We update the "tagSelection" state
* 7. Which updates the "options" state (which is then passed to the stateless <TagFilterPanel />)
*/
if (query) {
const clauseInclude = query.ast.getOrFieldClause('tag', undefined, true, 'eq');
const clauseExclude = query.ast.getOrFieldClause('tag', undefined, false, 'eq');
const updatedTagSelection: TagSelection = {};
if (clauseInclude) {
toArray(clauseInclude.value).forEach((tagName) => {
updatedTagSelection[tagName] = 'include';
});
}
if (clauseExclude) {
toArray(clauseExclude.value).forEach((tagName) => {
updatedTagSelection[tagName] = 'exclude';
});
}
setTagSelection(updatedTagSelection);
}
}, [query]);
useEffect(() => {
if (isPopoverOpen) {
// Refresh the tag list whenever we open the pop over
updateTagList();
// To avoid "cutting" the inflight css transition when opening the popover
// we add a slight delay to switch the "isInUse" flag.
setTimeout(() => {
setIsInUse(true);
}, 250);
} else {
setIsInUse(false);
}
}, [isPopoverOpen, updateTagList]);
return {
isPopoverOpen,
isInUse,
options,
totalActiveFilters,
onFilterButtonClick,
onSelectChange,
closePopover,
};
};

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const TAG_MANAGEMENT_APP_URL = '/app/management/kibana/tags';

View file

@ -7,9 +7,8 @@
*/
import React from 'react';
import { from } from 'rxjs';
import { EuiBadgeGroup, EuiBadge } from '@elastic/eui';
import { Services } from './services';
import type { Services, TagListProps } from './services';
/**
* Parameters drawn from the Storybook arguments collection that customize a component story.
@ -17,56 +16,42 @@ import { Services } from './services';
export type Params = Record<keyof ReturnType<typeof getStoryArgTypes>, any>;
type ActionFn = (name: string) => any;
const tags = [
{
name: 'elastic',
color: '#8dc4de',
description: 'elastic tag',
},
{
name: 'cloud',
color: '#f5ed14',
description: 'cloud tag',
},
];
interface Props {
onClick?: (tag: { name: string }) => void;
tags?: typeof tags | null;
}
export const TagList = ({ onClick, tags: _tags = tags }: Props) => {
if (_tags === null) {
export const TagList = ({ onClick, references, tagRender }: TagListProps) => {
if (references.length === 0) {
return null;
}
return (
<EuiBadgeGroup>
{_tags.map((tag) => (
<EuiBadge
key={tag.name}
onClick={() => {
if (onClick) {
onClick(tag);
}
}}
onClickAriaLabel="tag button"
iconOnClick={() => undefined}
iconOnClickAriaLabel=""
color={tag.color}
title={tag.description}
>
{tag.name}
</EuiBadge>
))}
</EuiBadgeGroup>
<div>
{references.map((ref) => {
const tag = { ...ref, color: 'blue', description: '' };
if (tagRender) {
return tagRender(tag);
}
return (
<button
key={tag.name}
onClick={() => {
if (onClick) {
onClick(tag);
}
}}
data-test-subj={`tag-${tag.id}`}
>
{tag.name}
</button>
);
})}
</div>
);
};
export const getTagList =
({ tags: _tags }: Props = {}) =>
({ onClick }: Props) => {
return <TagList onClick={onClick} tags={_tags} />;
({ references: _tags }: TagListProps = { references: [] }) =>
({ onClick }: TagListProps) => {
return <TagList onClick={onClick} references={_tags} />;
};
/**
@ -82,7 +67,9 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) =>
currentAppId$: from('mockedApp'),
navigateToUrl: () => undefined,
TagList,
getTagList: () => [],
itemHasTags: () => true,
getTagManagementUrl: () => '',
getTagIdsFromReferences: () => [],
...params,
};

View file

@ -5,8 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { sortBy } from 'lodash';
import type { State, UserContentCommonSchema } from './table_list_view';
import type { Action } from './actions';
@ -40,7 +38,7 @@ export function getReducer<T extends UserContentCommonSchema>() {
...state,
hasInitialFetchReturned: true,
isFetchingItems: false,
items: !state.searchQuery ? sortBy<T>(items, 'title') : items,
items,
totalItems: action.data.response.total,
hasUpdatedAtMetadata,
tableSort: tableSort ?? state.tableSort,

View file

@ -7,7 +7,6 @@
*/
import React, { FC, useContext, useMemo, useCallback } from 'react';
import type { SearchFilterConfig } from '@elastic/eui';
import type { Observable } from 'rxjs';
import type { FormattedRelative } from '@kbn/i18n-react';
import type { MountPoint, OverlayRef } from '@kbn/core-mount-utils-browser';
@ -15,6 +14,9 @@ import type { OverlayFlyoutOpenOptions } from '@kbn/core-overlays-browser';
import { RedirectAppLinksKibanaProvider } from '@kbn/shared-ux-link-redirect-app';
import { InspectorKibanaProvider } from '@kbn/content-management-inspector';
import { TAG_MANAGEMENT_APP_URL } from './constants';
import type { Tag } from './types';
type NotifyFn = (title: JSX.Element, text?: string) => void;
export interface SavedObjectsReference {
@ -30,6 +32,12 @@ export type DateFormatter = (props: {
children: (formattedDate: string) => JSX.Element;
}) => JSX.Element;
export interface TagListProps {
references: SavedObjectsReference[];
onClick?: (tag: Tag) => void;
tagRender?: (tag: Tag) => JSX.Element;
}
/**
* Abstract external services for this component.
*/
@ -42,12 +50,16 @@ export interface Services {
searchQueryParser?: (searchQuery: string) => {
searchQuery: string;
references?: SavedObjectsFindOptionsReference[];
referencesToExclude?: SavedObjectsFindOptionsReference[];
};
getSearchBarFilters?: () => SearchFilterConfig[];
DateFormatterComp?: DateFormatter;
TagList: FC<{ references: SavedObjectsReference[]; onClick?: (tag: { name: string }) => void }>;
/** Predicate function to indicate if the saved object references include tags */
/** Handler to retrieve the list of available tags */
getTagList: () => Tag[];
TagList: FC<TagListProps>;
/** Predicate function to indicate if some of the saved object references are tags */
itemHasTags: (references: SavedObjectsReference[]) => boolean;
/** Handler to return the url to navigate to the kibana tags management */
getTagManagementUrl: () => string;
getTagIdsFromReferences: (references: SavedObjectsReference[]) => string[];
}
@ -81,6 +93,11 @@ export interface TableListViewKibanaDependencies {
addDanger: (notifyArgs: { title: MountPoint; text?: string }) => void;
};
};
http: {
basePath: {
prepend: (path: string) => string;
};
};
overlays: {
openFlyout(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef;
};
@ -111,7 +128,8 @@ export interface TableListViewKibanaDependencies {
object: {
references: SavedObjectsReference[];
};
onClick?: (tag: { name: string; description: string; color: string }) => void;
onClick?: (tag: Tag) => void;
tagRender?: (tag: Tag) => JSX.Element;
}>;
SavedObjectSaveModalTagSelector: React.FC<{
initialSelection: string[];
@ -127,12 +145,10 @@ export interface TableListViewKibanaDependencies {
) => {
searchTerm: string;
tagReferences: SavedObjectsFindOptionsReference[];
tagReferencesToExclude: SavedObjectsFindOptionsReference[];
valid: boolean;
};
getSearchBarFilter: (options?: {
useName?: boolean;
tagField?: string;
}) => SearchFilterConfig;
getTagList: () => Tag[];
getTagIdsFromReferences: (references: SavedObjectsReference[]) => string[];
};
};
@ -149,12 +165,6 @@ export const TableListViewKibanaProvider: FC<TableListViewKibanaDependencies> =
}) => {
const { core, toMountPoint, savedObjectsTagging, FormattedRelative } = services;
const getSearchBarFilters = useMemo(() => {
if (savedObjectsTagging) {
return () => [savedObjectsTagging.ui.getSearchBarFilter({ useName: true })];
}
}, [savedObjectsTagging]);
const searchQueryParser = useMemo(() => {
if (savedObjectsTagging) {
return (searchQuery: string) => {
@ -162,18 +172,19 @@ export const TableListViewKibanaProvider: FC<TableListViewKibanaDependencies> =
return {
searchQuery: res.searchTerm,
references: res.tagReferences,
referencesToExclude: res.tagReferencesToExclude,
};
};
}
}, [savedObjectsTagging]);
const TagList = useMemo(() => {
const Comp: Services['TagList'] = ({ references, onClick }) => {
const Comp: Services['TagList'] = ({ references, onClick, tagRender }) => {
if (!savedObjectsTagging?.ui.components.TagList) {
return null;
}
const PluginTagList = savedObjectsTagging.ui.components.TagList;
return <PluginTagList object={{ references }} onClick={onClick} />;
return <PluginTagList object={{ references }} onClick={onClick} tagRender={tagRender} />;
};
return Comp;
@ -190,6 +201,14 @@ export const TableListViewKibanaProvider: FC<TableListViewKibanaDependencies> =
[savedObjectsTagging?.ui]
);
const getTagList = useCallback(() => {
if (!savedObjectsTagging?.ui.getTagList) {
return [];
}
return savedObjectsTagging.ui.getTagList();
}, [savedObjectsTagging?.ui]);
const itemHasTags = useCallback(
(references: SavedObjectsReference[]) => {
return getTagIdsFromReferences(references).length > 0;
@ -214,14 +233,15 @@ export const TableListViewKibanaProvider: FC<TableListViewKibanaDependencies> =
notifyError={(title, text) => {
core.notifications.toasts.addDanger({ title: toMountPoint(title), text });
}}
getSearchBarFilters={getSearchBarFilters}
searchQueryParser={searchQueryParser}
DateFormatterComp={(props) => <FormattedRelative {...props} />}
currentAppId$={core.application.currentAppId$}
navigateToUrl={core.application.navigateToUrl}
getTagList={getTagList}
TagList={TagList}
itemHasTags={itemHasTags}
getTagIdsFromReferences={getTagIdsFromReferences}
getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)}
>
{children}
</TableListViewProvider>

View file

@ -52,21 +52,28 @@ const itemTypes = ['foo', 'bar', 'baz', 'elastic'];
const mockItems: UserContentCommonSchema[] = createMockItems(500);
export const ConnectedComponent = (params: Params) => {
const findItems = (searchQuery: string) => {
const hits = mockItems
.filter((_, i) => i < params.numberOfItemsToRender)
.filter((item) => {
return (
item.attributes.title.includes(searchQuery) ||
item.attributes.description?.includes(searchQuery)
);
});
return Promise.resolve({
total: hits.length,
hits,
});
};
return (
<TableListViewProvider {...getStoryServices(params, action)}>
<Component
// Added key to force a refresh of the component state
key={`${params.initialFilter}-${params.initialPageSize}`}
findItems={(searchQuery) => {
const hits = mockItems
.filter((_, i) => i < params.numberOfItemsToRender)
.filter((item) => item.attributes.title.includes(searchQuery));
return Promise.resolve({
total: hits.length,
hits,
});
}}
findItems={findItems}
getDetailViewLink={() => 'http://elastic.co'}
createItem={
params.canCreateItem

View file

@ -15,7 +15,11 @@ import type { ReactWrapper } from 'enzyme';
import { WithServices } from './__jest__';
import { getTagList } from './mocks';
import { TableListView, Props as TableListViewProps } from './table_list_view';
import {
TableListView,
Props as TableListViewProps,
UserContentCommonSchema,
} from './table_list_view';
const mockUseEffect = useEffect;
@ -115,23 +119,27 @@ describe('TableListView', () => {
const twoDaysAgoToString = new Date(twoDaysAgo.getTime()).toDateString();
const yesterday = new Date(new Date().setDate(new Date().getDate() - 1));
const yesterdayToString = new Date(yesterday.getTime()).toDateString();
const hits = [
const hits: UserContentCommonSchema[] = [
{
id: '123',
updatedAt: twoDaysAgo,
updatedAt: twoDaysAgo.toISOString(),
type: 'dashboard',
attributes: {
title: 'Item 1',
description: 'Item 1 description',
},
references: [],
},
{
id: '456',
// This is the latest updated and should come first in the table
updatedAt: yesterday,
updatedAt: yesterday.toISOString(),
type: 'dashboard',
attributes: {
title: 'Item 2',
description: 'Item 2 description',
},
references: [],
},
];
@ -150,8 +158,8 @@ describe('TableListView', () => {
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
['Item 2Item 2 descriptionelasticcloud', yesterdayToString], // Comes first as it is the latest updated
['Item 1Item 1 descriptionelasticcloud', twoDaysAgoToString],
['Item 2Item 2 description', yesterdayToString], // Comes first as it is the latest updated
['Item 1Item 1 description', twoDaysAgoToString],
]);
});
@ -160,7 +168,7 @@ describe('TableListView', () => {
const updatedAtValues: Moment[] = [];
const updatedHits = hits.map(({ id, attributes }, i) => {
const updatedHits = hits.map(({ id, attributes, references }, i) => {
const updatedAt = new Date(new Date().setDate(new Date().getDate() - (7 + i)));
updatedAtValues.push(moment(updatedAt));
@ -168,6 +176,7 @@ describe('TableListView', () => {
id,
updatedAt,
attributes,
references,
};
});
@ -187,8 +196,8 @@ describe('TableListView', () => {
expect(tableCellsValues).toEqual([
// Renders the datetime with this format: "July 28, 2022"
['Item 1Item 1 descriptionelasticcloud', updatedAtValues[0].format('LL')],
['Item 2Item 2 descriptionelasticcloud', updatedAtValues[1].format('LL')],
['Item 1Item 1 description', updatedAtValues[0].format('LL')],
['Item 2Item 2 description', updatedAtValues[1].format('LL')],
]);
});
@ -200,7 +209,7 @@ describe('TableListView', () => {
findItems: jest.fn().mockResolvedValue({
total: hits.length,
// Not including the "updatedAt" metadata
hits: hits.map(({ attributes }) => ({ attributes })),
hits: hits.map(({ attributes, references }) => ({ attributes, references })),
}),
});
});
@ -211,8 +220,8 @@ describe('TableListView', () => {
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
['Item 1Item 1 descriptionelasticcloud'], // Sorted by title
['Item 2Item 2 descriptionelasticcloud'],
['Item 1Item 1 description'], // Sorted by title
['Item 2Item 2 description'],
]);
});
@ -225,7 +234,11 @@ describe('TableListView', () => {
total: hits.length + 1,
hits: [
...hits,
{ id: '789', attributes: { title: 'Item 3', description: 'Item 3 description' } },
{
id: '789',
attributes: { title: 'Item 3', description: 'Item 3 description' },
references: [],
},
],
}),
});
@ -237,9 +250,9 @@ describe('TableListView', () => {
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
['Item 2Item 2 descriptionelasticcloud', yesterdayToString],
['Item 1Item 1 descriptionelasticcloud', twoDaysAgoToString],
['Item 3Item 3 descriptionelasticcloud', '-'], // Empty column as no updatedAt provided
['Item 2Item 2 description', yesterdayToString],
['Item 1Item 1 description', twoDaysAgoToString],
['Item 3Item 3 description', '-'], // Empty column as no updatedAt provided
]);
});
});
@ -248,10 +261,14 @@ describe('TableListView', () => {
const initialPageSize = 20;
const totalItems = 30;
const hits = [...Array(totalItems)].map((_, i) => ({
const hits: UserContentCommonSchema[] = [...Array(totalItems)].map((_, i) => ({
id: `item${i}`,
type: 'dashboard',
updatedAt: new Date().toISOString(),
attributes: {
title: `Item ${i < 10 ? `0${i}` : i}`, // prefix with "0" for correct A-Z sorting
},
references: [],
}));
const props = {
@ -275,8 +292,8 @@ describe('TableListView', () => {
const [[firstRowTitle]] = tableCellsValues;
const [lastRowTitle] = tableCellsValues[tableCellsValues.length - 1];
expect(firstRowTitle).toBe('Item 00elasticcloud');
expect(lastRowTitle).toBe('Item 19elasticcloud');
expect(firstRowTitle).toBe('Item 00');
expect(lastRowTitle).toBe('Item 19');
});
test('should navigate to page 2', async () => {
@ -304,38 +321,48 @@ describe('TableListView', () => {
const [[firstRowTitle]] = tableCellsValues;
const [lastRowTitle] = tableCellsValues[tableCellsValues.length - 1];
expect(firstRowTitle).toBe('Item 20elasticcloud');
expect(lastRowTitle).toBe('Item 29elasticcloud');
expect(firstRowTitle).toBe('Item 20');
expect(lastRowTitle).toBe('Item 29');
});
});
describe('column sorting', () => {
const setupColumnSorting = registerTestBed<string, TableListViewProps>(
WithServices<TableListViewProps>(TableListView, { TagList: getTagList({ tags: null }) }),
WithServices<TableListViewProps>(TableListView, { TagList: getTagList({ references: [] }) }),
{
defaultProps: { ...requiredProps },
memoryRouter: { wrapComponent: false },
}
);
const getActions = (testBed: TestBed) => ({
openSortSelect() {
testBed.find('tableSortSelectBtn').at(0).simulate('click');
},
});
const twoDaysAgo = new Date(new Date().setDate(new Date().getDate() - 2));
const twoDaysAgoToString = new Date(twoDaysAgo.getTime()).toDateString();
const yesterday = new Date(new Date().setDate(new Date().getDate() - 1));
const yesterdayToString = new Date(yesterday.getTime()).toDateString();
const hits = [
const hits: UserContentCommonSchema[] = [
{
id: '123',
updatedAt: twoDaysAgo, // first asc, last desc
updatedAt: twoDaysAgo.toISOString(), // first asc, last desc
type: 'dashboard',
attributes: {
title: 'z-foo', // first desc, last asc
},
references: [{ id: 'id-tag-1', name: 'tag-1', type: 'tag' }],
},
{
id: '456',
updatedAt: yesterday, // first desc, last asc
updatedAt: yesterday.toISOString(), // first desc, last asc
type: 'dashboard',
attributes: {
title: 'a-foo', // first asc, last desc
},
references: [],
},
];
@ -367,11 +394,12 @@ describe('TableListView', () => {
findItems: jest.fn().mockResolvedValue({ total: hits.length, hits }),
});
});
const { openSortSelect } = getActions(testBed!);
const { component, find } = testBed!;
component.update();
act(() => {
find('tableSortSelectBtn').simulate('click');
openSortSelect();
});
component.update();
@ -396,6 +424,7 @@ describe('TableListView', () => {
});
const { component, table, find } = testBed!;
const { openSortSelect } = getActions(testBed!);
component.update();
let { tableCellsValues } = table.getMetaData('itemsInMemTable');
@ -406,7 +435,7 @@ describe('TableListView', () => {
]);
act(() => {
find('tableSortSelectBtn').simulate('click');
openSortSelect();
});
component.update();
const filterOptions = find('sortSelect').find('li');
@ -451,10 +480,11 @@ describe('TableListView', () => {
});
const { component, table, find } = testBed!;
const { openSortSelect } = getActions(testBed!);
component.update();
act(() => {
find('tableSortSelectBtn').simulate('click');
openSortSelect();
});
component.update();
let filterOptions = find('sortSelect').find('li');
@ -493,7 +523,7 @@ describe('TableListView', () => {
]);
act(() => {
find('tableSortSelectBtn').simulate('click');
openSortSelect();
});
component.update();
filterOptions = find('sortSelect').find('li');
@ -516,22 +546,26 @@ describe('TableListView', () => {
}
);
const hits = [
const hits: UserContentCommonSchema[] = [
{
id: '123',
updatedAt: new Date(new Date().setDate(new Date().getDate() - 1)),
updatedAt: new Date(new Date().setDate(new Date().getDate() - 1)).toISOString(),
attributes: {
title: 'Item 1',
description: 'Item 1 description',
},
references: [],
type: 'dashboard',
},
{
id: '456',
updatedAt: new Date(new Date().setDate(new Date().getDate() - 2)),
updatedAt: new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(),
attributes: {
title: 'Item 2',
description: 'Item 2 description',
},
references: [],
type: 'dashboard',
},
];
@ -553,4 +587,154 @@ describe('TableListView', () => {
expect(tableCellsValues[1][2]).toBe('Inspect Item 2');
});
});
describe('tag filtering', () => {
const setupTagFiltering = registerTestBed<string, TableListViewProps>(
WithServices<TableListViewProps>(TableListView, {
getTagList: () => [
{ id: 'id-tag-1', name: 'tag-1', type: 'tag', description: '', color: '' },
{ id: 'id-tag-2', name: 'tag-2', type: 'tag', description: '', color: '' },
{ id: 'id-tag-3', name: 'tag-3', type: 'tag', description: '', color: '' },
{ id: 'id-tag-4', name: 'tag-4', type: 'tag', description: '', color: '' },
],
}),
{
defaultProps: { ...requiredProps },
memoryRouter: { wrapComponent: false },
}
);
const hits: UserContentCommonSchema[] = [
{
id: '123',
updatedAt: new Date(new Date().setDate(new Date().getDate() - 1)).toISOString(),
type: 'dashboard',
attributes: {
title: 'Item 1',
description: 'Item 1 description',
},
references: [
{ id: 'id-tag-1', name: 'tag-1', type: 'tag' },
{ id: 'id-tag-2', name: 'tag-2', type: 'tag' },
],
},
{
id: '456',
updatedAt: new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(),
type: 'dashboard',
attributes: {
title: 'Item 2',
description: 'Item 2 description',
},
references: [],
},
];
test('should filter by tag from the table', async () => {
let testBed: TestBed;
const findItems = jest.fn().mockResolvedValue({ total: hits.length, hits });
await act(async () => {
testBed = await setupTagFiltering({
findItems,
});
});
const { component, table, find } = testBed!;
component.update();
const getSearchBoxValue = () => find('tableListSearchBox').props().defaultValue;
const getLastCallArgsFromFindItems = () =>
findItems.mock.calls[findItems.mock.calls.length - 1];
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
// "tag-1" and "tag-2" are rendered in the column
expect(tableCellsValues[0][0]).toBe('Item 1Item 1 descriptiontag-1tag-2');
await act(async () => {
find('tag-id-tag-1').simulate('click');
});
component.update();
// The search bar should be updated
let expected = 'tag:(tag-1)';
let [searchTerm] = getLastCallArgsFromFindItems();
expect(getSearchBoxValue()).toBe(expected);
expect(searchTerm).toBe(expected);
await act(async () => {
find('tag-id-tag-2').simulate('click');
});
component.update();
expected = 'tag:(tag-1 or tag-2)';
[searchTerm] = getLastCallArgsFromFindItems();
expect(getSearchBoxValue()).toBe(expected);
expect(searchTerm).toBe(expected);
// Ctrl + click on a tag
await act(async () => {
find('tag-id-tag-2').simulate('click', { ctrlKey: true });
});
component.update();
expected = 'tag:(tag-1) -tag:(tag-2)';
[searchTerm] = getLastCallArgsFromFindItems();
expect(getSearchBoxValue()).toBe(expected);
expect(searchTerm).toBe(expected);
});
test('should filter by tag from the search bar filter', async () => {
let testBed: TestBed;
const findItems = jest.fn().mockResolvedValue({ total: hits.length, hits });
await act(async () => {
testBed = await setupTagFiltering({
findItems,
});
});
const { component, find, exists } = testBed!;
component.update();
const getSearchBoxValue = () => find('tableListSearchBox').props().defaultValue;
const getLastCallArgsFromFindItems = () =>
findItems.mock.calls[findItems.mock.calls.length - 1];
const openTagFilterDropdown = async () => {
await act(async () => {
find('tagFilterPopoverButton').simulate('click');
});
component.update();
};
await openTagFilterDropdown();
expect(exists('tagSelectableList')).toBe(true);
await act(async () => {
find('tag-searchbar-option-tag-1').simulate('click');
});
component.update();
// The search bar should be updated and search term sent to the findItems() handler
let expected = 'tag:(tag-1)';
let [searchTerm] = getLastCallArgsFromFindItems();
expect(getSearchBoxValue()).toBe(expected);
expect(searchTerm).toBe(expected);
// Ctrl + click one item
await act(async () => {
find('tag-searchbar-option-tag-2').simulate('click', { ctrlKey: true });
});
component.update();
expected = 'tag:(tag-1) -tag:(tag-2)';
[searchTerm] = getLastCallArgsFromFindItems();
expect(getSearchBoxValue()).toBe(expected);
expect(searchTerm).toBe(expected);
});
});
});

View file

@ -18,6 +18,8 @@ import {
EuiSpacer,
EuiTableActionsColumnType,
CriteriaWithPagination,
Query,
Ast,
} from '@elastic/eui';
import { keyBy, uniq, get } from 'lodash';
import { i18n } from '@kbn/i18n';
@ -39,6 +41,7 @@ import type { SavedObjectsReference, SavedObjectsFindOptionsReference } from './
import type { Action } from './actions';
import { getReducer } from './reducer';
import type { SortColumnField } from './components';
import { useTags } from './use_tags';
interface InspectorConfig extends Pick<OpenInspectorParams, 'isReadonly' | 'onSave'> {
enabled?: boolean;
@ -49,7 +52,7 @@ export interface Props<T extends UserContentCommonSchema = UserContentCommonSche
entityNamePlural: string;
tableListTitle: string;
listingLimit: number;
initialFilter: string;
initialFilter?: string;
initialPageSize: number;
emptyPrompt?: JSX.Element;
/** Add an additional custom column */
@ -64,7 +67,10 @@ export interface Props<T extends UserContentCommonSchema = UserContentCommonSche
children?: ReactNode | undefined;
findItems(
searchQuery: string,
references?: SavedObjectsFindOptionsReference[]
refs?: {
references?: SavedObjectsFindOptionsReference[];
referencesToExclude?: SavedObjectsFindOptionsReference[];
}
): Promise<{ total: number; hits: T[] }>;
/** Handler to set the item title "href" value. If it returns undefined there won't be a link for this item. */
getDetailViewLink?: (entity: T) => string | undefined;
@ -83,7 +89,10 @@ export interface State<T extends UserContentCommonSchema = UserContentCommonSche
isDeletingItems: boolean;
showDeleteModal: boolean;
fetchError?: IHttpFetchError<Error>;
searchQuery: string;
searchQuery: {
text: string;
query: Query;
};
selectedIds: string[];
totalItems: number;
hasUpdatedAtMetadata: boolean;
@ -105,6 +114,8 @@ export interface UserContentCommonSchema {
};
}
const ast = Ast.create([]);
function TableListViewComp<T extends UserContentCommonSchema>({
tableListTitle,
entityName,
@ -170,7 +181,10 @@ function TableListViewComp<T extends UserContentCommonSchema>({
showDeleteModal: false,
hasUpdatedAtMetadata: false,
selectedIds: [],
searchQuery: initialQuery,
searchQuery:
initialQuery !== undefined
? { text: initialQuery, query: new Query(ast, undefined, initialQuery) }
: { text: '', query: new Query(ast, undefined, '') },
pagination: {
pageIndex: 0,
totalItemCount: 0,
@ -197,11 +211,31 @@ function TableListViewComp<T extends UserContentCommonSchema>({
pagination,
tableSort,
} = state;
const hasNoItems = !isFetchingItems && items.length === 0 && !searchQuery;
const hasQuery = searchQuery.text !== '';
const hasNoItems = !isFetchingItems && items.length === 0 && !hasQuery;
const pageDataTestSubject = `${entityName}LandingPage`;
const showFetchError = Boolean(fetchError);
const showLimitError = !showFetchError && totalItems > listingLimit;
const updateQuery = useCallback((query: Query) => {
dispatch({
type: 'onSearchQueryChange',
data: { query, text: query.text },
});
}, []);
const {
addOrRemoveIncludeTagFilter,
addOrRemoveExcludeTagFilter,
clearTagSelection,
tagsToTableItemMap,
} = useTags({
query: searchQuery.query,
updateQuery,
items,
});
const inspectItem = useCallback(
(item: T) => {
const tags = getTagIdsFromReferences(item.references).map((_id) => {
@ -237,7 +271,14 @@ function TableListViewComp<T extends UserContentCommonSchema>({
item={record}
getDetailViewLink={getDetailViewLink}
onClickTitle={onClickTitle}
searchTerm={searchQuery}
onClickTag={(tag, withModifierKey) => {
if (withModifierKey) {
addOrRemoveExcludeTagFilter(tag);
} else {
addOrRemoveIncludeTagFilter(tag);
}
}}
searchTerm={searchQuery.text}
/>
);
},
@ -328,7 +369,9 @@ function TableListViewComp<T extends UserContentCommonSchema>({
id,
getDetailViewLink,
onClickTitle,
searchQuery,
searchQuery.text,
addOrRemoveIncludeTagFilter,
addOrRemoveExcludeTagFilter,
DateFormatterComp,
inspector,
inspectItem,
@ -351,11 +394,15 @@ function TableListViewComp<T extends UserContentCommonSchema>({
try {
const idx = ++fetchIdx.current;
const { searchQuery: searchQueryParsed, references } = searchQueryParser
? searchQueryParser(searchQuery)
: { searchQuery, references: undefined };
const {
searchQuery: searchQueryParsed,
references,
referencesToExclude,
} = searchQueryParser
? searchQueryParser(searchQuery.text)
: { searchQuery: searchQuery.text, references: undefined, referencesToExclude: undefined };
const response = await findItems(searchQueryParsed, references);
const response = await findItems(searchQueryParsed, { references, referencesToExclude });
if (!isMounted.current) {
return;
@ -504,7 +551,7 @@ function TableListViewComp<T extends UserContentCommonSchema>({
return null;
}
if (!fetchError && hasNoItems) {
if (!showFetchError && hasNoItems) {
return (
<KibanaPageTemplate panelled isEmptyState={true} data-test-subj={pageDataTestSubject}>
<KibanaPageTemplate.Section
@ -554,10 +601,14 @@ function TableListViewComp<T extends UserContentCommonSchema>({
selectedIds={selectedIds}
entityName={entityName}
entityNamePlural={entityNamePlural}
tagsToTableItemMap={tagsToTableItemMap}
deleteItems={deleteItems}
tableCaption={tableListTitle}
onTableChange={onTableChange}
onSortChange={onSortChange}
addOrRemoveIncludeTagFilter={addOrRemoveIncludeTagFilter}
addOrRemoveExcludeTagFilter={addOrRemoveExcludeTagFilter}
clearTagSelection={clearTagSelection}
/>
{/* Delete modal */}

View file

@ -0,0 +1,14 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export interface Tag {
id?: string;
name: string;
description: string;
color: string;
}

View file

@ -0,0 +1,159 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useCallback, useMemo } from 'react';
import { Query } from '@elastic/eui';
import type { Tag } from './types';
import type { UserContentCommonSchema } from './table_list_view';
type QueryUpdater = (query: Query, tag: Tag) => Query;
export function useTags({
query,
updateQuery,
items,
}: {
query: Query;
updateQuery: (query: Query) => void;
items: UserContentCommonSchema[];
}) {
// Return a map of tag.id to an array of saved object ids having that tag
// { 'abc-123': ['saved_object_id_1', 'saved_object_id_2', ...] }
const tagsToTableItemMap = useMemo(() => {
return items.reduce((acc, item) => {
const tagReferences = item.references.filter((ref) => ref.type === 'tag');
if (tagReferences.length > 0) {
tagReferences.forEach((ref) => {
if (!acc[ref.id]) {
acc[ref.id] = [];
}
acc[ref.id].push(item.id);
});
}
return acc;
}, {} as { [tagId: string]: string[] });
}, [items]);
const updateTagClauseGetter = useCallback(
(queryUpdater: QueryUpdater) =>
(tag: Tag, q?: Query, doUpdate: boolean = true) => {
const updatedQuery = queryUpdater(q !== undefined ? q : query, tag);
if (doUpdate) {
updateQuery(updatedQuery);
}
return updatedQuery;
},
[query, updateQuery]
);
const hasTagInClauseGetter = useCallback(
(matchValue: 'must' | 'must_not') => (tag: Tag, _query?: Query) => {
const q = Boolean(_query) ? _query! : query;
const tagsClauses = q.ast.getFieldClauses('tag');
if (tagsClauses) {
const mustHaveTagClauses = q.ast
.getFieldClauses('tag')
.find(({ match }) => match === matchValue)?.value as string[];
if (mustHaveTagClauses && mustHaveTagClauses.includes(tag.name)) {
return true;
}
}
return false;
},
[query]
);
const addTagToIncludeClause = useMemo(
() => updateTagClauseGetter((q, tag) => q.addOrFieldValue('tag', tag.name, true, 'eq')),
[updateTagClauseGetter]
);
const removeTagFromIncludeClause = useMemo(
() => updateTagClauseGetter((q, tag) => q.removeOrFieldValue('tag', tag.name)),
[updateTagClauseGetter]
);
const addTagToExcludeClause = useMemo(
() => updateTagClauseGetter((q, tag) => q.addOrFieldValue('tag', tag.name, false, 'eq')),
[updateTagClauseGetter]
);
const removeTagFromExcludeClause = useMemo(
() => updateTagClauseGetter((q, tag) => q.removeOrFieldValue('tag', tag.name)),
[updateTagClauseGetter]
);
const hasTagInInclude = useMemo(() => hasTagInClauseGetter('must'), [hasTagInClauseGetter]);
const hasTagInExclude = useMemo(() => hasTagInClauseGetter('must_not'), [hasTagInClauseGetter]);
const addOrRemoveIncludeTagFilter = useCallback(
(tag: Tag) => {
let q: Query | undefined;
// Remove the tag in the "Exclude" list if it is there
if (hasTagInExclude(tag)) {
q = removeTagFromExcludeClause(tag, undefined, false);
} else if (hasTagInInclude(tag, q)) {
// Already selected, remove the filter
removeTagFromIncludeClause(tag, q);
return;
}
addTagToIncludeClause(tag, q);
},
[
hasTagInExclude,
hasTagInInclude,
removeTagFromExcludeClause,
addTagToIncludeClause,
removeTagFromIncludeClause,
]
);
const addOrRemoveExcludeTagFilter = useCallback(
(tag: Tag) => {
let q: Query | undefined;
// Remove the tag in the "Include" list if it is there
if (hasTagInInclude(tag)) {
q = removeTagFromIncludeClause(tag, undefined, false);
}
if (hasTagInExclude(tag, q)) {
// Already selected, remove the filter
removeTagFromExcludeClause(tag, q);
return;
}
addTagToExcludeClause(tag, q);
},
[
hasTagInInclude,
hasTagInExclude,
removeTagFromIncludeClause,
addTagToExcludeClause,
removeTagFromExcludeClause,
]
);
const clearTagSelection = useCallback(() => {
const updatedQuery = query.removeOrFieldClauses('tag');
updateQuery(updatedQuery);
return updateQuery;
}, [query, updateQuery]);
return {
addOrRemoveIncludeTagFilter,
addOrRemoveExcludeTagFilter,
clearTagSelection,
tagsToTableItemMap,
};
}

View file

@ -70,6 +70,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
overlays,
savedObjectsTagging,
settings: { uiSettings },
http,
} = pluginServices.getServices();
let globalEmbedSettings: DashboardEmbedSettings | undefined;
@ -172,6 +173,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
core: {
application: application as TableListViewApplicationService,
notifications,
http,
overlays,
},
toMountPoint,

View file

@ -32,7 +32,7 @@ function mountWith({ props: incomingProps }: { props?: DashboardListingProps })
const wrappingComponent: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const { application, notifications, savedObjectsTagging, overlays } =
const { application, notifications, savedObjectsTagging, http, overlays } =
pluginServices.getServices();
return (
@ -42,6 +42,7 @@ function mountWith({ props: incomingProps }: { props?: DashboardListingProps })
application:
application as unknown as TableListViewKibanaDependencies['core']['application'],
notifications,
http,
overlays,
}}
savedObjectsTagging={

View file

@ -261,12 +261,22 @@ export const DashboardListing = ({
]);
const fetchItems = useCallback(
(searchTerm: string, references?: SavedObjectsFindOptionsReference[]) => {
(
searchTerm: string,
{
references,
referencesToExclude,
}: {
references?: SavedObjectsFindOptionsReference[];
referencesToExclude?: SavedObjectsFindOptionsReference[];
} = {}
) => {
return findDashboards
.findSavedObjects({
search: searchTerm,
size: listingLimit,
hasReference: references,
hasNoReference: referencesToExclude,
})
.then(({ total, hits }) => {
return {

View file

@ -50,8 +50,14 @@ export const dashboardSavedObjectServiceFactory: DashboardSavedObjectServiceFact
...requiredServices,
}),
findDashboards: {
findSavedObjects: ({ hasReference, search, size }) =>
findDashboardSavedObjects({ hasReference, search, size, savedObjectsClient }),
findSavedObjects: ({ hasReference, hasNoReference, search, size }) =>
findDashboardSavedObjects({
hasReference,
hasNoReference,
search,
size,
savedObjectsClient,
}),
findByIds: (ids) => findDashboardSavedObjectsByIds(savedObjectsClient, ids),
findByTitle: (title) => findDashboardIdByTitle(title, savedObjectsClient),
},

View file

@ -18,6 +18,7 @@ import type { DashboardAttributes } from '../../../application';
export interface FindDashboardSavedObjectsArgs {
hasReference?: SavedObjectsFindOptionsReference[];
hasNoReference?: SavedObjectsFindOptionsReference[];
savedObjectsClient: SavedObjectsClientContract;
search: string;
size: number;
@ -31,6 +32,7 @@ export interface FindDashboardSavedObjectsResponse {
export async function findDashboardSavedObjects({
savedObjectsClient,
hasReference,
hasNoReference,
search,
size,
}: FindDashboardSavedObjectsArgs): Promise<FindDashboardSavedObjectsResponse> {
@ -41,6 +43,7 @@ export async function findDashboardSavedObjects({
defaultSearchOperator: 'AND' as 'AND',
perPage: size,
hasReference,
hasNoReference,
page: 1,
});
return {

View file

@ -53,7 +53,10 @@ export interface DashboardSavedObjectService {
) => Promise<SaveDashboardReturn>;
findDashboards: {
findSavedObjects: (
props: Pick<FindDashboardSavedObjectsArgs, 'hasReference' | 'search' | 'size'>
props: Pick<
FindDashboardSavedObjectsArgs,
'hasReference' | 'hasNoReference' | 'search' | 'size'
>
) => Promise<FindDashboardSavedObjectsResponse>;
findByIds: (ids: string[]) => Promise<FindDashboardBySavedObjectIdsResult[]>;
findByTitle: (title: string) => Promise<{ id: string } | undefined>;

View file

@ -33,6 +33,7 @@ export const savedObjectsTaggingServiceFactory: SavedObjectsTaggingServiceFactor
updateTagsReferences,
getTagIdsFromReferences,
getTableColumnDefinition,
getTagList,
},
} = taggingApi;
@ -45,5 +46,6 @@ export const savedObjectsTaggingServiceFactory: SavedObjectsTaggingServiceFactor
updateTagsReferences,
getTagIdsFromReferences,
getTableColumnDefinition,
getTagList,
};
};

View file

@ -18,4 +18,5 @@ export interface DashboardSavedObjectsTaggingService {
updateTagsReferences?: SavedObjectsTaggingApi['ui']['updateTagsReferences'];
getTagIdsFromReferences?: SavedObjectsTaggingApi['ui']['getTagIdsFromReferences'];
getTableColumnDefinition?: SavedObjectsTaggingApi['ui']['getTableColumnDefinition'];
getTagList?: SavedObjectsTaggingApi['ui']['getTagList'];
}

View file

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

View file

@ -66,6 +66,10 @@ export interface SavedObjectsTaggingApiUi {
* @param tagId
*/
getTag(tagId: string): Tag | undefined;
/**
* Return a list of available tags
*/
getTagList(): Tag[];
/**
* Type-guard to safely manipulate tag-enhanced `SavedObject` from the `savedObject` plugin.
@ -222,6 +226,10 @@ export interface TagListComponentProps {
* Handler to execute when clicking on a tag
*/
onClick?: (tag: TagWithOptionalId) => void;
/**
* Handler to render the tag
*/
tagRender?: (tag: TagWithOptionalId) => JSX.Element;
}
/**
@ -321,6 +329,7 @@ export interface GetSearchBarFilterOptions {
export interface ParsedSearchQuery {
searchTerm: string;
tagReferences: SavedObjectsFindOptionsReference[];
tagReferencesToExclude: SavedObjectsFindOptionsReference[];
valid: boolean;
}

View file

@ -158,7 +158,8 @@ export async function findListItems(
visTypes: Pick<TypesStart, 'get' | 'getAliases'>,
search: string,
size: number,
references?: SavedObjectsFindOptionsReference[]
references?: SavedObjectsFindOptionsReference[],
referencesToExclude?: SavedObjectsFindOptionsReference[]
) {
const visAliases = visTypes.getAliases();
const extensions = visAliases
@ -180,6 +181,7 @@ export async function findListItems(
page: 1,
defaultSearchOperator: 'AND' as 'AND',
hasReference: references,
hasNoReference: referencesToExclude,
};
const { total, savedObjects } = await savedObjectsClient.find<SavedObjectAttributes>(

View file

@ -148,14 +148,24 @@ export const VisualizeListing = () => {
const noItemsFragment = useMemo(() => getNoItemsMessage(createNewVis), [createNewVis]);
const fetchItems = useCallback(
(searchTerm: string, references?: SavedObjectsFindOptionsReference[]) => {
(
searchTerm: string,
{
references,
referencesToExclude,
}: {
references?: SavedObjectsFindOptionsReference[];
referencesToExclude?: SavedObjectsFindOptionsReference[];
} = {}
) => {
const isLabsEnabled = uiSettings.get(VISUALIZE_ENABLE_LABS_SETTING);
return findListItems(
savedObjects.client,
getTypes(),
searchTerm,
listingLimit,
references
references,
referencesToExclude
).then(({ total, hits }: { total: number; hits: Array<Record<string, unknown>> }) => ({
total,
hits: hits

View file

@ -21,19 +21,13 @@ import { GraphServices } from '../application';
const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit';
const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage';
interface GraphUserContent extends UserContentCommonSchema {
type: string;
attributes: {
title: string;
description?: string;
};
}
type GraphUserContent = UserContentCommonSchema;
const toTableListViewSavedObject = (savedObject: GraphWorkspaceSavedObject): GraphUserContent => {
return {
id: savedObject.id!,
updatedAt: savedObject.updatedAt!,
references: savedObject.references,
references: savedObject.references ?? [],
type: savedObject.type,
attributes: {
title: savedObject.title,

View file

@ -65,7 +65,16 @@ const toTableListViewSavedObject = (
};
};
async function findMaps(searchTerm: string, tagReferences?: SavedObjectsFindOptionsReference[]) {
async function findMaps(
searchTerm: string,
{
references,
referencesToExclude,
}: {
references?: SavedObjectsFindOptionsReference[];
referencesToExclude?: SavedObjectsFindOptionsReference[];
} = {}
) {
const resp = await getSavedObjectsClient().find<MapSavedObjectAttributes>({
type: MAP_SAVED_OBJECT_TYPE,
search: searchTerm ? `${searchTerm}*` : undefined,
@ -74,7 +83,8 @@ async function findMaps(searchTerm: string, tagReferences?: SavedObjectsFindOpti
searchFields: ['title^3', 'description'],
defaultSearchOperator: 'AND',
fields: ['description', 'title'],
hasReference: tagReferences,
hasReference: references,
hasNoReference: referencesToExclude,
});
return {

View file

@ -13,17 +13,22 @@ import { TagBadge } from './tag_badge';
export interface TagListProps {
tags: TagWithOptionalId[];
onClick?: (tag: TagWithOptionalId) => void;
tagRender?: (tag: TagWithOptionalId) => JSX.Element;
}
/**
* Displays a list of tag
*/
export const TagList: FC<TagListProps> = ({ tags, onClick }) => {
export const TagList: FC<TagListProps> = ({ tags, onClick, tagRender }) => {
return (
<EuiBadgeGroup>
{tags.map((tag) => (
<TagBadge key={tag.name} tag={tag} onClick={onClick} />
))}
{tags.map((tag) =>
tagRender ? (
<span key={tag.name}>{tagRender(tag)}</span>
) : (
<TagBadge key={tag.name} tag={tag} onClick={onClick} />
)
)}
</EuiBadgeGroup>
);
};

View file

@ -19,16 +19,22 @@ interface SavedObjectTagListProps {
object: { references: SavedObjectReference[] };
tags: Tag[];
onClick?: (tag: TagWithOptionalId) => void;
tagRender?: (tag: TagWithOptionalId) => JSX.Element;
}
const SavedObjectTagList: FC<SavedObjectTagListProps> = ({ object, tags: allTags, onClick }) => {
const SavedObjectTagList: FC<SavedObjectTagListProps> = ({
object,
tags: allTags,
onClick,
tagRender,
}) => {
const objectTags = useMemo(() => {
const { tags } = getObjectTags(object, allTags);
tags.sort(byNameTagSorter);
return tags;
}, [object, allTags]);
return <TagList tags={objectTags} onClick={onClick} />;
return <TagList tags={objectTags} onClick={onClick} tagRender={tagRender} />;
};
interface GetConnectedTagListOptions {

View file

@ -20,10 +20,12 @@ const expectTagOption = (tag: Tag, useName: boolean) => ({
describe('getSearchBarFilter', () => {
let cache: ReturnType<typeof tagsCacheMock.create>;
let getSearchBarFilter: SavedObjectsTaggingApiUi['getSearchBarFilter'];
let getTagList: () => Tag[];
beforeEach(() => {
cache = tagsCacheMock.create();
getSearchBarFilter = buildGetSearchBarFilter({ cache });
getTagList = () => cache.getState();
getSearchBarFilter = buildGetSearchBarFilter({ getTagList });
});
it('has the correct base configuration', () => {
@ -59,20 +61,6 @@ describe('getSearchBarFilter', () => {
expect(fetched).toEqual(tags.map((tag) => expectTagOption(tag, true)));
});
it('sorts the tags by name', async () => {
const tag1 = createTag({ id: 'id-1', name: 'aaa' });
const tag2 = createTag({ id: 'id-2', name: 'ccc' });
const tag3 = createTag({ id: 'id-3', name: 'bbb' });
cache.getState.mockReturnValue([tag1, tag2, tag3]);
// EUI types for filters are incomplete
const { options } = getSearchBarFilter() as any;
const fetched = await options();
expect(fetched).toEqual([tag1, tag3, tag2].map((tag) => expectTagOption(tag, true)));
});
it('uses the `useName` option', async () => {
const tags = [
createTag({ id: 'id-1', name: 'name-1' }),

View file

@ -11,16 +11,16 @@ import {
SavedObjectsTaggingApiUi,
GetSearchBarFilterOptions,
} from '@kbn/saved-objects-tagging-oss-plugin/public';
import { ITagsCache } from '../services';
import { Tag } from '../../common';
import { TagSearchBarOption } from '../components';
import { byNameTagSorter } from '../utils';
export interface BuildGetSearchBarFilterOptions {
cache: ITagsCache;
getTagList: () => Tag[];
}
export const buildGetSearchBarFilter = ({
cache,
getTagList,
}: BuildGetSearchBarFilterOptions): SavedObjectsTaggingApiUi['getSearchBarFilter'] => {
return ({ useName = true, tagField = 'tag' }: GetSearchBarFilterOptions = {}) => {
return {
@ -35,16 +35,13 @@ export const buildGetSearchBarFilter = ({
// everytime the filter is opened. That way we can keep in sync in case of tags
// that would be added without the searchbar having trigger a re-render.
return Promise.resolve(
cache
.getState()
.sort(byNameTagSorter)
.map((tag) => {
return {
value: useName ? tag.name : tag.id,
name: tag.name,
view: <TagSearchBarOption tag={tag} />,
};
})
getTagList().map((tag) => {
return {
value: useName ? tag.name : tag.id,
name: tag.name,
view: <TagSearchBarOption tag={tag} />,
};
})
);
},
};

View file

@ -0,0 +1,25 @@
/*
* 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 { createTag } from '../../common/test_utils';
import { tagsCacheMock } from '../services/tags/tags_cache.mock';
import { buildGetTagList } from './get_tag_list';
describe('getTagList', () => {
it('sorts the tags by name', async () => {
const tag1 = createTag({ id: 'id-1', name: 'aaa' });
const tag2 = createTag({ id: 'id-2', name: 'ccc' });
const tag3 = createTag({ id: 'id-3', name: 'bbb' });
const cache = tagsCacheMock.create();
cache.getState.mockReturnValue([tag1, tag2, tag3]);
const getTagList = buildGetTagList(cache);
const tags = getTagList();
expect(tags).toEqual([tag1, tag3, tag2]);
});
});

View file

@ -0,0 +1,11 @@
/*
* 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 { byNameTagSorter } from '../utils';
import type { ITagsCache } from '../services';
export const buildGetTagList = (cache: ITagsCache) => () => cache.getState().sort(byNameTagSorter);

View file

@ -20,6 +20,7 @@ import { buildGetTableColumnDefinition } from './get_table_column_definition';
import { buildGetSearchBarFilter } from './get_search_bar_filter';
import { buildParseSearchQuery } from './parse_search_query';
import { buildConvertNameToReference } from './convert_name_to_reference';
import { buildGetTagList } from './get_tag_list';
import { hasTagDecoration } from './has_tag_decoration';
interface GetUiApiOptions {
@ -39,10 +40,12 @@ export const getUiApi = ({
}: GetUiApiOptions): SavedObjectsTaggingApiUi => {
const components = getComponents({ cache, capabilities, overlays, theme, tagClient: client });
const getTagList = buildGetTagList(cache);
return {
components,
getTableColumnDefinition: buildGetTableColumnDefinition({ components, cache }),
getSearchBarFilter: buildGetSearchBarFilter({ cache }),
getSearchBarFilter: buildGetSearchBarFilter({ getTagList }),
parseSearchQuery: buildParseSearchQuery({ cache }),
convertNameToReference: buildConvertNameToReference({ cache }),
hasTagDecoration,
@ -50,5 +53,6 @@ export const getUiApi = ({
getTagIdFromName: (tagName: string) => convertTagNameToId(tagName, cache.getState()),
updateTagsReferences,
getTag: (tagId: string) => getTag(tagId, cache.getState()),
getTagList,
};
};

View file

@ -38,6 +38,7 @@ describe('parseSearchQuery', () => {
expect(parseSearchQuery(searchTerm)).toEqual({
searchTerm,
tagReferences: [],
tagReferencesToExclude: [],
valid: true,
});
});
@ -48,6 +49,7 @@ describe('parseSearchQuery', () => {
expect(parseSearchQuery(searchTerm)).toEqual({
searchTerm,
tagReferences: [],
tagReferencesToExclude: [],
valid: false,
});
});
@ -58,6 +60,18 @@ describe('parseSearchQuery', () => {
expect(parseSearchQuery(searchTerm, { useName: false })).toEqual({
searchTerm: 'my search term',
tagReferences: [tagRef('id-1'), tagRef('id-2')],
tagReferencesToExclude: [],
valid: true,
});
});
it('returns the tag references to exclude matching the tag field clause when using `useName: false`', () => {
const searchTerm = '-tag:(id-1 OR id-2) my search term';
expect(parseSearchQuery(searchTerm, { useName: false })).toEqual({
searchTerm: 'my search term',
tagReferences: [],
tagReferencesToExclude: [tagRef('id-1'), tagRef('id-2')],
valid: true,
});
});
@ -68,6 +82,18 @@ describe('parseSearchQuery', () => {
expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({
searchTerm: 'my search term',
tagReferences: [tagRef('id-1'), tagRef('id-2')],
tagReferencesToExclude: [],
valid: true,
});
});
it('returns the tag references to exclude matching the tag field clause when using `useName: true`', () => {
const searchTerm = '-tag:(name-1 OR name-2) my search term';
expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({
searchTerm: 'my search term',
tagReferences: [],
tagReferencesToExclude: [tagRef('id-1'), tagRef('id-2')],
valid: true,
});
});
@ -78,6 +104,7 @@ describe('parseSearchQuery', () => {
expect(parseSearchQuery(searchTerm, { tagField: 'custom' })).toEqual({
searchTerm: 'my search term',
tagReferences: [tagRef('id-1'), tagRef('id-2')],
tagReferencesToExclude: [],
valid: true,
});
});
@ -88,6 +115,7 @@ describe('parseSearchQuery', () => {
expect(parseSearchQuery(searchTerm, { useName: true })).toEqual({
searchTerm: 'my search term',
tagReferences: [tagRef('id-1')],
tagReferencesToExclude: [],
valid: true,
});
});

View file

@ -29,6 +29,7 @@ export const buildParseSearchQuery = ({
return {
searchTerm: query,
tagReferences: [],
tagReferencesToExclude: [],
valid: false,
};
}
@ -39,12 +40,12 @@ export const buildParseSearchQuery = ({
return {
searchTerm: '',
tagReferences: [],
tagReferencesToExclude: [],
valid: true,
};
}
let searchTerm: string = '';
let tagReferences: SavedObjectsFindOptionsReference[] = [];
if (parsed.ast.getTermClauses().length) {
searchTerm = parsed.ast
@ -52,26 +53,52 @@ export const buildParseSearchQuery = ({
.map((clause: any) => clause.value)
.join(' ');
}
let tagReferences: SavedObjectsFindOptionsReference[] = [];
let tagReferencesToExclude: SavedObjectsFindOptionsReference[] = [];
if (parsed.ast.getFieldClauses(tagField)) {
const selectedTags = parsed.ast.getFieldClauses(tagField)[0].value as string[];
if (useName) {
selectedTags.forEach((tagName) => {
const found = cache.getState().find((tag) => tag.name === tagName);
if (found) {
tagReferences.push({
type: 'tag',
id: found.id,
});
// The query can have clauses that either *must* match or *must_not* match
// We will retrieve the list of name for both list and convert them to references
const { selectedTags, excludedTags } = parsed.ast.getFieldClauses(tagField).reduce(
(acc, clause) => {
if (clause.match === 'must') {
acc.selectedTags = clause.value as string[];
} else if (clause.match === 'must_not') {
acc.excludedTags = clause.value as string[];
}
});
} else {
tagReferences = selectedTags.map((tagId) => ({ type: 'tag', id: tagId }));
}
return acc;
},
{ selectedTags: [], excludedTags: [] } as { selectedTags: string[]; excludedTags: string[] }
);
const tagsToReferences = (tagNames: string[]) => {
if (useName) {
const references: SavedObjectsFindOptionsReference[] = [];
tagNames.forEach((tagName) => {
const found = cache.getState().find((tag) => tag.name === tagName);
if (found) {
references.push({
type: 'tag',
id: found.id,
});
}
});
return references;
} else {
return tagNames.map((tagId) => ({ type: 'tag', id: tagId }));
}
};
tagReferences = tagsToReferences(selectedTags);
tagReferencesToExclude = tagsToReferences(excludedTags);
}
return {
searchTerm,
tagReferences,
tagReferencesToExclude,
valid: true,
};
};