diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 51968181ef567e..fa1476ad45c17d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/packages/content-management/table_list/src/__jest__/tests.helpers.tsx b/packages/content-management/table_list/src/__jest__/tests.helpers.tsx index 544044d6ec76c8..9fd8fa66cee6cb 100644 --- a/packages/content-management/table_list/src/__jest__/tests.helpers.tsx +++ b/packages/content-management/table_list/src/__jest__/tests.helpers.tsx @@ -21,7 +21,9 @@ export const getMockServices = (overrides?: Partial) => { currentAppId$: from('mockedApp'), navigateToUrl: () => undefined, TagList, + getTagList: () => [], itemHasTags: () => true, + getTagManagementUrl: () => '', getTagIdsFromReferences: () => [], ...overrides, }; diff --git a/packages/content-management/table_list/src/actions.ts b/packages/content-management/table_list/src/actions.ts index 9eff5f445079db..ba706025b036ad 100644 --- a/packages/content-management/table_list/src/actions.ts +++ b/packages/content-management/table_list/src/actions.ts @@ -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 = diff --git a/packages/content-management/table_list/src/components/index.ts b/packages/content-management/table_list/src/components/index.ts index 004222d7729d0e..a4a09a5e6bbc6d 100644 --- a/packages/content-management/table_list/src/components/index.ts +++ b/packages/content-management/table_list/src/components/index.ts @@ -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'; diff --git a/packages/content-management/table_list/src/components/item_details.tsx b/packages/content-management/table_list/src/components/item_details.tsx index 1d5c5a65902a15..ccfbb5e3ea55a9 100644 --- a/packages/content-management/table_list/src/components/item_details.tsx +++ b/packages/content-management/table_list/src/components/item_details.tsx @@ -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 = Pick< TableListViewProps, @@ -20,14 +22,15 @@ type InheritedProps = Pick< interface Props extends InheritedProps { 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({ id, @@ -35,6 +38,7 @@ export function ItemDetails({ searchTerm = '', getDetailViewLink, onClickTitle, + onClickTag, }: Props) { const { references, @@ -79,7 +83,9 @@ export function ItemDetails({ onClick={onClickTitleHandler} data-test-subj={`${id}ListingTitleLink-${item.attributes.title.split(' ').join('-')}`} > - {title} + + {title} + ); @@ -90,6 +96,7 @@ export function ItemDetails({ onClickTitle, onClickTitleHandler, redirectAppLinksCoreStart, + searchTerm, title, ]); @@ -100,13 +107,20 @@ export function ItemDetails({ {renderTitle()} {Boolean(description) && ( -

{description!}

+

+ + {description!} + +

)} {hasTags && ( <> - + } + /> )} diff --git a/packages/content-management/table_list/src/components/table.tsx b/packages/content-management/table_list/src/components/table.tsx index 846fa087a8db89..1e4ee84204dd4a 100644 --- a/packages/content-management/table_list/src/components/table.tsx +++ b/packages/content-management/table_list/src/components/table.tsx @@ -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 = Pick< @@ -33,7 +38,12 @@ type State = Pick< 'items' | 'selectedIds' | 'searchQuery' | 'tableSort' | 'pagination' >; -interface Props extends State { +type TagManagementProps = Pick< + UseTagFilterPanelParams, + 'addOrRemoveIncludeTagFilter' | 'addOrRemoveExcludeTagFilter' | 'tagsToTableItemMap' +>; + +interface Props extends State, TagManagementProps { dispatch: Dispatch>; entityName: string; entityNamePlural: string; @@ -44,6 +54,7 @@ interface Props extends State { deleteItems: TableListViewProps['deleteItems']; onSortChange: (column: SortColumnField, direction: Direction) => void; onTableChange: (criteria: CriteriaWithPagination) => void; + clearTagSelection: () => void; } export function Table({ @@ -58,12 +69,16 @@ export function Table({ hasUpdatedAtMetadata, entityName, entityNamePlural, + tagsToTableItemMap, deleteItems, tableCaption, onTableChange, onSortChange, + addOrRemoveExcludeTagFilter, + addOrRemoveIncludeTagFilter, + clearTagSelection, }: Props) { - const { getSearchBarFilters } = useServices(); + const { getTagList } = useServices(); const renderToolsLeft = useCallback(() => { if (!deleteItems || selectedIds.length === 0) { @@ -97,8 +112,37 @@ export function Table({ } : 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(() => { + return { type: 'custom_component', component: () => { return ( @@ -110,25 +154,53 @@ export function Table({ ); }, }; + }, [hasUpdatedAtMetadata, onSortChange, tableSort]); + + const tagFilterPanel = useMemo(() => { + return { + type: 'custom_component', + component: () => { + return ( + + ); + }, + }; + }, [ + isPopoverOpen, + isInUse, + closePopover, + options, + totalActiveFilters, + onFilterButtonClick, + onSelectChange, + clearTagSelection, + ]); - return getSearchBarFilters - ? [tableSortSelectFilter, ...getSearchBarFilters()] - : [tableSortSelectFilter]; - }, [onSortChange, hasUpdatedAtMetadata, tableSort, getSearchBarFilters]); + 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 = ( ({ message={noItemsMessage} selection={selection} search={search} + executeQueryOptions={{ enabled: false }} sorting={tableSort ? { sort: tableSort as PropertySort } : undefined} onChange={onTableChange} data-test-subj="itemsInMemTable" diff --git a/packages/content-management/table_list/src/components/tag_badge.tsx b/packages/content-management/table_list/src/components/tag_badge.tsx new file mode 100644 index 00000000000000..bfbd7588849402 --- /dev/null +++ b/packages/content-management/table_list/src/components/tag_badge.tsx @@ -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 = ({ tag, onClick }) => { + return ( + { + 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} + + ); +}; diff --git a/packages/content-management/table_list/src/components/tag_filter_panel.tsx b/packages/content-management/table_list/src/components/tag_filter_panel.tsx new file mode 100644 index 00000000000000..03439f9dec1612 --- /dev/null +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -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 = ({ + 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 ( + <> + 0} + numActiveFilters={totalActiveFilters} + grow + > + Tags + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downCenter" + panelClassName="euiFilterGroup__popoverPanel" + panelStyle={isInUse ? { transition: 'none' } : undefined} + > + + + Tags + + {totalActiveFilters > 0 && ( + + {i18n.translate( + 'contentManagement.tableList.tagFilterPanel.clearSelectionButtonLabelLabel', + { + defaultMessage: 'Clear selection', + } + )} + + )} + + + + + 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 ?
{search}
: } + {list} + + ); + }} + + + + + + + {i18n.translate( + 'contentManagement.tableList.tagFilterPanel.modifierKeyHelpText', + { + defaultMessage: '{modifierKeyPrefix} + click exclude', + values: { + modifierKeyPrefix, + }, + } + )} + + + + + + Save + + + + + + {i18n.translate( + 'contentManagement.tableList.tagFilterPanel.manageAllTagsLinkLabel', + { + defaultMessage: 'Manage tags', + } + )} + + + + + +
+ + ); +}; diff --git a/packages/content-management/table_list/src/components/use_tag_filter_panel.tsx b/packages/content-management/table_list/src/components/use_tag_filter_panel.tsx new file mode 100644 index 00000000000000..ca7aab6f8bb08a --- /dev/null +++ b/packages/content-management/table_list/src/components/use_tag_filter_panel.tsx @@ -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([]); + const [tagSelection, setTagSelection] = useState({}); + 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: ( + + + + {name} + + + + + {tagsToTableItemMap[id ?? '']?.length ?? 0} + + + + ), + }; + }); + + 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 ) + */ + 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, + }; +}; diff --git a/packages/content-management/table_list/src/constants.ts b/packages/content-management/table_list/src/constants.ts new file mode 100644 index 00000000000000..d8afaa75d4d940 --- /dev/null +++ b/packages/content-management/table_list/src/constants.ts @@ -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'; diff --git a/packages/content-management/table_list/src/mocks.tsx b/packages/content-management/table_list/src/mocks.tsx index e2105bb9f78314..ff398d7b131be3 100644 --- a/packages/content-management/table_list/src/mocks.tsx +++ b/packages/content-management/table_list/src/mocks.tsx @@ -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, 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 ( - - {_tags.map((tag) => ( - { - if (onClick) { - onClick(tag); - } - }} - onClickAriaLabel="tag button" - iconOnClick={() => undefined} - iconOnClickAriaLabel="" - color={tag.color} - title={tag.description} - > - {tag.name} - - ))} - +
+ {references.map((ref) => { + const tag = { ...ref, color: 'blue', description: '' }; + + if (tagRender) { + return tagRender(tag); + } + + return ( + + ); + })} +
); }; export const getTagList = - ({ tags: _tags }: Props = {}) => - ({ onClick }: Props) => { - return ; + ({ references: _tags }: TagListProps = { references: [] }) => + ({ onClick }: TagListProps) => { + return ; }; /** @@ -82,7 +67,9 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) => currentAppId$: from('mockedApp'), navigateToUrl: () => undefined, TagList, + getTagList: () => [], itemHasTags: () => true, + getTagManagementUrl: () => '', getTagIdsFromReferences: () => [], ...params, }; diff --git a/packages/content-management/table_list/src/reducer.tsx b/packages/content-management/table_list/src/reducer.tsx index c90cb4c8839575..2c82d37fc496ef 100644 --- a/packages/content-management/table_list/src/reducer.tsx +++ b/packages/content-management/table_list/src/reducer.tsx @@ -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() { ...state, hasInitialFetchReturned: true, isFetchingItems: false, - items: !state.searchQuery ? sortBy(items, 'title') : items, + items, totalItems: action.data.response.total, hasUpdatedAtMetadata, tableSort: tableSort ?? state.tableSort, diff --git a/packages/content-management/table_list/src/services.tsx b/packages/content-management/table_list/src/services.tsx index f450190ce9d0a8..3ad0acd5f93bdb 100644 --- a/packages/content-management/table_list/src/services.tsx +++ b/packages/content-management/table_list/src/services.tsx @@ -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; + /** 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 = }) => { 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 = 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 ; + return ; }; return Comp; @@ -190,6 +201,14 @@ export const TableListViewKibanaProvider: FC = [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 = notifyError={(title, text) => { core.notifications.toasts.addDanger({ title: toMountPoint(title), text }); }} - getSearchBarFilters={getSearchBarFilters} searchQueryParser={searchQueryParser} DateFormatterComp={(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} diff --git a/packages/content-management/table_list/src/table_list_view.stories.tsx b/packages/content-management/table_list/src/table_list_view.stories.tsx index 7b197c0fa1b5be..4943c9d0be6577 100644 --- a/packages/content-management/table_list/src/table_list_view.stories.tsx +++ b/packages/content-management/table_list/src/table_list_view.stories.tsx @@ -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 ( { - 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 diff --git a/packages/content-management/table_list/src/table_list_view.test.tsx b/packages/content-management/table_list/src/table_list_view.test.tsx index e8b452ececfae3..92e1ddaa45cc07 100644 --- a/packages/content-management/table_list/src/table_list_view.test.tsx +++ b/packages/content-management/table_list/src/table_list_view.test.tsx @@ -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( - WithServices(TableListView, { TagList: getTagList({ tags: null }) }), + WithServices(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( + WithServices(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); + }); + }); }); diff --git a/packages/content-management/table_list/src/table_list_view.tsx b/packages/content-management/table_list/src/table_list_view.tsx index bcc68b27b5424c..a42bc4cb9a1c7b 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -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 { enabled?: boolean; @@ -49,7 +52,7 @@ export interface Props; /** 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; - 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({ tableListTitle, entityName, @@ -170,7 +181,10 @@ function TableListViewComp({ 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({ 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({ 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({ id, getDetailViewLink, onClickTitle, - searchQuery, + searchQuery.text, + addOrRemoveIncludeTagFilter, + addOrRemoveExcludeTagFilter, DateFormatterComp, inspector, inspectItem, @@ -351,11 +394,15 @@ function TableListViewComp({ 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({ return null; } - if (!fetchError && hasNoItems) { + if (!showFetchError && hasNoItems) { return ( ({ selectedIds={selectedIds} entityName={entityName} entityNamePlural={entityNamePlural} + tagsToTableItemMap={tagsToTableItemMap} deleteItems={deleteItems} tableCaption={tableListTitle} onTableChange={onTableChange} onSortChange={onSortChange} + addOrRemoveIncludeTagFilter={addOrRemoveIncludeTagFilter} + addOrRemoveExcludeTagFilter={addOrRemoveExcludeTagFilter} + clearTagSelection={clearTagSelection} /> {/* Delete modal */} diff --git a/packages/content-management/table_list/src/types.ts b/packages/content-management/table_list/src/types.ts new file mode 100644 index 00000000000000..0e716e6d59cf34 --- /dev/null +++ b/packages/content-management/table_list/src/types.ts @@ -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; +} diff --git a/packages/content-management/table_list/src/use_tags.ts b/packages/content-management/table_list/src/use_tags.ts new file mode 100644 index 00000000000000..c72f550bc54b3b --- /dev/null +++ b/packages/content-management/table_list/src/use_tags.ts @@ -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, + }; +} diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index 9534b54b164086..9ff31f266468ec 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -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, diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx index 9ce1e18e31d825..c70f2c48ffce0c 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx @@ -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={ diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx index 1e78b943034780..4752348246b00e 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -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 { diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts index 7fb558309936e7..f64658802e0e5b 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/dashboard_saved_object_service.ts @@ -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), }, diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts index c24511f56d3e24..da677c4441941f 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/lib/find_dashboard_saved_objects.ts @@ -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 { @@ -41,6 +43,7 @@ export async function findDashboardSavedObjects({ defaultSearchOperator: 'AND' as 'AND', perPage: size, hasReference, + hasNoReference, page: 1, }); return { diff --git a/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts b/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts index dd817c751aa8d9..f7c00c3d31fb4a 100644 --- a/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts +++ b/src/plugins/dashboard/public/services/dashboard_saved_object/types.ts @@ -53,7 +53,10 @@ export interface DashboardSavedObjectService { ) => Promise; findDashboards: { findSavedObjects: ( - props: Pick + props: Pick< + FindDashboardSavedObjectsArgs, + 'hasReference' | 'hasNoReference' | 'search' | 'size' + > ) => Promise; findByIds: (ids: string[]) => Promise; findByTitle: (title: string) => Promise<{ id: string } | undefined>; diff --git a/src/plugins/dashboard/public/services/saved_objects_tagging/saved_objects_tagging_service.ts b/src/plugins/dashboard/public/services/saved_objects_tagging/saved_objects_tagging_service.ts index a100282b4cff24..2cb84d53664926 100644 --- a/src/plugins/dashboard/public/services/saved_objects_tagging/saved_objects_tagging_service.ts +++ b/src/plugins/dashboard/public/services/saved_objects_tagging/saved_objects_tagging_service.ts @@ -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, }; }; diff --git a/src/plugins/dashboard/public/services/saved_objects_tagging/types.ts b/src/plugins/dashboard/public/services/saved_objects_tagging/types.ts index ba08a537093462..dd4b8bc4845040 100644 --- a/src/plugins/dashboard/public/services/saved_objects_tagging/types.ts +++ b/src/plugins/dashboard/public/services/saved_objects_tagging/types.ts @@ -18,4 +18,5 @@ export interface DashboardSavedObjectsTaggingService { updateTagsReferences?: SavedObjectsTaggingApi['ui']['updateTagsReferences']; getTagIdsFromReferences?: SavedObjectsTaggingApi['ui']['getTagIdsFromReferences']; getTableColumnDefinition?: SavedObjectsTaggingApi['ui']['getTableColumnDefinition']; + getTagList?: SavedObjectsTaggingApi['ui']['getTagList']; } diff --git a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts index 35afe6fc4bc9b6..e96aa2277b0a5d 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts @@ -63,6 +63,7 @@ const createApiUiMock = () => { getTagIdFromName: jest.fn(), updateTagsReferences: jest.fn(), getTag: jest.fn(), + getTagList: jest.fn(), }; return mock; diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index 0cc475b127a45c..d7ea41c225f20f 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -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; } diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts index 2238ff7cf054af..eada1c8beadd72 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -158,7 +158,8 @@ export async function findListItems( visTypes: Pick, 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( diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx index 048de833df8028..bf7b25269c85b3 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx @@ -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> }) => ({ total, hits: hits diff --git a/x-pack/plugins/graph/public/apps/listing_route.tsx b/x-pack/plugins/graph/public/apps/listing_route.tsx index af869f7afaa21a..15f4898b843640 100644 --- a/x-pack/plugins/graph/public/apps/listing_route.tsx +++ b/x-pack/plugins/graph/public/apps/listing_route.tsx @@ -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, diff --git a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx index e9c3102e2aa855..1506a2569c273d 100644 --- a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx +++ b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx @@ -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({ 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 { diff --git a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx index d18e224b969322..cfe67c5ed08277 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/base/tag_list.tsx @@ -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 = ({ tags, onClick }) => { +export const TagList: FC = ({ tags, onClick, tagRender }) => { return ( - {tags.map((tag) => ( - - ))} + {tags.map((tag) => + tagRender ? ( + {tagRender(tag)} + ) : ( + + ) + )} ); }; diff --git a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx index 2ef49512340ac5..46deb57db7f404 100644 --- a/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/components/connected/tag_list.tsx @@ -19,16 +19,22 @@ interface SavedObjectTagListProps { object: { references: SavedObjectReference[] }; tags: Tag[]; onClick?: (tag: TagWithOptionalId) => void; + tagRender?: (tag: TagWithOptionalId) => JSX.Element; } -const SavedObjectTagList: FC = ({ object, tags: allTags, onClick }) => { +const SavedObjectTagList: FC = ({ + object, + tags: allTags, + onClick, + tagRender, +}) => { const objectTags = useMemo(() => { const { tags } = getObjectTags(object, allTags); tags.sort(byNameTagSorter); return tags; }, [object, allTags]); - return ; + return ; }; interface GetConnectedTagListOptions { diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts index 96a498580c2e47..e5216ea2091770 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.test.ts @@ -20,10 +20,12 @@ const expectTagOption = (tag: Tag, useName: boolean) => ({ describe('getSearchBarFilter', () => { let cache: ReturnType; 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' }), diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx index 5ce3a8fd8b7319..25e674bbf39d2e 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_search_bar_filter.tsx @@ -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: , - }; - }) + getTagList().map((tag) => { + return { + value: useName ? tag.name : tag.id, + name: tag.name, + view: , + }; + }) ); }, }; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.test.ts new file mode 100644 index 00000000000000..3821d3a4bc3133 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.test.ts @@ -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]); + }); +}); diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.ts new file mode 100644 index 00000000000000..6fc6e7cd51df94 --- /dev/null +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.ts @@ -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); diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts index 6b4eddf357478f..8d53135f3f55af 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts @@ -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, }; }; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts index 02de2673885e31..15e2349af47dc7 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.test.ts @@ -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, }); }); diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts index a5ac82f8c9821c..8f22fcea3f782a 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/parse_search_query.ts @@ -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, }; };