mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[TableListView] Enhance tag filtering (#142108)
This commit is contained in:
parent
b72a9a3df2
commit
a67776b365
41 changed files with 1319 additions and 211 deletions
8
.github/CODEOWNERS
vendored
8
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -21,7 +21,9 @@ export const getMockServices = (overrides?: Partial<Services>) => {
|
|||
currentAppId$: from('mockedApp'),
|
||||
navigateToUrl: () => undefined,
|
||||
TagList,
|
||||
getTagList: () => [],
|
||||
itemHasTags: () => true,
|
||||
getTagManagementUrl: () => '',
|
||||
getTagIdsFromReferences: () => [],
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
@ -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> =
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
9
packages/content-management/table_list/src/constants.ts
Normal file
9
packages/content-management/table_list/src/constants.ts
Normal 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';
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 */}
|
||||
|
|
14
packages/content-management/table_list/src/types.ts
Normal file
14
packages/content-management/table_list/src/types.ts
Normal 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;
|
||||
}
|
159
packages/content-management/table_list/src/use_tags.ts
Normal file
159
packages/content-management/table_list/src/use_tags.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -18,4 +18,5 @@ export interface DashboardSavedObjectsTaggingService {
|
|||
updateTagsReferences?: SavedObjectsTaggingApi['ui']['updateTagsReferences'];
|
||||
getTagIdsFromReferences?: SavedObjectsTaggingApi['ui']['getTagIdsFromReferences'];
|
||||
getTableColumnDefinition?: SavedObjectsTaggingApi['ui']['getTableColumnDefinition'];
|
||||
getTagList?: SavedObjectsTaggingApi['ui']['getTagList'];
|
||||
}
|
||||
|
|
|
@ -63,6 +63,7 @@ const createApiUiMock = () => {
|
|||
getTagIdFromName: jest.fn(),
|
||||
updateTagsReferences: jest.fn(),
|
||||
getTag: jest.fn(),
|
||||
getTagList: jest.fn(),
|
||||
};
|
||||
|
||||
return mock;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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' }),
|
||||
|
|
|
@ -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} />,
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue