From 0c46a5a6a6146a453ed1a638cda46e26bb415f56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 29 Sep 2022 15:06:05 +0100 Subject: [PATCH 01/46] [Core] Update SO client to support "hasNoReference" --- .../src/lib/search_dsl/query_params.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts index 896b934c90b80..58ffeec24944a 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts @@ -141,11 +141,6 @@ interface QueryParams { hasNoReferenceOperator?: SearchOperator; kueryNode?: KueryNode; } - -// A de-duplicated set of namespaces makes for a more efficient query. -const uniqNamespaces = (namespacesToNormalize?: string[]) => - namespacesToNormalize ? Array.from(new Set(namespacesToNormalize)) : undefined; - const toArray = (val: unknown) => { if (typeof val === 'undefined') { return val; From 8f24ba720e0aba521037802e4e69efa643386198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 26 Sep 2022 17:10:24 +0100 Subject: [PATCH 02/46] Revert "Remove EuiHighlight component" This reverts commit 28fb423148353dd62839430e19cdbd1df59e4443. --- .../table_list/src/components/item_details.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 1d5c5a65902a1..7e79a19cfc734 100644 --- a/packages/content-management/table_list/src/components/item_details.tsx +++ b/packages/content-management/table_list/src/components/item_details.tsx @@ -79,7 +79,9 @@ export function ItemDetails({ onClick={onClickTitleHandler} data-test-subj={`${id}ListingTitleLink-${item.attributes.title.split(' ').join('-')}`} > - {title} + + {title} + ); @@ -90,6 +92,7 @@ export function ItemDetails({ onClickTitle, onClickTitleHandler, redirectAppLinksCoreStart, + searchTerm, title, ]); @@ -100,7 +103,11 @@ export function ItemDetails({ {renderTitle()} {Boolean(description) && ( -

{description!}

+

+ + {description!} + +

)} {hasTags && ( From c77df00cf834302d829691910c1c79a993ea9a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 26 Sep 2022 17:13:35 +0100 Subject: [PATCH 03/46] Fix regex when highlighting --- .../table_list/src/components/item_details.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 7e79a19cfc734..31dc9503aa403 100644 --- a/packages/content-management/table_list/src/components/item_details.tsx +++ b/packages/content-management/table_list/src/components/item_details.tsx @@ -7,7 +7,7 @@ */ 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 { useServices } from '../services'; @@ -25,9 +25,9 @@ interface Props extends InheritedProps { /** * 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, @@ -80,8 +80,8 @@ export function ItemDetails({ data-test-subj={`${id}ListingTitleLink-${item.attributes.title.split(' ').join('-')}`} > - {title} - + {title} + ); From 52ee513459341940ce8e67d16e59f9f6320b0760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 28 Sep 2022 14:14:18 +0100 Subject: [PATCH 04/46] Update tagging parse search query to return excluded tags --- .../public/ui_api/parse_search_query.ts | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) 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 a5ac82f8c9821..6db8a3e115a9a 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 retrive 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, }; }; From c69e66da77043f8e08e44c517dbc35536802bf4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 28 Sep 2022 14:17:10 +0100 Subject: [PATCH 05/46] Add missing handler from SavedOjbectTaggingService --- .../table_list/src/services.tsx | 3 +++ .../table_list/src/table_list_view.tsx | 17 ++++++++++++----- .../application/listing/dashboard_listing.tsx | 11 ++++++++++- .../saved_objects_tagging_oss/public/api.ts | 1 + .../components/visualize_listing.tsx | 11 ++++++++++- .../public/routes/list_page/maps_list_view.tsx | 13 +++++++++++-- 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/content-management/table_list/src/services.tsx b/packages/content-management/table_list/src/services.tsx index 7841b72741388..2b4786c52c423 100644 --- a/packages/content-management/table_list/src/services.tsx +++ b/packages/content-management/table_list/src/services.tsx @@ -41,6 +41,7 @@ export interface Services { searchQueryParser?: (searchQuery: string) => { searchQuery: string; references?: SavedObjectsFindOptionsReference[]; + referencesToExclude?: SavedObjectsFindOptionsReference[]; }; getSearchBarFilters?: () => SearchFilterConfig[]; DateFormatterComp?: DateFormatter; @@ -118,6 +119,7 @@ export interface TableListViewKibanaDependencies { ) => { searchTerm: string; tagReferences: SavedObjectsFindOptionsReference[]; + tagReferencesToExclude: SavedObjectsFindOptionsReference[]; valid: boolean; }; getSearchBarFilter: (options?: { @@ -153,6 +155,7 @@ export const TableListViewKibanaProvider: FC = return { searchQuery: res.searchTerm, references: res.tagReferences, + referencesToExclude: res.tagReferencesToExclude, }; }; } 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 80b814c4dcdb9..1e1c36e346b5b 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -58,7 +58,10 @@ 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; @@ -288,11 +291,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; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx index 1e78b94303478..9374e0e450f49 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -261,7 +261,16 @@ export const DashboardListing = ({ ]); const fetchItems = useCallback( - (searchTerm: string, references?: SavedObjectsFindOptionsReference[]) => { + ( + searchTerm: string, + { + references, + referencesToExclude, + }: { + references?: SavedObjectsFindOptionsReference[]; + referencesToExclude?: SavedObjectsFindOptionsReference[]; + } = {} + ) => { return findDashboards .findSavedObjects({ search: searchTerm, diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index c32793b376826..05bcb29a8ab21 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -316,6 +316,7 @@ export interface GetSearchBarFilterOptions { export interface ParsedSearchQuery { searchTerm: string; tagReferences: SavedObjectsFindOptionsReference[]; + tagReferencesToExclude: SavedObjectsFindOptionsReference[]; valid: boolean; } 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 048de833df802..71a71d1cdde2d 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx @@ -148,7 +148,16 @@ 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, 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 e9c3102e2aa85..10a95f20b449a 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,7 @@ async function findMaps(searchTerm: string, tagReferences?: SavedObjectsFindOpti searchFields: ['title^3', 'description'], defaultSearchOperator: 'AND', fields: ['description', 'title'], - hasReference: tagReferences, + hasReference: references, }); return { From 8210483370e693f21cecc587ffbbd05a4def5534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 28 Sep 2022 14:20:14 +0100 Subject: [PATCH 06/46] Keep in state Query object instead of query string --- .../table_list/src/actions.ts | 5 ++++- .../table_list/src/components/table.tsx | 17 ++++++++++---- .../table_list/src/reducer.tsx | 4 +--- .../table_list/src/table_list_view.tsx | 22 ++++++++++++++----- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/packages/content-management/table_list/src/actions.ts b/packages/content-management/table_list/src/actions.ts index 9eff5f445079d..e8834b2a5c8f2 100644 --- a/packages/content-management/table_list/src/actions.ts +++ b/packages/content-management/table_list/src/actions.ts @@ -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 | null; + text: string; + }; } export type Action = diff --git a/packages/content-management/table_list/src/components/table.tsx b/packages/content-management/table_list/src/components/table.tsx index 846fa087a8db8..45a9524aa675d 100644 --- a/packages/content-management/table_list/src/components/table.tsx +++ b/packages/content-management/table_list/src/components/table.tsx @@ -16,6 +16,7 @@ import { PropertySort, SearchFilterConfig, Direction, + Query, } from '@elastic/eui'; import { useServices } from '../services'; @@ -97,6 +98,14 @@ export function Table({ } : undefined; + const onSearchQueryChange = useCallback( + (arg: { query: Query | null; queryText: string }) => { + const { queryText, query } = arg; + dispatch({ type: 'onSearchQueryChange', data: { query, text: queryText } }); + }, + [dispatch] + ); + const searchFilters = useMemo(() => { const tableSortSelectFilter: SearchFilterConfig = { type: 'custom_component', @@ -118,17 +127,16 @@ export function Table({ 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={{ disabled: true }} sorting={tableSort ? { sort: tableSort as PropertySort } : undefined} onChange={onTableChange} data-test-subj="itemsInMemTable" diff --git a/packages/content-management/table_list/src/reducer.tsx b/packages/content-management/table_list/src/reducer.tsx index c90cb4c883957..2c82d37fc496e 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/table_list_view.tsx b/packages/content-management/table_list/src/table_list_view.tsx index 1e1c36e346b5b..addfa058df2de 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'; @@ -43,7 +45,7 @@ export interface Props; - searchQuery: string; + searchQuery: { + text: string; + query: Query | null; + }; selectedIds: string[]; totalItems: number; hasUpdatedAtMetadata: boolean; @@ -156,7 +161,9 @@ function TableListViewComp({ showDeleteModal: false, hasUpdatedAtMetadata: false, selectedIds: [], - searchQuery: initialQuery, + searchQuery: Boolean(initialQuery) + ? { text: initialQuery!, query: null } + : { text: '', query: null }, pagination: { pageIndex: 0, totalItemCount: 0, @@ -188,6 +195,10 @@ function TableListViewComp({ const showFetchError = Boolean(fetchError); const showLimitError = !showFetchError && totalItems > listingLimit; + const initializeQuery = useCallback(() => { + const ast = Ast.create([]); + return new Query(ast, undefined, searchQuery.text); + }, [searchQuery]); const tableColumns = useMemo(() => { const columns: Array> = [ { @@ -203,7 +214,8 @@ function TableListViewComp({ item={record} getDetailViewLink={getDetailViewLink} onClickTitle={onClickTitle} - searchTerm={searchQuery} + onClickTag={(tag) => { + searchTerm={searchQuery.text} /> ); }, @@ -270,7 +282,7 @@ function TableListViewComp({ id, getDetailViewLink, onClickTitle, - searchQuery, + searchQuery.text, DateFormatterComp, ]); From 521196ae4fb2cf5609cdbf02181acf7c88de2ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 28 Sep 2022 14:38:31 +0100 Subject: [PATCH 07/46] Handle tags filtering in search bar --- .../src/components/item_details.tsx | 4 +- .../table_list/src/table_list_view.tsx | 22 +++- .../table_list/src/use_tags.ts | 110 ++++++++++++++++++ 3 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 packages/content-management/table_list/src/use_tags.ts 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 31dc9503aa403..525acee34dee9 100644 --- a/packages/content-management/table_list/src/components/item_details.tsx +++ b/packages/content-management/table_list/src/components/item_details.tsx @@ -20,6 +20,7 @@ type InheritedProps = Pick< interface Props extends InheritedProps { item: T; searchTerm?: string; + onClickTag: (tag: { name: string }) => void; } /** @@ -35,6 +36,7 @@ export function ItemDetails({ searchTerm = '', getDetailViewLink, onClickTitle, + onClickTag, }: Props) { const { references, @@ -113,7 +115,7 @@ export function ItemDetails({ {hasTags && ( <> - + )} 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 addfa058df2de..3f8d86de1e3c7 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -19,7 +19,6 @@ import { EuiTableActionsColumnType, CriteriaWithPagination, Query, - Ast, } from '@elastic/eui'; import { keyBy, uniq, get } from 'lodash'; import { i18n } from '@kbn/i18n'; @@ -39,6 +38,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'; export interface Props { entityName: string; @@ -60,7 +60,7 @@ export interface Props({ const showFetchError = Boolean(fetchError); const showLimitError = !showFetchError && totalItems > listingLimit; - const initializeQuery = useCallback(() => { - const ast = Ast.create([]); - return new Query(ast, undefined, searchQuery.text); - }, [searchQuery]); + const updateQuery = useCallback((query: Query) => { + dispatch({ + type: 'onSearchQueryChange', + data: { query, text: query.text }, + }); + }, []); + + const { addOrRemoveExcludeTagFilter, addOrRemoveIncludeTagFilter } = useTags({ + searchQuery, + updateQuery, + }); + const tableColumns = useMemo(() => { const columns: Array> = [ { @@ -283,6 +291,8 @@ function TableListViewComp({ getDetailViewLink, onClickTitle, searchQuery.text, + addOrRemoveExcludeTagFilter, + addOrRemoveIncludeTagFilter, DateFormatterComp, ]); 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 0000000000000..69d451f456184 --- /dev/null +++ b/packages/content-management/table_list/src/use_tags.ts @@ -0,0 +1,110 @@ +/* + * 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 } from 'react'; +import { Query, Ast } from '@elastic/eui'; + +import type { State } from './table_list_view'; + +export function useTags({ + searchQuery, + updateQuery, +}: { + searchQuery: State['searchQuery']; + updateQuery: (query: Query) => void; +}) { + const initializeQuery = useCallback(() => { + const ast = Ast.create([]); + return new Query(ast, undefined, searchQuery.text); + }, [searchQuery]); + + const addIncludeTagFilter = useCallback( + (tag: { name: string }) => { + const query = searchQuery.query ?? initializeQuery(); + const updatedQuery = query.addOrFieldValue('tag', tag.name, true, 'eq'); + updateQuery(updatedQuery); + }, + [searchQuery, initializeQuery, updateQuery] + ); + + const removeIncludeTagFilter = useCallback( + (tag: { name: string }) => { + const query = searchQuery.query ?? initializeQuery(); + const updatedQuery = query.removeOrFieldValue('tag', tag.name); + updateQuery(updatedQuery); + }, + [searchQuery, initializeQuery, updateQuery] + ); + + const addOrRemoveIncludeTagFilter = useCallback( + (tag: { name: string }) => { + const query = searchQuery.query ?? initializeQuery(); + const tagsClauses = query.ast.getFieldClauses('tag'); + + if (tagsClauses) { + const mustHaveTagClauses = query.ast + .getFieldClauses('tag') + .find(({ match }) => match === 'must')?.value as string[]; + + if (mustHaveTagClauses && mustHaveTagClauses.includes(tag.name)) { + // Already selected, remove the filter + removeIncludeTagFilter(tag); + return; + } + } + + addIncludeTagFilter(tag); + }, + [searchQuery.query, initializeQuery, addIncludeTagFilter, removeIncludeTagFilter] + ); + + const addExcludeTagFilter = useCallback( + (tag: { name: string }) => { + const query = searchQuery.query ?? initializeQuery(); + + const updatedQuery = query.addOrFieldValue('tag', tag.name, false, 'eq'); + updateQuery(updatedQuery); + }, + [initializeQuery, searchQuery.query, updateQuery] + ); + + const removeExcludeTagFilter = useCallback( + (tag: { name: string }) => { + const query = searchQuery.query ?? initializeQuery(); + const updatedQuery = query.removeOrFieldValue('tag', tag.name); + updateQuery(updatedQuery); + }, + [searchQuery, initializeQuery, updateQuery] + ); + + const addOrRemoveExcludeTagFilter = useCallback( + (tag: { name: string }) => { + const query = searchQuery.query ?? initializeQuery(); + const tagsClauses = query.ast.getFieldClauses('tag'); + + if (tagsClauses) { + const mustHaveTagClauses = query.ast + .getFieldClauses('tag') + .find(({ match }) => match === 'must_not')?.value as string[]; + + if (mustHaveTagClauses && mustHaveTagClauses.includes(tag.name)) { + // Already selected, remove the filter + removeExcludeTagFilter(tag); + return; + } + } + + addExcludeTagFilter(tag); + }, + [searchQuery.query, initializeQuery, addExcludeTagFilter, removeExcludeTagFilter] + ); + + return { + addOrRemoveIncludeTagFilter, + addOrRemoveExcludeTagFilter, + }; +} From 5bc6a5464d80cdfb49361605e2f882f1542ce7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 28 Sep 2022 14:38:47 +0100 Subject: [PATCH 08/46] Fix storybook story --- .../src/table_list_view.stories.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) 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 7b197c0fa1b5b..4943c9d0be657 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 From a20b56ad0e7fb00afb28c19f4f020074140b742d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 28 Sep 2022 15:42:44 +0100 Subject: [PATCH 09/46] Fix TS issue --- .../content-management/table_list/src/table_list_view.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 3f8d86de1e3c7..1d5b425551ad1 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -202,7 +202,7 @@ function TableListViewComp({ }); }, []); - const { addOrRemoveExcludeTagFilter, addOrRemoveIncludeTagFilter } = useTags({ + const { addOrRemoveIncludeTagFilter } = useTags({ searchQuery, updateQuery, }); @@ -223,6 +223,8 @@ function TableListViewComp({ getDetailViewLink={getDetailViewLink} onClickTitle={onClickTitle} onClickTag={(tag) => { + addOrRemoveIncludeTagFilter(tag); + }} searchTerm={searchQuery.text} /> ); @@ -291,7 +293,6 @@ function TableListViewComp({ getDetailViewLink, onClickTitle, searchQuery.text, - addOrRemoveExcludeTagFilter, addOrRemoveIncludeTagFilter, DateFormatterComp, ]); From b926e97c634b66455119bd599ddfbfb98a94237e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 29 Sep 2022 15:07:07 +0100 Subject: [PATCH 10/46] Update Dashboard to pass "hasNoReference" tag list --- .../dashboard/public/application/listing/dashboard_listing.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx index 9374e0e450f49..0e734729861e2 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -272,10 +272,11 @@ export const DashboardListing = ({ } = {} ) => { return findDashboards - .findSavedObjects({ + .findSavedObjects(searchTerm, { search: searchTerm, size: listingLimit, hasReference: references, + hasNoReference: referencesToExclude, }) .then(({ total, hits }) => { return { From a6e68d978ac87490de1f6894db3ba189c82ab2aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 12 Oct 2022 10:27:32 +0100 Subject: [PATCH 11/46] Revert merge conflict resolution --- .../src/lib/search_dsl/query_params.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts index 58ffeec24944a..a12db18d272b4 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts @@ -141,6 +141,10 @@ interface QueryParams { hasNoReferenceOperator?: SearchOperator; kueryNode?: KueryNode; } +// A de-duplicated set of namespaces makes for a more efficient query. +const uniqNamespaces = (namespacesToNormalize?: string[]) => + namespacesToNormalize ? Array.from(new Set(namespacesToNormalize)) : undefined; + const toArray = (val: unknown) => { if (typeof val === 'undefined') { return val; From 27d839dd43a014499ebc13ed63e27a5c7f826dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 13 Oct 2022 12:07:38 +0100 Subject: [PATCH 12/46] Fix TS issue --- packages/content-management/table_list/src/actions.ts | 2 +- packages/content-management/table_list/src/components/table.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/content-management/table_list/src/actions.ts b/packages/content-management/table_list/src/actions.ts index e8834b2a5c8f2..3691748bbc3d3 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'; diff --git a/packages/content-management/table_list/src/components/table.tsx b/packages/content-management/table_list/src/components/table.tsx index 45a9524aa675d..c0da70188825a 100644 --- a/packages/content-management/table_list/src/components/table.tsx +++ b/packages/content-management/table_list/src/components/table.tsx @@ -156,7 +156,7 @@ export function Table({ message={noItemsMessage} selection={selection} search={search} - executeQueryOptions={{ disabled: true }} + executeQueryOptions={{ enabled: false }} sorting={tableSort ? { sort: tableSort as PropertySort } : undefined} onChange={onTableChange} data-test-subj="itemsInMemTable" From b881f0e3fb76ab1427c9a5bf3c8fe096b697e0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 13 Oct 2022 12:08:02 +0100 Subject: [PATCH 13/46] Fix Dashboard fetch --- .../public/application/listing/dashboard_listing.tsx | 2 +- .../dashboard_saved_object_service.ts | 10 ++++++++-- .../lib/find_dashboard_saved_objects.ts | 3 +++ .../public/services/dashboard_saved_object/types.ts | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx index 0e734729861e2..4752348246b00 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -272,7 +272,7 @@ export const DashboardListing = ({ } = {} ) => { return findDashboards - .findSavedObjects(searchTerm, { + .findSavedObjects({ search: searchTerm, size: listingLimit, hasReference: references, 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 7fb558309936e..f64658802e0e5 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 c24511f56d3e2..da677c4441941 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 dd817c751aa8d..f7c00c3d31fb4 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>; From 87bb91d63a83d05ddf956e162692dc789f47dfed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Fri, 14 Oct 2022 15:23:02 +0100 Subject: [PATCH 14/46] Add tag filter panel component --- .../src/components/tag_filter_panel.tsx | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 packages/content-management/table_list/src/components/tag_filter_panel.tsx 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 0000000000000..7907b2f9b065a --- /dev/null +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -0,0 +1,240 @@ +/* + * 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, useState, useEffect, useCallback } from 'react'; +import { + Query, + EuiPopover, + EuiPopoverTitle, + EuiSelectable, + EuiFilterButton, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiButtonEmpty, + EuiTextColor, + EuiHealth, + EuiSpacer, + EuiLink, + useEuiTheme, +} from '@elastic/eui'; +import type { EuiSelectableProps, ExclusiveUnion, FieldValueOptionType } from '@elastic/eui'; +import { css } from '@emotion/react'; + +import { Tag } from '../types'; + +const toArray = (item: unknown) => (Array.isArray(item) ? item : [item]); + +const testSubjFriendly = (name: string) => { + return name.replace(' ', '_'); +}; + +interface TagOptionItem extends FieldValueOptionType { + label: string; + checked?: 'on' | 'off'; + tag: Tag; +} + +export interface Props { + query: Query | null; + tagsToTableItemMap: { [tagId: string]: string[] }; + getTagList: () => Tag[]; + addOrRemoveIncludeTagFilter: (tag: Tag) => void; + addOrRemoveExcludeTagFilter: (tag: Tag) => void; +} + +export const TagFilterPanel: FC = ({ query, getTagList, addOrRemoveIncludeTagFilter }) => { + const { euiTheme } = useEuiTheme(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [options, setOptions] = useState([]); + + const isSearchVisible = options.length > 10; + const totalActiveFilters = options.reduce((acc, option) => { + if (option.checked !== undefined) { + acc += 1; + } + return acc; + }, 0); + + const footerCSS = css` + border-top: ${euiTheme.border.thin}; + text-align: center; + `; + + const bottomBarCSS = css` + background-color: ${euiTheme.colors.lightestShade}; + border-top: ${euiTheme.border.thin}; + padding: ${euiTheme.size.s}; + text-align: center; + `; + + let searchProps: ExclusiveUnion< + { searchable: false }, + { + searchable: true; + searchProps: EuiSelectableProps['searchProps']; + } + > = { + searchable: false, + }; + + if (isSearchVisible) { + searchProps = { + searchable: true, + searchProps: { + compressed: true, + // disabled: this.state.error != null, + }, + }; + } + + const togglePopOver = () => { + setIsPopoverOpen((prev) => !prev); + }; + + const closePopover = () => { + setIsPopoverOpen(false); + }; + + const onSelectChange = useCallback( + (updatedOptions: TagOptionItem[]) => { + const diff = updatedOptions.find((item, index) => item.checked !== options[index].checked); + if (diff) { + addOrRemoveIncludeTagFilter(diff.tag); + } + }, + [options, addOrRemoveIncludeTagFilter] + ); + + const updateTagList = useCallback(() => { + const tags = getTagList(); + + setOptions( + tags.map((tag) => { + const { name, id, color } = tag; + return { + name, + label: name, + value: id, + tag, + view: ( + + + {name} + + + ), + }; + }) + ); + }, [getTagList]); + + useEffect(() => { + updateTagList(); + }, [updateTagList]); + + useEffect(() => { + if (query) { + const items: { [key: string]: TagOptionItem[] } = { + on: [], + off: [], + rest: [], + }; + + const clauseInclude = query.ast.getOrFieldClause('tag', undefined, true, 'eq'); + const clausesExclude = query.ast.getOrFieldClause('tag', undefined, false, 'eq'); + + setOptions((prev) => { + prev.forEach((op) => { + if (clauseInclude && toArray(clauseInclude.value).includes(op.name)) { + items.on.push({ ...op, checked: 'on' as const }); + } else if (clausesExclude && toArray(clausesExclude.value).includes(op.name)) { + items.on.push({ ...op, checked: 'off' as const }); + } else { + items.on.push({ ...op, checked: undefined }); + } + }); + + return [...items.on, ...items.off, ...items.rest]; + }); + } + }, [query]); + + return ( + <> + 0} + numActiveFilters={totalActiveFilters} + grow + > + Tags + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downCenter" + panelClassName="euiFilterGroup__popoverPanel" + > + + + + 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} + {...searchProps} + > + {(list, search) => ( + <> + {isSearchVisible ? ( + {search} + ) : ( + + )} + {list} + + )} + + + + {totalActiveFilters > 0 ? ( + + Clear selection + + ) : ( + + )} + + Ctrl + click to filter out tags + + + + + + Manage all tags + + + + + + + ); +}; From b04a45e2356c8a457c283e91523118efc16bad36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Fri, 14 Oct 2022 15:25:15 +0100 Subject: [PATCH 15/46] Expose getTagList() hander from tagging plugin --- .../table_list/src/components/index.ts | 1 + .../src/components/item_details.tsx | 3 +- .../table_list/src/components/table.tsx | 44 ++++++++++++++++--- .../table_list/src/services.tsx | 30 ++++++------- .../table_list/src/types.ts | 14 ++++++ .../saved_objects_tagging_service.ts | 2 + .../saved_objects_tagging_oss/public/api.ts | 4 ++ .../public/ui_api/get_search_bar_filter.tsx | 25 +++++------ .../public/ui_api/index.ts | 6 ++- 9 files changed, 92 insertions(+), 37 deletions(-) create mode 100644 packages/content-management/table_list/src/types.ts diff --git a/packages/content-management/table_list/src/components/index.ts b/packages/content-management/table_list/src/components/index.ts index 004222d7729d0..a4a09a5e6bbc6 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 525acee34dee9..62f0815b98462 100644 --- a/packages/content-management/table_list/src/components/item_details.tsx +++ b/packages/content-management/table_list/src/components/item_details.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useMemo } from 'react'; 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'; @@ -20,7 +21,7 @@ type InheritedProps = Pick< interface Props extends InheritedProps { item: T; searchTerm?: string; - onClickTag: (tag: { name: string }) => void; + onClickTag: (tag: Tag) => void; } /** diff --git a/packages/content-management/table_list/src/components/table.tsx b/packages/content-management/table_list/src/components/table.tsx index c0da70188825a..00dc59ca2c497 100644 --- a/packages/content-management/table_list/src/components/table.tsx +++ b/packages/content-management/table_list/src/components/table.tsx @@ -27,6 +27,8 @@ import type { UserContentCommonSchema, } from '../table_list_view'; import { TableSortSelect } from './table_sort_select'; +import { TagFilterPanel } from './tag_filter_panel'; +import type { Props as TagFilterPanelProps } from './tag_filter_panel'; import type { SortColumnField } from './table_sort_select'; type State = Pick< @@ -34,7 +36,12 @@ type State = Pick< 'items' | 'selectedIds' | 'searchQuery' | 'tableSort' | 'pagination' >; -interface Props extends State { +interface Props + extends State, + Pick< + TagFilterPanelProps, + 'addOrRemoveIncludeTagFilter' | 'addOrRemoveExcludeTagFilter' | 'tagsToTableItemMap' + > { dispatch: Dispatch>; entityName: string; entityNamePlural: string; @@ -59,12 +66,15 @@ export function Table({ hasUpdatedAtMetadata, entityName, entityNamePlural, + tagsToTableItemMap, deleteItems, tableCaption, onTableChange, onSortChange, + addOrRemoveExcludeTagFilter, + addOrRemoveIncludeTagFilter, }: Props) { - const { getSearchBarFilters } = useServices(); + const { getTagList } = useServices(); const renderToolsLeft = useCallback(() => { if (!deleteItems || selectedIds.length === 0) { @@ -120,10 +130,32 @@ export function Table({ }, }; - return getSearchBarFilters - ? [tableSortSelectFilter, ...getSearchBarFilters()] - : [tableSortSelectFilter]; - }, [onSortChange, hasUpdatedAtMetadata, tableSort, getSearchBarFilters]); + const tagFilterPanel: SearchFilterConfig = { + type: 'custom_component', + component: () => { + return ( + + ); + }, + }; + + return [tableSortSelectFilter, tagFilterPanel]; + }, [ + onSortChange, + hasUpdatedAtMetadata, + tableSort, + getTagList, + searchQuery.query, + tagsToTableItemMap, + addOrRemoveIncludeTagFilter, + addOrRemoveExcludeTagFilter, + ]); const search = useMemo(() => { return { diff --git a/packages/content-management/table_list/src/services.tsx b/packages/content-management/table_list/src/services.tsx index 2b4786c52c423..f7a709c0754af 100644 --- a/packages/content-management/table_list/src/services.tsx +++ b/packages/content-management/table_list/src/services.tsx @@ -7,11 +7,12 @@ */ 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 { RedirectAppLinksKibanaProvider } from '@kbn/shared-ux-link-redirect-app'; +import { Tag } from './types'; + type UnmountCallback = () => void; type MountPoint = (element: HTMLElement) => UnmountCallback; type NotifyFn = (title: JSX.Element, text?: string) => void; @@ -43,9 +44,9 @@ export interface Services { references?: SavedObjectsFindOptionsReference[]; referencesToExclude?: SavedObjectsFindOptionsReference[]; }; - getSearchBarFilters?: () => SearchFilterConfig[]; DateFormatterComp?: DateFormatter; - TagList: FC<{ references: SavedObjectsReference[]; onClick?: (tag: { name: string }) => void }>; + getTagList: () => Tag[]; + TagList: FC<{ references: SavedObjectsReference[]; onClick?: (tag: Tag) => void }>; /** Predicate function to indicate if the saved object references include tags */ itemHasTags: (references: SavedObjectsReference[]) => boolean; } @@ -107,7 +108,7 @@ export interface TableListViewKibanaDependencies { object: { references: SavedObjectsReference[]; }; - onClick?: (tag: { name: string; description: string; color: string }) => void; + onClick?: (tag: Tag) => void; }>; }; parseSearchQuery: ( @@ -122,10 +123,7 @@ export interface TableListViewKibanaDependencies { tagReferencesToExclude: SavedObjectsFindOptionsReference[]; valid: boolean; }; - getSearchBarFilter: (options?: { - useName?: boolean; - tagField?: string; - }) => SearchFilterConfig; + getTagList: () => Tag[]; getTagIdsFromReferences: (references: SavedObjectsReference[]) => string[]; }; }; @@ -142,12 +140,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) => { @@ -184,6 +176,14 @@ export const TableListViewKibanaProvider: FC = [savedObjectsTagging?.ui] ); + const getTagList = useCallback(() => { + if (!savedObjectsTagging?.ui.getTagList) { + return []; + } + + return savedObjectsTagging.ui.getTagList(); + }, [savedObjectsTagging?.ui]); + return ( = 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} > 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 0000000000000..6bc1660a99112 --- /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/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 a100282b4cff2..2cb84d5366492 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/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index 05bcb29a8ab21..7cbe070859ac1 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. 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 5ce3a8fd8b731..25e674bbf39d2 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/index.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts index 6b4eddf357478..8918a4f701bef 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 @@ -14,6 +14,7 @@ import { updateTagsReferences, convertTagNameToId, getTag, + byNameTagSorter, } from '../utils'; import { getComponents } from './components'; import { buildGetTableColumnDefinition } from './get_table_column_definition'; @@ -39,10 +40,12 @@ export const getUiApi = ({ }: GetUiApiOptions): SavedObjectsTaggingApiUi => { const components = getComponents({ cache, capabilities, overlays, theme, tagClient: client }); + const getTagList = () => cache.getState().sort(byNameTagSorter); + 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, }; }; From e69d78d14cd0faa02c0b4b72574e4b5829e22b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Fri, 14 Oct 2022 15:26:09 +0100 Subject: [PATCH 16/46] Aggregate tag by saved object --- .../table_list/src/table_list_view.tsx | 6 +++- .../table_list/src/use_tags.ts | 35 +++++++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) 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 1d5b425551ad1..19ed17c5736d3 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -202,9 +202,10 @@ function TableListViewComp({ }); }, []); - const { addOrRemoveIncludeTagFilter } = useTags({ + const { addOrRemoveIncludeTagFilter, addOrRemoveExcludeTagFilter, tagsToTableItemMap } = useTags({ searchQuery, updateQuery, + items, }); const tableColumns = useMemo(() => { @@ -521,10 +522,13 @@ function TableListViewComp({ selectedIds={selectedIds} entityName={entityName} entityNamePlural={entityNamePlural} + tagsToTableItemMap={tagsToTableItemMap} deleteItems={deleteItems} tableCaption={tableListTitle} onTableChange={onTableChange} onSortChange={onSortChange} + addOrRemoveIncludeTagFilter={addOrRemoveIncludeTagFilter} + addOrRemoveExcludeTagFilter={addOrRemoveExcludeTagFilter} /> {/* Delete modal */} diff --git a/packages/content-management/table_list/src/use_tags.ts b/packages/content-management/table_list/src/use_tags.ts index 69d451f456184..0b1534ceae776 100644 --- a/packages/content-management/table_list/src/use_tags.ts +++ b/packages/content-management/table_list/src/use_tags.ts @@ -5,25 +5,45 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { Query, Ast } from '@elastic/eui'; -import type { State } from './table_list_view'; +import type { Tag } from './types'; +import type { State, UserContentCommonSchema } from './table_list_view'; export function useTags({ searchQuery, updateQuery, + items, }: { searchQuery: State['searchQuery']; updateQuery: (query: Query) => void; + items: UserContentCommonSchema[]; }) { const initializeQuery = useCallback(() => { const ast = Ast.create([]); return new Query(ast, undefined, searchQuery.text); }, [searchQuery]); + // 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) { + if (!acc[item.id]) { + acc[item.id] = []; + } + acc[item.id].push(item.id); + } + + return acc; + }, {} as { [tagId: string]: string[] }); + }, [items]); + const addIncludeTagFilter = useCallback( - (tag: { name: string }) => { + (tag: Tag) => { const query = searchQuery.query ?? initializeQuery(); const updatedQuery = query.addOrFieldValue('tag', tag.name, true, 'eq'); updateQuery(updatedQuery); @@ -41,7 +61,7 @@ export function useTags({ ); const addOrRemoveIncludeTagFilter = useCallback( - (tag: { name: string }) => { + (tag: Tag) => { const query = searchQuery.query ?? initializeQuery(); const tagsClauses = query.ast.getFieldClauses('tag'); @@ -63,7 +83,7 @@ export function useTags({ ); const addExcludeTagFilter = useCallback( - (tag: { name: string }) => { + (tag: Tag) => { const query = searchQuery.query ?? initializeQuery(); const updatedQuery = query.addOrFieldValue('tag', tag.name, false, 'eq'); @@ -73,7 +93,7 @@ export function useTags({ ); const removeExcludeTagFilter = useCallback( - (tag: { name: string }) => { + (tag: Tag) => { const query = searchQuery.query ?? initializeQuery(); const updatedQuery = query.removeOrFieldValue('tag', tag.name); updateQuery(updatedQuery); @@ -82,7 +102,7 @@ export function useTags({ ); const addOrRemoveExcludeTagFilter = useCallback( - (tag: { name: string }) => { + (tag: Tag) => { const query = searchQuery.query ?? initializeQuery(); const tagsClauses = query.ast.getFieldClauses('tag'); @@ -106,5 +126,6 @@ export function useTags({ return { addOrRemoveIncludeTagFilter, addOrRemoveExcludeTagFilter, + tagsToTableItemMap, }; } From 158aea11a0c6c9c68149a919b37cff98ac4e522d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Fri, 14 Oct 2022 15:26:21 +0100 Subject: [PATCH 17/46] Fix TS issues --- .../table_list/src/__jest__/tests.helpers.tsx | 1 + packages/content-management/table_list/src/mocks.tsx | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) 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 e3e624da2f85f..32897355b9ad2 100644 --- a/packages/content-management/table_list/src/__jest__/tests.helpers.tsx +++ b/packages/content-management/table_list/src/__jest__/tests.helpers.tsx @@ -20,6 +20,7 @@ export const getMockServices = (overrides?: Partial) => { currentAppId$: from('mockedApp'), navigateToUrl: () => undefined, TagList, + getTagList: () => [], itemHasTags: () => true, ...overrides, }; diff --git a/packages/content-management/table_list/src/mocks.tsx b/packages/content-management/table_list/src/mocks.tsx index ed63e1c66d94b..f0d06c4b3dd58 100644 --- a/packages/content-management/table_list/src/mocks.tsx +++ b/packages/content-management/table_list/src/mocks.tsx @@ -10,6 +10,7 @@ import { from } from 'rxjs'; import { EuiBadgeGroup, EuiBadge } from '@elastic/eui'; import { Services } from './services'; +import { Tag } from './types'; /** * Parameters drawn from the Storybook arguments collection that customize a component story. @@ -22,16 +23,18 @@ const tags = [ name: 'elastic', color: '#8dc4de', description: 'elastic tag', + id: '1', }, { name: 'cloud', color: '#f5ed14', description: 'cloud tag', + id: '2', }, ]; interface Props { - onClick?: (tag: { name: string }) => void; + onClick?: (tag: Tag) => void; tags?: typeof tags | null; } @@ -82,6 +85,7 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) => currentAppId$: from('mockedApp'), navigateToUrl: () => undefined, TagList, + getTagList: () => [], itemHasTags: () => true, ...params, }; From 1d4fefc563293f3ad5077f1ebf4fd81fa315b9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Fri, 14 Oct 2022 15:40:01 +0100 Subject: [PATCH 18/46] Add badge to select option --- .../src/components/tag_filter_panel.tsx | 34 +++++++++++++------ .../table_list/src/use_tags.ts | 10 +++--- 2 files changed, 30 insertions(+), 14 deletions(-) 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 index 7907b2f9b065a..dc35878a9789a 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -22,6 +22,7 @@ import { EuiSpacer, EuiLink, useEuiTheme, + EuiBadge, } from '@elastic/eui'; import type { EuiSelectableProps, ExclusiveUnion, FieldValueOptionType } from '@elastic/eui'; import { css } from '@emotion/react'; @@ -48,7 +49,12 @@ export interface Props { addOrRemoveExcludeTagFilter: (tag: Tag) => void; } -export const TagFilterPanel: FC = ({ query, getTagList, addOrRemoveIncludeTagFilter }) => { +export const TagFilterPanel: FC = ({ + query, + getTagList, + tagsToTableItemMap, + addOrRemoveIncludeTagFilter, +}) => { const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [options, setOptions] = useState([]); @@ -117,25 +123,33 @@ export const TagFilterPanel: FC = ({ query, getTagList, addOrRemoveInclud setOptions( tags.map((tag) => { const { name, id, color } = tag; + return { name, label: name, value: id, tag, view: ( - - - {name} - - + + + + + {name} + + + + + {tagsToTableItemMap[id]?.length ?? 0} + + ), }; }) ); - }, [getTagList]); + }, [getTagList, tagsToTableItemMap]); useEffect(() => { updateTagList(); diff --git a/packages/content-management/table_list/src/use_tags.ts b/packages/content-management/table_list/src/use_tags.ts index 0b1534ceae776..63ba273abafff 100644 --- a/packages/content-management/table_list/src/use_tags.ts +++ b/packages/content-management/table_list/src/use_tags.ts @@ -32,10 +32,12 @@ export function useTags({ const tagReferences = item.references.filter((ref) => ref.type === 'tag'); if (tagReferences.length > 0) { - if (!acc[item.id]) { - acc[item.id] = []; - } - acc[item.id].push(item.id); + tagReferences.forEach((ref) => { + if (!acc[ref.id]) { + acc[ref.id] = []; + } + acc[ref.id].push(item.id); + }); } return acc; From 08e265252a963629446ce9d4a793c0dda735341e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 17 Oct 2022 12:57:44 +0200 Subject: [PATCH 19/46] Add tag selection state to panel --- .../src/components/tag_filter_panel.tsx | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) 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 index dc35878a9789a..fe5cf22a04201 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -58,14 +58,12 @@ export const TagFilterPanel: FC = ({ const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [options, setOptions] = useState([]); + const [tagSelection, setTagSelection] = useState<{ + [tagId: string]: 'include' | 'exclude' | undefined; + }>({}); const isSearchVisible = options.length > 10; - const totalActiveFilters = options.reduce((acc, option) => { - if (option.checked !== undefined) { - acc += 1; - } - return acc; - }, 0); + const totalActiveFilters = Object.keys(tagSelection).length; const footerCSS = css` border-top: ${euiTheme.border.thin}; @@ -100,7 +98,13 @@ export const TagFilterPanel: FC = ({ } const togglePopOver = () => { - setIsPopoverOpen((prev) => !prev); + setIsPopoverOpen((prev) => { + if (prev === false) { + // Refresh the tag list whenever we open the pop over + updateTagList(); + } + return !prev; + }); }; const closePopover = () => { @@ -123,12 +127,16 @@ export const TagFilterPanel: FC = ({ setOptions( tags.map((tag) => { const { name, id, color } = tag; - + let checked; + if (tagSelection[name]) { + checked = tagSelection[name] === 'include' ? ('on' as const) : ('off' as const); + } return { name, label: name, value: id, tag, + checked, view: ( @@ -142,43 +150,37 @@ export const TagFilterPanel: FC = ({ - {tagsToTableItemMap[id]?.length ?? 0} + + {tagsToTableItemMap[id]?.length ?? 0} + ), }; }) ); - }, [getTagList, tagsToTableItemMap]); - - useEffect(() => { - updateTagList(); - }, [updateTagList]); + }, [getTagList, tagsToTableItemMap, tagSelection]); useEffect(() => { if (query) { - const items: { [key: string]: TagOptionItem[] } = { - on: [], - off: [], - rest: [], - }; - const clauseInclude = query.ast.getOrFieldClause('tag', undefined, true, 'eq'); - const clausesExclude = query.ast.getOrFieldClause('tag', undefined, false, 'eq'); - - setOptions((prev) => { - prev.forEach((op) => { - if (clauseInclude && toArray(clauseInclude.value).includes(op.name)) { - items.on.push({ ...op, checked: 'on' as const }); - } else if (clausesExclude && toArray(clausesExclude.value).includes(op.name)) { - items.on.push({ ...op, checked: 'off' as const }); - } else { - items.on.push({ ...op, checked: undefined }); - } + const clauseExclude = query.ast.getOrFieldClause('tag', undefined, false, 'eq'); + + const updatedTagSelection: typeof tagSelection = {}; + + if (clauseInclude) { + toArray(clauseInclude.value).forEach((tagName) => { + updatedTagSelection[tagName] = 'include'; + }); + } + + if (clauseExclude) { + toArray(clauseExclude.value).forEach((tagName) => { + updatedTagSelection[tagName] = 'exclude'; }); + } - return [...items.on, ...items.off, ...items.rest]; - }); + setTagSelection(updatedTagSelection); } }, [query]); From 6defc08108e9421c2e1319c7e14ba92800c673cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 17 Oct 2022 15:19:04 +0200 Subject: [PATCH 20/46] Add handler to clear tag filtering --- .../table_list/src/components/table.tsx | 18 ++++++++----- .../src/components/tag_filter_panel.tsx | 26 ++++++++++++------- .../table_list/src/table_list_view.tsx | 8 +++++- .../table_list/src/use_tags.ts | 9 +++++++ 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/content-management/table_list/src/components/table.tsx b/packages/content-management/table_list/src/components/table.tsx index 00dc59ca2c497..09f14d6743d78 100644 --- a/packages/content-management/table_list/src/components/table.tsx +++ b/packages/content-management/table_list/src/components/table.tsx @@ -36,12 +36,15 @@ type State = Pick< 'items' | 'selectedIds' | 'searchQuery' | 'tableSort' | 'pagination' >; -interface Props - extends State, - Pick< - TagFilterPanelProps, - 'addOrRemoveIncludeTagFilter' | 'addOrRemoveExcludeTagFilter' | 'tagsToTableItemMap' - > { +type TagManagementProps = Pick< + TagFilterPanelProps, + | 'clearTagSelection' + | 'addOrRemoveIncludeTagFilter' + | 'addOrRemoveExcludeTagFilter' + | 'tagsToTableItemMap' +>; + +interface Props extends State, TagManagementProps { dispatch: Dispatch>; entityName: string; entityNamePlural: string; @@ -73,6 +76,7 @@ export function Table({ onSortChange, addOrRemoveExcludeTagFilter, addOrRemoveIncludeTagFilter, + clearTagSelection, }: Props) { const { getTagList } = useServices(); @@ -140,6 +144,7 @@ export function Table({ tagsToTableItemMap={tagsToTableItemMap} addOrRemoveIncludeTagFilter={addOrRemoveIncludeTagFilter} addOrRemoveExcludeTagFilter={addOrRemoveExcludeTagFilter} + clearTagSelection={clearTagSelection} /> ); }, @@ -155,6 +160,7 @@ export function Table({ tagsToTableItemMap, addOrRemoveIncludeTagFilter, addOrRemoveExcludeTagFilter, + clearTagSelection, ]); const search = useMemo(() => { 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 index fe5cf22a04201..b95e37a4553e9 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -41,10 +41,15 @@ interface TagOptionItem extends FieldValueOptionType { tag: Tag; } +interface TagSelection { + [tagId: string]: 'include' | 'exclude' | undefined; +} + export interface Props { query: Query | null; tagsToTableItemMap: { [tagId: string]: string[] }; getTagList: () => Tag[]; + clearTagSelection: () => void; addOrRemoveIncludeTagFilter: (tag: Tag) => void; addOrRemoveExcludeTagFilter: (tag: Tag) => void; } @@ -53,14 +58,13 @@ export const TagFilterPanel: FC = ({ query, getTagList, tagsToTableItemMap, + clearTagSelection, addOrRemoveIncludeTagFilter, }) => { const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [options, setOptions] = useState([]); - const [tagSelection, setTagSelection] = useState<{ - [tagId: string]: 'include' | 'exclude' | undefined; - }>({}); + const [tagSelection, setTagSelection] = useState({}); const isSearchVisible = options.length > 10; const totalActiveFilters = Object.keys(tagSelection).length; @@ -166,7 +170,7 @@ export const TagFilterPanel: FC = ({ const clauseInclude = query.ast.getOrFieldClause('tag', undefined, true, 'eq'); const clauseExclude = query.ast.getOrFieldClause('tag', undefined, false, 'eq'); - const updatedTagSelection: typeof tagSelection = {}; + const updatedTagSelection: TagSelection = {}; if (clauseInclude) { toArray(clauseInclude.value).forEach((tagName) => { @@ -206,7 +210,7 @@ export const TagFilterPanel: FC = ({ anchorPosition="downCenter" panelClassName="euiFilterGroup__popoverPanel" > - + singleSelection={false} @@ -231,16 +235,20 @@ export const TagFilterPanel: FC = ({ - {totalActiveFilters > 0 ? ( - + {totalActiveFilters > 0 && ( + Clear selection - ) : ( - )} + Ctrl + click to filter out tags + 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 19ed17c5736d3..7faa5197b6bee 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -202,7 +202,12 @@ function TableListViewComp({ }); }, []); - const { addOrRemoveIncludeTagFilter, addOrRemoveExcludeTagFilter, tagsToTableItemMap } = useTags({ + const { + addOrRemoveIncludeTagFilter, + addOrRemoveExcludeTagFilter, + clearTagSelection, + tagsToTableItemMap, + } = useTags({ searchQuery, updateQuery, items, @@ -529,6 +534,7 @@ function TableListViewComp({ onSortChange={onSortChange} addOrRemoveIncludeTagFilter={addOrRemoveIncludeTagFilter} addOrRemoveExcludeTagFilter={addOrRemoveExcludeTagFilter} + clearTagSelection={clearTagSelection} /> {/* Delete modal */} diff --git a/packages/content-management/table_list/src/use_tags.ts b/packages/content-management/table_list/src/use_tags.ts index 63ba273abafff..782f414424e93 100644 --- a/packages/content-management/table_list/src/use_tags.ts +++ b/packages/content-management/table_list/src/use_tags.ts @@ -125,9 +125,18 @@ export function useTags({ [searchQuery.query, initializeQuery, addExcludeTagFilter, removeExcludeTagFilter] ); + const clearTagSelection = useCallback(() => { + if (!searchQuery.query) { + return; + } + const updatedQuery = searchQuery.query.removeOrFieldClauses('tag'); + updateQuery(updatedQuery); + }, [searchQuery.query, updateQuery]); + return { addOrRemoveIncludeTagFilter, addOrRemoveExcludeTagFilter, + clearTagSelection, tagsToTableItemMap, }; } From fac3cef12f3f57411256c72e7126d1581dc490f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 18 Oct 2022 11:11:06 +0200 Subject: [PATCH 21/46] Add link to navigate to tag management --- .../src/components/tag_filter_panel.tsx | 28 +++++++++++++++---- .../table_list/src/constants.ts | 9 ++++++ .../table_list/src/services.tsx | 14 ++++++++-- 3 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 packages/content-management/table_list/src/constants.ts 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 index b95e37a4553e9..98cb827ecd91e 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { FC, useState, useEffect, useCallback } from 'react'; +import React, { FC, useState, useEffect, useCallback, useMemo } from 'react'; import { Query, EuiPopover, @@ -26,8 +26,11 @@ import { } from '@elastic/eui'; import type { EuiSelectableProps, ExclusiveUnion, FieldValueOptionType } from '@elastic/eui'; import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; -import { Tag } from '../types'; +import { useServices } from '../services'; +import type { Tag } from '../types'; const toArray = (item: unknown) => (Array.isArray(item) ? item : [item]); @@ -65,6 +68,7 @@ export const TagFilterPanel: FC = ({ const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [options, setOptions] = useState([]); const [tagSelection, setTagSelection] = useState({}); + const { navigateToUrl, currentAppId$, getTagManagementUrl } = useServices(); const isSearchVisible = options.length > 10; const totalActiveFilters = Object.keys(tagSelection).length; @@ -252,9 +256,23 @@ export const TagFilterPanel: FC = ({ - - Manage all tags - + + + {i18n.translate( + 'contentManagement.tableList.tagFilterPanel.manageAllTagsLinkLabel', + { + defaultMessage: 'Manage all tags', + } + )} + + 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 0000000000000..d8afaa75d4d94 --- /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/services.tsx b/packages/content-management/table_list/src/services.tsx index f7a709c0754af..23115c06bb070 100644 --- a/packages/content-management/table_list/src/services.tsx +++ b/packages/content-management/table_list/src/services.tsx @@ -11,7 +11,8 @@ import type { Observable } from 'rxjs'; import type { FormattedRelative } from '@kbn/i18n-react'; import { RedirectAppLinksKibanaProvider } from '@kbn/shared-ux-link-redirect-app'; -import { Tag } from './types'; +import { TAG_MANAGEMENT_APP_URL } from './constants'; +import type { Tag } from './types'; type UnmountCallback = () => void; type MountPoint = (element: HTMLElement) => UnmountCallback; @@ -45,10 +46,13 @@ export interface Services { referencesToExclude?: SavedObjectsFindOptionsReference[]; }; DateFormatterComp?: DateFormatter; + /** Handler to retrieve the list of available tags */ getTagList: () => Tag[]; TagList: FC<{ references: SavedObjectsReference[]; onClick?: (tag: Tag) => void }>; - /** Predicate function to indicate if the saved object references include tags */ + /** 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; } const TableListViewContext = React.createContext(null); @@ -81,6 +85,11 @@ export interface TableListViewKibanaDependencies { addDanger: (notifyArgs: { title: MountPoint; text?: string }) => void; }; }; + http: { + basePath: { + prepend: (path: string) => string; + }; + }; }; /** * Handler from the '@kbn/kibana-react-plugin/public' Plugin @@ -203,6 +212,7 @@ export const TableListViewKibanaProvider: FC = getTagList={getTagList} TagList={TagList} itemHasTags={itemHasTags} + getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)} > {children} From bd27745fe90a49290d06dee8aba133926541852d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 18 Oct 2022 11:53:47 +0200 Subject: [PATCH 22/46] Update dashboard integration --- src/plugins/dashboard/public/application/dashboard_router.tsx | 2 ++ .../public/application/listing/dashboard_listing.test.tsx | 3 ++- .../dashboard/public/services/saved_objects_tagging/types.ts | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index db3d96fe8fb10..c3013be3cb941 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -69,6 +69,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da notifications, savedObjectsTagging, settings: { uiSettings }, + http, } = pluginServices.getServices(); let globalEmbedSettings: DashboardEmbedSettings | undefined; @@ -171,6 +172,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da core: { application: application as TableListViewApplicationService, notifications, + http, }, toMountPoint, savedObjectsTagging: savedObjectsTagging.hasApi // TODO: clean up this logic once https://github.com/elastic/kibana/issues/140433 is resolved 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 4a77249aeb39c..ca399e09a2434 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 } = pluginServices.getServices(); + const { application, notifications, savedObjectsTagging, http } = pluginServices.getServices(); return ( @@ -41,6 +41,7 @@ function mountWith({ props: incomingProps }: { props?: DashboardListingProps }) application: application as unknown as TableListViewKibanaDependencies['core']['application'], notifications, + http, }} savedObjectsTagging={ { 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 ba08a53709346..dd4b8bc484504 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']; } From d16d6d7e25d1bac5376c6a830a6cc49400e3fadb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 18 Oct 2022 11:54:17 +0200 Subject: [PATCH 23/46] Mark tag id as optional --- .../table_list/src/components/tag_filter_panel.tsx | 4 ++-- packages/content-management/table_list/src/types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 98cb827ecd91e..08aac191b1aaa 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -142,7 +142,7 @@ export const TagFilterPanel: FC = ({ return { name, label: name, - value: id, + value: id ?? '', tag, checked, view: ( @@ -159,7 +159,7 @@ export const TagFilterPanel: FC = ({ - {tagsToTableItemMap[id]?.length ?? 0} + {tagsToTableItemMap[id ?? '']?.length ?? 0} diff --git a/packages/content-management/table_list/src/types.ts b/packages/content-management/table_list/src/types.ts index 6bc1660a99112..0e716e6d59cf3 100644 --- a/packages/content-management/table_list/src/types.ts +++ b/packages/content-management/table_list/src/types.ts @@ -7,7 +7,7 @@ */ export interface Tag { - id: string; + id?: string; name: string; description: string; color: string; From 8889f8ccc79f11fbc4159442a14237021677a816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 18 Oct 2022 11:54:27 +0200 Subject: [PATCH 24/46] Fix TS issues --- .../table_list/src/__jest__/tests.helpers.tsx | 1 + .../table_list/src/components/tag_filter_panel.tsx | 2 +- packages/content-management/table_list/src/mocks.tsx | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) 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 32897355b9ad2..69f6bb954bef8 100644 --- a/packages/content-management/table_list/src/__jest__/tests.helpers.tsx +++ b/packages/content-management/table_list/src/__jest__/tests.helpers.tsx @@ -22,6 +22,7 @@ export const getMockServices = (overrides?: Partial) => { TagList, getTagList: () => [], itemHasTags: () => true, + getTagManagementUrl: () => '', ...overrides, }; 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 index 08aac191b1aaa..51af90853845c 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { FC, useState, useEffect, useCallback, useMemo } from 'react'; +import React, { FC, useState, useEffect, useCallback } from 'react'; import { Query, EuiPopover, diff --git a/packages/content-management/table_list/src/mocks.tsx b/packages/content-management/table_list/src/mocks.tsx index f0d06c4b3dd58..b72669ae2dd6a 100644 --- a/packages/content-management/table_list/src/mocks.tsx +++ b/packages/content-management/table_list/src/mocks.tsx @@ -87,6 +87,7 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) => TagList, getTagList: () => [], itemHasTags: () => true, + getTagManagementUrl: () => '', ...params, }; From ba6a4fd4d4124ad9ed0fb3e8794fd8c18414c65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 18 Oct 2022 16:08:33 +0200 Subject: [PATCH 25/46] Add support for ctrl click --- .../src/components/tag_filter_panel.tsx | 62 ++++++++- .../table_list/src/use_tags.ts | 131 +++++++++++------- 2 files changed, 136 insertions(+), 57 deletions(-) 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 index 51af90853845c..9d1853169efa3 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { FC, useState, useEffect, useCallback } from 'react'; +import React, { FC, useState, useEffect, useCallback, useRef, MouseEvent } from 'react'; import { Query, EuiPopover, @@ -63,13 +63,19 @@ export const TagFilterPanel: FC = ({ tagsToTableItemMap, clearTagSelection, addOrRemoveIncludeTagFilter, + addOrRemoveExcludeTagFilter, }) => { const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [options, setOptions] = useState([]); const [tagSelection, setTagSelection] = useState({}); const { navigateToUrl, currentAppId$, getTagManagementUrl } = useServices(); - + const optionItemsRef = useRef< + Array<{ + tag: Tag; + el: HTMLDivElement | HTMLSpanElement | null; + }> + >([]); const isSearchVisible = options.length > 10; const totalActiveFilters = Object.keys(tagSelection).length; @@ -100,7 +106,6 @@ export const TagFilterPanel: FC = ({ searchable: true, searchProps: { compressed: true, - // disabled: this.state.error != null, }, }; } @@ -108,6 +113,7 @@ export const TagFilterPanel: FC = ({ const togglePopOver = () => { setIsPopoverOpen((prev) => { if (prev === false) { + optionItemsRef.current = []; // Refresh the tag list whenever we open the pop over updateTagList(); } @@ -129,11 +135,27 @@ export const TagFilterPanel: FC = ({ [options, addOrRemoveIncludeTagFilter] ); + const onOptionClick = useCallback( + (tag: Tag) => (e: MouseEvent) => { + e.preventDefault(); + + if (e.ctrlKey) { + addOrRemoveExcludeTagFilter(tag); + } else { + addOrRemoveIncludeTagFilter(tag); + } + + setIsPopoverOpen(false); + e.stopPropagation(); + }, + [addOrRemoveIncludeTagFilter, addOrRemoveExcludeTagFilter] + ); + const updateTagList = useCallback(() => { const tags = getTagList(); setOptions( - tags.map((tag) => { + tags.map((tag, i) => { const { name, id, color } = tag; let checked; if (tagSelection[name]) { @@ -146,7 +168,14 @@ export const TagFilterPanel: FC = ({ tag, checked, view: ( - + { + optionItemsRef.current[i] = { tag, el }; + }} + > = ({ }; }) ); - }, [getTagList, tagsToTableItemMap, tagSelection]); + }, [getTagList, tagsToTableItemMap, tagSelection, onOptionClick]); useEffect(() => { if (query) { @@ -192,6 +221,27 @@ export const TagFilterPanel: FC = ({ } }, [query]); + useEffect(() => { + if (isPopoverOpen) { + setImmediate(() => { + optionItemsRef.current.forEach(({ el, tag }) => { + if (el) { + el.addEventListener( + 'contextmenu', + (e) => { + // Disable context menu as on Mac "ctrl + click" equals "right clicking" which opens the context menu + e.preventDefault(); + addOrRemoveExcludeTagFilter(tag); + setIsPopoverOpen(false); + }, + false + ); + } + }); + }); + } + }, [isPopoverOpen, addOrRemoveExcludeTagFilter]); + return ( <> Query; + export function useTags({ searchQuery, updateQuery, @@ -44,85 +46,111 @@ export function useTags({ }, {} as { [tagId: string]: string[] }); }, [items]); - const addIncludeTagFilter = useCallback( - (tag: Tag) => { - const query = searchQuery.query ?? initializeQuery(); - const updatedQuery = query.addOrFieldValue('tag', tag.name, true, 'eq'); - updateQuery(updatedQuery); - }, - [searchQuery, initializeQuery, updateQuery] - ); - - const removeIncludeTagFilter = useCallback( - (tag: { name: string }) => { - const query = searchQuery.query ?? initializeQuery(); - const updatedQuery = query.removeOrFieldValue('tag', tag.name); - updateQuery(updatedQuery); - }, - [searchQuery, initializeQuery, updateQuery] + const updateTagClauseGetter = useCallback( + (queryUpdater: QueryUpdater) => + (tag: Tag, q?: Query, doUpdate: boolean = true) => { + const query = q !== undefined ? q : searchQuery.query ?? initializeQuery(); + const updatedQuery = queryUpdater(query, tag); + if (doUpdate) { + updateQuery(updatedQuery); + } + return updatedQuery; + }, + [searchQuery.query, initializeQuery, updateQuery] ); - const addOrRemoveIncludeTagFilter = useCallback( - (tag: Tag) => { - const query = searchQuery.query ?? initializeQuery(); + const hasTagInClauseGetter = useCallback( + (matchValue: 'must' | 'must_not') => (tag: Tag, _query?: Query) => { + const query = Boolean(_query) ? _query! : searchQuery.query ?? initializeQuery(); const tagsClauses = query.ast.getFieldClauses('tag'); if (tagsClauses) { const mustHaveTagClauses = query.ast .getFieldClauses('tag') - .find(({ match }) => match === 'must')?.value as string[]; + .find(({ match }) => match === matchValue)?.value as string[]; if (mustHaveTagClauses && mustHaveTagClauses.includes(tag.name)) { - // Already selected, remove the filter - removeIncludeTagFilter(tag); - return; + return true; } } - - addIncludeTagFilter(tag); + return false; }, - [searchQuery.query, initializeQuery, addIncludeTagFilter, removeIncludeTagFilter] + [searchQuery.query, initializeQuery] ); - const addExcludeTagFilter = useCallback( - (tag: Tag) => { - const query = searchQuery.query ?? initializeQuery(); + const addTagToIncludeClause = useMemo( + () => updateTagClauseGetter((query, tag) => query.addOrFieldValue('tag', tag.name, true, 'eq')), + [updateTagClauseGetter] + ); - const updatedQuery = query.addOrFieldValue('tag', tag.name, false, 'eq'); - updateQuery(updatedQuery); - }, - [initializeQuery, searchQuery.query, updateQuery] + const removeTagFromIncludeClause = useMemo( + () => updateTagClauseGetter((query, tag) => query.removeOrFieldValue('tag', tag.name)), + [updateTagClauseGetter] + ); + + const addTagToExcludeClause = useMemo( + () => + updateTagClauseGetter((query, tag) => query.addOrFieldValue('tag', tag.name, false, 'eq')), + [updateTagClauseGetter] ); - const removeExcludeTagFilter = useCallback( + const removeTagFromExcludeClause = useMemo( + () => updateTagClauseGetter((query, tag) => query.removeOrFieldValue('tag', tag.name)), + [updateTagClauseGetter] + ); + + const hasTagInInclude = useMemo(() => hasTagInClauseGetter('must'), [hasTagInClauseGetter]); + const hasTagInExclude = useMemo(() => hasTagInClauseGetter('must_not'), [hasTagInClauseGetter]); + + const addOrRemoveIncludeTagFilter = useCallback( (tag: Tag) => { - const query = searchQuery.query ?? initializeQuery(); - const updatedQuery = query.removeOrFieldValue('tag', tag.name); - updateQuery(updatedQuery); + let query: Query | undefined; + + // Remove the tag in the "Exclude" list if it is there + if (hasTagInExclude(tag)) { + query = removeTagFromExcludeClause(tag, undefined, false); + } + + if (hasTagInInclude(tag, query)) { + // Already selected, remove the filter + removeTagFromIncludeClause(tag, query); + return; + } + addTagToIncludeClause(tag, query); }, - [searchQuery, initializeQuery, updateQuery] + [ + hasTagInExclude, + hasTagInInclude, + removeTagFromExcludeClause, + addTagToIncludeClause, + removeTagFromIncludeClause, + ] ); const addOrRemoveExcludeTagFilter = useCallback( (tag: Tag) => { - const query = searchQuery.query ?? initializeQuery(); - const tagsClauses = query.ast.getFieldClauses('tag'); + let query: Query | undefined; - if (tagsClauses) { - const mustHaveTagClauses = query.ast - .getFieldClauses('tag') - .find(({ match }) => match === 'must_not')?.value as string[]; + // Remove the tag in the "Include" list if it is there + if (hasTagInInclude(tag)) { + query = removeTagFromIncludeClause(tag, undefined, false); + } - if (mustHaveTagClauses && mustHaveTagClauses.includes(tag.name)) { - // Already selected, remove the filter - removeExcludeTagFilter(tag); - return; - } + if (hasTagInExclude(tag, query)) { + // Already selected, remove the filter + removeTagFromExcludeClause(tag, query); + return; } - addExcludeTagFilter(tag); + addTagToExcludeClause(tag, query); }, - [searchQuery.query, initializeQuery, addExcludeTagFilter, removeExcludeTagFilter] + [ + hasTagInInclude, + hasTagInExclude, + removeTagFromIncludeClause, + addTagToExcludeClause, + removeTagFromExcludeClause, + ] ); const clearTagSelection = useCallback(() => { @@ -131,6 +159,7 @@ export function useTags({ } const updatedQuery = searchQuery.query.removeOrFieldClauses('tag'); updateQuery(updatedQuery); + return updateQuery; }, [searchQuery.query, updateQuery]); return { From 4ce73b8f7e167ea7583800e8c578de17f938aea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 24 Oct 2022 15:57:05 +0100 Subject: [PATCH 26/46] Refactor ctrl + click detection --- .../src/components/ctrl_click_detect.tsx | 54 +++++++ .../src/components/tag_filter_panel.tsx | 152 ++++++++---------- 2 files changed, 118 insertions(+), 88 deletions(-) create mode 100644 packages/content-management/table_list/src/components/ctrl_click_detect.tsx diff --git a/packages/content-management/table_list/src/components/ctrl_click_detect.tsx b/packages/content-management/table_list/src/components/ctrl_click_detect.tsx new file mode 100644 index 0000000000000..11964551bc7c5 --- /dev/null +++ b/packages/content-management/table_list/src/components/ctrl_click_detect.tsx @@ -0,0 +1,54 @@ +/* + * 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, useRef } from 'react'; +import type { FC, ReactNode, MutableRefObject, MouseEvent as ReactMouseEvent } from 'react'; + +type ClickHandlerWithMeta = (e: ReactMouseEvent, meta: { isCtrlKey: boolean }) => void; + +interface Props { + onClick: ClickHandlerWithMeta; + children: (ref: MutableRefObject) => ReactNode; +} + +export const CtrlClickDetect: FC = ({ onClick, children }) => { + const elRef = useRef(null); + const isMounted = useRef(false); + + useEffect(() => { + function onElClick(e: MouseEvent) { + e.preventDefault(); + onClick(e as unknown as ReactMouseEvent, { isCtrlKey: e.ctrlKey }); + } + + function onElContextmenu(e: MouseEvent) { + // Disable context menu as on Mac "ctrl + click" equals "right clicking" + // which opens the context menu + e.preventDefault(); + onClick(e as unknown as ReactMouseEvent, { isCtrlKey: true }); + } + + const el = elRef.current; + + if (el && !isMounted.current && onClick) { + el.addEventListener('click', onElClick); + el.addEventListener('contextmenu', onElContextmenu, false); + } + + isMounted.current = true; + + return () => { + if (el) { + el.removeEventListener('click', onElClick); + el.removeEventListener('contextmenu', onElContextmenu); + } + isMounted.current = false; + }; + }, [onClick]); + + return <>{children(elRef)}; +}; 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 index 9d1853169efa3..214be5db123c7 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import React, { FC, useState, useEffect, useCallback, useRef, MouseEvent } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; +import type { FC, MouseEvent } from 'react'; import { Query, EuiPopover, @@ -31,6 +32,7 @@ import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { useServices } from '../services'; import type { Tag } from '../types'; +import { CtrlClickDetect } from './ctrl_click_detect'; const toArray = (item: unknown) => (Array.isArray(item) ? item : [item]); @@ -70,12 +72,6 @@ export const TagFilterPanel: FC = ({ const [options, setOptions] = useState([]); const [tagSelection, setTagSelection] = useState({}); const { navigateToUrl, currentAppId$, getTagManagementUrl } = useServices(); - const optionItemsRef = useRef< - Array<{ - tag: Tag; - el: HTMLDivElement | HTMLSpanElement | null; - }> - >([]); const isSearchVisible = options.length > 10; const totalActiveFilters = Object.keys(tagSelection).length; @@ -110,20 +106,13 @@ export const TagFilterPanel: FC = ({ }; } - const togglePopOver = () => { - setIsPopoverOpen((prev) => { - if (prev === false) { - optionItemsRef.current = []; - // Refresh the tag list whenever we open the pop over - updateTagList(); - } - return !prev; - }); - }; + const togglePopOver = useCallback(() => { + setIsPopoverOpen((prev) => !prev); + }, []); - const closePopover = () => { + const closePopover = useCallback(() => { setIsPopoverOpen(false); - }; + }, []); const onSelectChange = useCallback( (updatedOptions: TagOptionItem[]) => { @@ -136,66 +125,67 @@ export const TagFilterPanel: FC = ({ ); const onOptionClick = useCallback( - (tag: Tag) => (e: MouseEvent) => { - e.preventDefault(); + (tag: Tag) => + (e: MouseEvent, { isCtrlKey }: { isCtrlKey: boolean }) => { + e.preventDefault(); - if (e.ctrlKey) { - addOrRemoveExcludeTagFilter(tag); - } else { - addOrRemoveIncludeTagFilter(tag); - } + if (isCtrlKey) { + addOrRemoveExcludeTagFilter(tag); + } else { + addOrRemoveIncludeTagFilter(tag); + } - setIsPopoverOpen(false); - e.stopPropagation(); - }, + setIsPopoverOpen(false); + + e.stopPropagation(); + }, [addOrRemoveIncludeTagFilter, addOrRemoveExcludeTagFilter] ); const updateTagList = useCallback(() => { const tags = getTagList(); - setOptions( - tags.map((tag, i) => { - const { name, id, color } = tag; - let checked; - if (tagSelection[name]) { - checked = tagSelection[name] === 'include' ? ('on' as const) : ('off' as const); - } - return { - name, - label: name, - value: id ?? '', - tag, - checked, - view: ( - { - optionItemsRef.current[i] = { tag, el }; - }} - > - - - - {name} - - - - - - {tagsToTableItemMap[id ?? '']?.length ?? 0} - - - - ), - }; - }) - ); + 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: ( + + {(ref) => ( + + + + + {name} + + + + + + {tagsToTableItemMap[id ?? '']?.length ?? 0} + + + + )} + + ), + }; + }); + + setOptions(tagsToSelectOptions); }, [getTagList, tagsToTableItemMap, tagSelection, onOptionClick]); useEffect(() => { @@ -223,24 +213,10 @@ export const TagFilterPanel: FC = ({ useEffect(() => { if (isPopoverOpen) { - setImmediate(() => { - optionItemsRef.current.forEach(({ el, tag }) => { - if (el) { - el.addEventListener( - 'contextmenu', - (e) => { - // Disable context menu as on Mac "ctrl + click" equals "right clicking" which opens the context menu - e.preventDefault(); - addOrRemoveExcludeTagFilter(tag); - setIsPopoverOpen(false); - }, - false - ); - } - }); - }); + // Refresh the tag list whenever we open the pop over + updateTagList(); } - }, [isPopoverOpen, addOrRemoveExcludeTagFilter]); + }, [isPopoverOpen, updateTagList]); return ( <> From 25f86406fe53dc95223f54bb527aaae0573c398a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 24 Oct 2022 16:47:16 +0100 Subject: [PATCH 27/46] Add tagRender prop to tag list --- .../public/components/base/tag_list.tsx | 13 +++++++++---- .../public/components/connected/tag_list.tsx | 10 ++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) 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 d18e224b96932..cfe67c5ed0827 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 2ef49512340ac..46deb57db7f40 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 { From 2fd634cd0e2100683d4cba7098420e9a1f9c601a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 24 Oct 2022 16:47:31 +0100 Subject: [PATCH 28/46] Add tagRender prop to tag list (2) --- src/plugins/saved_objects_tagging_oss/public/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index 7cbe070859ac1..f52481602492f 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -226,6 +226,8 @@ export interface TagListComponentProps { * Handler to execute when clicking on a tag */ onClick?: (tag: TagWithOptionalId) => void; + + tagRender?: (tag: TagWithOptionalId) => JSX.Element; } /** From d81540c932a6472a5e33e1d35d0a6a1eb41d2886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 24 Oct 2022 16:52:16 +0100 Subject: [PATCH 29/46] Add ctrl click tag in table --- .../src/components/item_details.tsx | 8 ++- .../table_list/src/components/tag_badge.tsx | 54 +++++++++++++++++++ .../src/components/tag_filter_panel.tsx | 5 ++ .../table_list/src/services.tsx | 11 ++-- .../table_list/src/table_list_view.tsx | 9 +++- 5 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 packages/content-management/table_list/src/components/tag_badge.tsx 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 62f0815b98462..75a9cba760f40 100644 --- a/packages/content-management/table_list/src/components/item_details.tsx +++ b/packages/content-management/table_list/src/components/item_details.tsx @@ -13,6 +13,7 @@ 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, @@ -21,7 +22,7 @@ type InheritedProps = Pick< interface Props extends InheritedProps { item: T; searchTerm?: string; - onClickTag: (tag: Tag) => void; + onClickTag: (tag: Tag, isCtrlKey: boolean) => void; } /** @@ -116,7 +117,10 @@ export function ItemDetails({ {hasTags && ( <> - + } + /> )} 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 0000000000000..a1e614b88241c --- /dev/null +++ b/packages/content-management/table_list/src/components/tag_badge.tsx @@ -0,0 +1,54 @@ +/* + * 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'; +import { CtrlClickDetect } from './ctrl_click_detect'; + +export interface Props { + tag: Tag; + onClick: (tag: Tag, isCtrlKey: 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 ( + { + onClick(tag, isCtrlKey); + }} + > + {(ref) => ( + + undefined, + onClickAriaLabel: i18n.translate('contentManagement.tableList.tagBadge.buttonLabel', { + defaultMessage: '{tagName} tag button.', + values: { + tagName: tag.name, + }, + }), + iconOnClick: () => undefined, + iconOnClickAriaLabel: '', + }} + > + {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 index 214be5db123c7..c4b52e40d2431 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -241,6 +241,7 @@ export const TagFilterPanel: FC = ({ panelClassName="euiFilterGroup__popoverPanel" > + {/* Selectable */} singleSelection={false} @@ -264,6 +265,8 @@ export const TagFilterPanel: FC = ({ )} + + {/* Clear selection + help text */} {totalActiveFilters > 0 && ( = ({ + + {/* Link to manage all tags */} Tag[]; - TagList: FC<{ references: SavedObjectsReference[]; onClick?: (tag: Tag) => void }>; + TagList: FC<{ + references: SavedObjectsReference[]; + onClick?: (tag: Tag) => void; + tagRender?: (tag: Tag) => JSX.Element; + }>; /** 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 */ @@ -118,6 +122,7 @@ export interface TableListViewKibanaDependencies { references: SavedObjectsReference[]; }; onClick?: (tag: Tag) => void; + tagRender?: (tag: Tag) => JSX.Element; }>; }; parseSearchQuery: ( @@ -163,12 +168,12 @@ export const TableListViewKibanaProvider: FC = }, [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; 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 7faa5197b6bee..6fe2d34fe1ffc 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -228,8 +228,12 @@ function TableListViewComp({ item={record} getDetailViewLink={getDetailViewLink} onClickTitle={onClickTitle} - onClickTag={(tag) => { - addOrRemoveIncludeTagFilter(tag); + onClickTag={(tag, isCtrlKey) => { + if (isCtrlKey) { + addOrRemoveExcludeTagFilter(tag); + } else { + addOrRemoveIncludeTagFilter(tag); + } }} searchTerm={searchQuery.text} /> @@ -300,6 +304,7 @@ function TableListViewComp({ onClickTitle, searchQuery.text, addOrRemoveIncludeTagFilter, + addOrRemoveExcludeTagFilter, DateFormatterComp, ]); From 72f04cb430469e05c25f385bce28159866bd7392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 8 Nov 2022 10:56:34 +0000 Subject: [PATCH 30/46] Refactor Ctrl Click Detect comp --- .../src/components/ctrl_click_detect.tsx | 33 +++++++------ .../src/components/item_details.tsx | 2 +- .../table_list/src/components/tag_badge.tsx | 46 +++++++++++-------- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/packages/content-management/table_list/src/components/ctrl_click_detect.tsx b/packages/content-management/table_list/src/components/ctrl_click_detect.tsx index 11964551bc7c5..91606867d7161 100644 --- a/packages/content-management/table_list/src/components/ctrl_click_detect.tsx +++ b/packages/content-management/table_list/src/components/ctrl_click_detect.tsx @@ -5,37 +5,45 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useCallback } from 'react'; import type { FC, ReactNode, MutableRefObject, MouseEvent as ReactMouseEvent } from 'react'; type ClickHandlerWithMeta = (e: ReactMouseEvent, meta: { isCtrlKey: boolean }) => void; interface Props { onClick: ClickHandlerWithMeta; - children: (ref: MutableRefObject) => ReactNode; + children: ( + ref: MutableRefObject, + onClick: (e: ReactMouseEvent) => void + ) => ReactNode; } export const CtrlClickDetect: FC = ({ onClick, children }) => { - const elRef = useRef(null); + const elRef = useRef(null); const isMounted = useRef(false); - useEffect(() => { - function onElClick(e: MouseEvent) { + const onElClick = useCallback( + (e: ReactMouseEvent) => { e.preventDefault(); - onClick(e as unknown as ReactMouseEvent, { isCtrlKey: e.ctrlKey }); - } + onClick(e, { isCtrlKey: e.ctrlKey }); + }, + [onClick] + ); - function onElContextmenu(e: MouseEvent) { + const onElContextmenu = useCallback( + (e: MouseEvent) => { // Disable context menu as on Mac "ctrl + click" equals "right clicking" // which opens the context menu e.preventDefault(); onClick(e as unknown as ReactMouseEvent, { isCtrlKey: true }); - } + }, + [onClick] + ); + useEffect(() => { const el = elRef.current; if (el && !isMounted.current && onClick) { - el.addEventListener('click', onElClick); el.addEventListener('contextmenu', onElContextmenu, false); } @@ -43,12 +51,11 @@ export const CtrlClickDetect: FC = ({ onClick, children }) => { return () => { if (el) { - el.removeEventListener('click', onElClick); el.removeEventListener('contextmenu', onElContextmenu); } isMounted.current = false; }; - }, [onClick]); + }, [onClick, onElContextmenu]); - return <>{children(elRef)}; + return <>{children(elRef, onElClick)}; }; 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 75a9cba760f40..ccfbb5e3ea55a 100644 --- a/packages/content-management/table_list/src/components/item_details.tsx +++ b/packages/content-management/table_list/src/components/item_details.tsx @@ -119,7 +119,7 @@ export function ItemDetails({ } + tagRender={(tag) => } /> )} diff --git a/packages/content-management/table_list/src/components/tag_badge.tsx b/packages/content-management/table_list/src/components/tag_badge.tsx index a1e614b88241c..c28ea91e4a376 100644 --- a/packages/content-management/table_list/src/components/tag_badge.tsx +++ b/packages/content-management/table_list/src/components/tag_badge.tsx @@ -9,6 +9,7 @@ import React, { FC } from 'react'; import { EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; import type { Tag } from '../types'; import { CtrlClickDetect } from './ctrl_click_detect'; @@ -22,32 +23,41 @@ export interface Props { * The badge representation of a Tag, which is the default display to be used for them. */ export const TagBadge: FC = ({ tag, onClick }) => { + const buttonCSS = css` + cursor: pointer; + &:hover { + text-decoration: underline; + } + `; + + const badgeCSS = css` + cursor: pointer; + text-decoration: inherit; + `; + return ( { onClick(tag, isCtrlKey); }} > - {(ref) => ( - - undefined, - onClickAriaLabel: i18n.translate('contentManagement.tableList.tagBadge.buttonLabel', { - defaultMessage: '{tagName} tag button.', - values: { - tagName: tag.name, - }, - }), - iconOnClick: () => undefined, - iconOnClickAriaLabel: '', - }} - > + {(ref, onClickWrapped) => ( + )} ); From a0149590e2200ffa78e7a15003aef609b2931f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 8 Nov 2022 10:57:20 +0000 Subject: [PATCH 31/46] Add jest test to filter by tag from the table --- .../table_list/src/mocks.tsx | 76 ++++----- .../table_list/src/services.tsx | 12 +- .../table_list/src/table_list_view.test.tsx | 149 +++++++++++++++--- .../table_list/src/table_list_view.tsx | 4 +- 4 files changed, 166 insertions(+), 75 deletions(-) diff --git a/packages/content-management/table_list/src/mocks.tsx b/packages/content-management/table_list/src/mocks.tsx index b72669ae2dd6a..8ba92754fbe5f 100644 --- a/packages/content-management/table_list/src/mocks.tsx +++ b/packages/content-management/table_list/src/mocks.tsx @@ -7,10 +7,8 @@ */ import React from 'react'; import { from } from 'rxjs'; -import { EuiBadgeGroup, EuiBadge } from '@elastic/eui'; -import { Services } from './services'; -import { Tag } from './types'; +import type { Services, TagListProps } from './services'; /** * Parameters drawn from the Storybook arguments collection that customize a component story. @@ -18,58 +16,42 @@ import { Tag } from './types'; export type Params = Record, any>; type ActionFn = (name: string) => any; -const tags = [ - { - name: 'elastic', - color: '#8dc4de', - description: 'elastic tag', - id: '1', - }, - { - name: 'cloud', - color: '#f5ed14', - description: 'cloud tag', - id: '2', - }, -]; - -interface Props { - onClick?: (tag: Tag) => 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 ; }; /** diff --git a/packages/content-management/table_list/src/services.tsx b/packages/content-management/table_list/src/services.tsx index 04e0892062786..dd0879a4ae21d 100644 --- a/packages/content-management/table_list/src/services.tsx +++ b/packages/content-management/table_list/src/services.tsx @@ -31,6 +31,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. */ @@ -48,11 +54,7 @@ export interface Services { DateFormatterComp?: DateFormatter; /** Handler to retrieve the list of available tags */ getTagList: () => Tag[]; - TagList: FC<{ - references: SavedObjectsReference[]; - onClick?: (tag: Tag) => void; - tagRender?: (tag: Tag) => JSX.Element; - }>; + 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 */ 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 01b1a3b215ea1..56877459cf651 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 @@ -123,6 +123,7 @@ describe('TableListView', () => { title: 'Item 1', description: 'Item 1 description', }, + references: [], }, { id: '456', @@ -132,6 +133,7 @@ describe('TableListView', () => { title: 'Item 2', description: 'Item 2 description', }, + references: [], }, ]; @@ -150,8 +152,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 +162,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 +170,7 @@ describe('TableListView', () => { id, updatedAt, attributes, + references, }; }); @@ -187,8 +190,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 +203,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 +214,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 +228,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 +244,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 ]); }); }); @@ -252,6 +259,7 @@ describe('TableListView', () => { attributes: { title: `Item ${i < 10 ? `0${i}` : i}`, // prefix with "0" for correct A-Z sorting }, + references: [], })); const props = { @@ -275,8 +283,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,20 +312,26 @@ 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)); @@ -329,6 +343,7 @@ describe('TableListView', () => { attributes: { title: 'z-foo', // first desc, last asc }, + references: [{ id: 'id-tag-1', name: 'tag-1', type: 'tag' }], }, { id: '456', @@ -336,6 +351,7 @@ describe('TableListView', () => { attributes: { title: 'a-foo', // first asc, last desc }, + references: [], }, ]; @@ -367,11 +383,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 +413,7 @@ describe('TableListView', () => { }); const { component, table, find } = testBed!; + const { openSortSelect } = getActions(testBed!); component.update(); let { tableCellsValues } = table.getMetaData('itemsInMemTable'); @@ -406,7 +424,7 @@ describe('TableListView', () => { ]); act(() => { - find('tableSortSelectBtn').simulate('click'); + openSortSelect(); }); component.update(); const filterOptions = find('sortSelect').find('li'); @@ -451,10 +469,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 +512,7 @@ describe('TableListView', () => { ]); act(() => { - find('tableSortSelectBtn').simulate('click'); + openSortSelect(); }); component.update(); filterOptions = find('sortSelect').find('li'); @@ -506,4 +525,92 @@ describe('TableListView', () => { ]); }); }); + + describe('tag filtering', () => { + const setupTagFiltering = registerTestBed( + WithServices(TableListView), + { + defaultProps: { ...requiredProps }, + memoryRouter: { wrapComponent: false }, + } + ); + + const hits = [ + { + id: '123', + updatedAt: new Date(new Date().setDate(new Date().getDate() - 1)), + 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)), + 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); + }); + }); }); 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 6fe2d34fe1ffc..348bde6fcf056 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -190,7 +190,7 @@ function TableListViewComp({ pagination, tableSort, } = state; - const hasNoItems = !isFetchingItems && items.length === 0 && !searchQuery; + const hasNoItems = !isFetchingItems && items.length === 0 && searchQuery.query === null; const pageDataTestSubject = `${entityName}LandingPage`; const showFetchError = Boolean(fetchError); const showLimitError = !showFetchError && totalItems > listingLimit; @@ -482,7 +482,7 @@ function TableListViewComp({ return null; } - if (!fetchError && hasNoItems) { + if (!showFetchError && hasNoItems) { return ( Date: Tue, 8 Nov 2022 11:52:11 +0000 Subject: [PATCH 32/46] Add jest test to filter by tag from the searchbar filter --- .../src/components/tag_filter_panel.tsx | 10 +-- .../table_list/src/table_list_view.test.tsx | 62 ++++++++++++++++++- 2 files changed, 66 insertions(+), 6 deletions(-) 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 index c4b52e40d2431..d2160a6f24391 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -161,16 +161,15 @@ export const TagFilterPanel: FC = ({ checked, view: ( - {(ref) => ( + {(ref, onClickWrapped) => ( - - {name} - + {name} @@ -226,7 +225,7 @@ export const TagFilterPanel: FC = ({ iconType="arrowDown" iconSide="right" onClick={togglePopOver} - data-test-subj="tableSortSelectBtn" + data-test-subj="tagFilterPopoverButton" hasActiveFilters={totalActiveFilters > 0} numActiveFilters={totalActiveFilters} grow @@ -251,6 +250,7 @@ export const TagFilterPanel: FC = ({ emptyMessage="There aren't any tags" noMatchesMessage="No tag matches the search" onChange={onSelectChange} + data-test-subj="tagSelectableList" {...searchProps} > {(list, search) => ( 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 56877459cf651..4fbcb7adf1c40 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 @@ -528,7 +528,14 @@ describe('TableListView', () => { describe('tag filtering', () => { const setupTagFiltering = registerTestBed( - WithServices(TableListView), + 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 }, @@ -612,5 +619,58 @@ describe('TableListView', () => { 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]; + + // Open the tag filter dropdown + await act(async () => { + find('tagFilterPopoverButton').simulate('click'); + }); + component.update(); + + 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); + + await act(async () => { + find('tagFilterPopoverButton').simulate('click'); + }); + component.update(); + + // 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); + }); }); }); From 4d621b99ff577b24d1a52625b88ed7b64bea466e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 8 Nov 2022 11:58:53 +0000 Subject: [PATCH 33/46] Revert changes to query_params.ts --- .../src/lib/search_dsl/query_params.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts index a12db18d272b4..896b934c90b80 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/query_params.ts @@ -141,6 +141,7 @@ interface QueryParams { hasNoReferenceOperator?: SearchOperator; kueryNode?: KueryNode; } + // A de-duplicated set of namespaces makes for a more efficient query. const uniqNamespaces = (namespacesToNormalize?: string[]) => namespacesToNormalize ? Array.from(new Set(namespacesToNormalize)) : undefined; From 3e00d82e34b621c7691250ffebf9e39b8af9c302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 8 Nov 2022 12:15:56 +0000 Subject: [PATCH 34/46] Fix TS, comments --- src/plugins/saved_objects_tagging_oss/public/api.mock.ts | 1 + src/plugins/saved_objects_tagging_oss/public/api.ts | 4 +++- .../saved_objects_tagging/public/ui_api/parse_search_query.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) 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 35afe6fc4bc9b..e96aa2277b0a5 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 f52481602492f..065661cebe7d6 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -226,7 +226,9 @@ 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; } 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 6db8a3e115a9a..8f22fcea3f782 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 @@ -59,7 +59,7 @@ export const buildParseSearchQuery = ({ if (parsed.ast.getFieldClauses(tagField)) { // The query can have clauses that either *must* match or *must_not* match - // We will retrive the list of name for both list and convert them to references + // 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') { From 0ce852ac3eef96bffd7c3d5621bb1a0303bd833d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 8 Nov 2022 12:19:56 +0000 Subject: [PATCH 35/46] Update visualize listing to exclude tags --- .../visualizations/public/utils/saved_visualize_utils.ts | 4 +++- .../public/visualize_app/components/visualize_listing.tsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts index 2238ff7cf054a..eada1c8beadd7 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 71a71d1cdde2d..bf7b25269c85b 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx @@ -164,7 +164,8 @@ export const VisualizeListing = () => { getTypes(), searchTerm, listingLimit, - references + references, + referencesToExclude ).then(({ total, hits }: { total: number; hits: Array> }) => ({ total, hits: hits From 96107d283f991fab17c0fc448d3d54db2ac927fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 8 Nov 2022 12:22:05 +0000 Subject: [PATCH 36/46] Update maps listing to exclude tags --- x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx | 1 + 1 file changed, 1 insertion(+) 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 10a95f20b449a..1506a2569c273 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 @@ -84,6 +84,7 @@ async function findMaps( defaultSearchOperator: 'AND', fields: ['description', 'title'], hasReference: references, + hasNoReference: referencesToExclude, }); return { From 718500dedeeccce307798b0009ee59480b7cf881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 8 Nov 2022 13:17:49 +0000 Subject: [PATCH 37/46] Update so tagging jest tests --- .../ui_api/get_search_bar_filter.test.ts | 18 ++---------- .../public/ui_api/get_tag_list.test.ts | 25 +++++++++++++++++ .../public/ui_api/get_tag_list.ts | 11 ++++++++ .../public/ui_api/index.ts | 4 +-- .../public/ui_api/parse_search_query.test.ts | 28 +++++++++++++++++++ 5 files changed, 69 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.test.ts create mode 100644 x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.ts 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 96a498580c2e4..e5216ea209177 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_tag_list.test.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/get_tag_list.test.ts new file mode 100644 index 0000000000000..3821d3a4bc313 --- /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 0000000000000..6fc6e7cd51df9 --- /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 8918a4f701bef..8d53135f3f55a 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 @@ -14,13 +14,13 @@ import { updateTagsReferences, convertTagNameToId, getTag, - byNameTagSorter, } from '../utils'; import { getComponents } from './components'; 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 { @@ -40,7 +40,7 @@ export const getUiApi = ({ }: GetUiApiOptions): SavedObjectsTaggingApiUi => { const components = getComponents({ cache, capabilities, overlays, theme, tagClient: client }); - const getTagList = () => cache.getState().sort(byNameTagSorter); + const getTagList = buildGetTagList(cache); return { components, 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 02de2673885e3..15e2349af47dc 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, }); }); From df910e40516b83cc5142399dd2c47a361488794f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 9 Nov 2022 11:52:11 +0000 Subject: [PATCH 38/46] Move Query initialization to TableListView --- .../table_list/src/table_list_view.tsx | 18 ++++-- .../table_list/src/use_tags.ts | 60 ++++++++----------- 2 files changed, 37 insertions(+), 41 deletions(-) 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 348bde6fcf056..c5d45a02d332d 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -19,6 +19,7 @@ import { EuiTableActionsColumnType, CriteriaWithPagination, Query, + Ast, } from '@elastic/eui'; import { keyBy, uniq, get } from 'lodash'; import { i18n } from '@kbn/i18n'; @@ -83,7 +84,7 @@ export interface State; searchQuery: { text: string; - query: Query | null; + query: Query; }; selectedIds: string[]; totalItems: number; @@ -106,6 +107,8 @@ export interface UserContentCommonSchema { }; } +const ast = Ast.create([]); + function TableListViewComp({ tableListTitle, entityName, @@ -161,9 +164,10 @@ function TableListViewComp({ showDeleteModal: false, hasUpdatedAtMetadata: false, selectedIds: [], - searchQuery: Boolean(initialQuery) - ? { text: initialQuery!, query: null } - : { text: '', query: null }, + searchQuery: + initialQuery !== undefined + ? { text: initialQuery, query: new Query(ast, undefined, initialQuery) } + : { text: '', query: new Query(ast, undefined, '') }, pagination: { pageIndex: 0, totalItemCount: 0, @@ -190,7 +194,9 @@ function TableListViewComp({ pagination, tableSort, } = state; - const hasNoItems = !isFetchingItems && items.length === 0 && searchQuery.query === null; + + const hasQuery = searchQuery.text !== ''; + const hasNoItems = !isFetchingItems && items.length === 0 && !hasQuery; const pageDataTestSubject = `${entityName}LandingPage`; const showFetchError = Boolean(fetchError); const showLimitError = !showFetchError && totalItems > listingLimit; @@ -208,7 +214,7 @@ function TableListViewComp({ clearTagSelection, tagsToTableItemMap, } = useTags({ - searchQuery, + query: searchQuery.query, updateQuery, items, }); diff --git a/packages/content-management/table_list/src/use_tags.ts b/packages/content-management/table_list/src/use_tags.ts index 98901638a59e4..20448151275c2 100644 --- a/packages/content-management/table_list/src/use_tags.ts +++ b/packages/content-management/table_list/src/use_tags.ts @@ -6,27 +6,22 @@ * Side Public License, v 1. */ import { useCallback, useMemo } from 'react'; -import { Query, Ast } from '@elastic/eui'; +import { Query } from '@elastic/eui'; import type { Tag } from './types'; -import type { State, UserContentCommonSchema } from './table_list_view'; +import type { UserContentCommonSchema } from './table_list_view'; type QueryUpdater = (query: Query, tag: Tag) => Query; export function useTags({ - searchQuery, + query, updateQuery, items, }: { - searchQuery: State['searchQuery']; + query: Query; updateQuery: (query: Query) => void; items: UserContentCommonSchema[]; }) { - const initializeQuery = useCallback(() => { - const ast = Ast.create([]); - return new Query(ast, undefined, searchQuery.text); - }, [searchQuery]); - // 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(() => { @@ -49,23 +44,22 @@ export function useTags({ const updateTagClauseGetter = useCallback( (queryUpdater: QueryUpdater) => (tag: Tag, q?: Query, doUpdate: boolean = true) => { - const query = q !== undefined ? q : searchQuery.query ?? initializeQuery(); - const updatedQuery = queryUpdater(query, tag); + const updatedQuery = queryUpdater(q !== undefined ? q : query, tag); if (doUpdate) { updateQuery(updatedQuery); } return updatedQuery; }, - [searchQuery.query, initializeQuery, updateQuery] + [query, updateQuery] ); const hasTagInClauseGetter = useCallback( (matchValue: 'must' | 'must_not') => (tag: Tag, _query?: Query) => { - const query = Boolean(_query) ? _query! : searchQuery.query ?? initializeQuery(); + const q = Boolean(_query) ? _query! : query; const tagsClauses = query.ast.getFieldClauses('tag'); if (tagsClauses) { - const mustHaveTagClauses = query.ast + const mustHaveTagClauses = q.ast .getFieldClauses('tag') .find(({ match }) => match === matchValue)?.value as string[]; @@ -75,27 +69,26 @@ export function useTags({ } return false; }, - [searchQuery.query, initializeQuery] + [query] ); const addTagToIncludeClause = useMemo( - () => updateTagClauseGetter((query, tag) => query.addOrFieldValue('tag', tag.name, true, 'eq')), + () => updateTagClauseGetter((q, tag) => q.addOrFieldValue('tag', tag.name, true, 'eq')), [updateTagClauseGetter] ); const removeTagFromIncludeClause = useMemo( - () => updateTagClauseGetter((query, tag) => query.removeOrFieldValue('tag', tag.name)), + () => updateTagClauseGetter((q, tag) => q.removeOrFieldValue('tag', tag.name)), [updateTagClauseGetter] ); const addTagToExcludeClause = useMemo( - () => - updateTagClauseGetter((query, tag) => query.addOrFieldValue('tag', tag.name, false, 'eq')), + () => updateTagClauseGetter((q, tag) => q.addOrFieldValue('tag', tag.name, false, 'eq')), [updateTagClauseGetter] ); const removeTagFromExcludeClause = useMemo( - () => updateTagClauseGetter((query, tag) => query.removeOrFieldValue('tag', tag.name)), + () => updateTagClauseGetter((q, tag) => q.removeOrFieldValue('tag', tag.name)), [updateTagClauseGetter] ); @@ -104,19 +97,19 @@ export function useTags({ const addOrRemoveIncludeTagFilter = useCallback( (tag: Tag) => { - let query: Query | undefined; + let q: Query | undefined; // Remove the tag in the "Exclude" list if it is there if (hasTagInExclude(tag)) { - query = removeTagFromExcludeClause(tag, undefined, false); + q = removeTagFromExcludeClause(tag, undefined, false); } - if (hasTagInInclude(tag, query)) { + if (hasTagInInclude(tag, q)) { // Already selected, remove the filter - removeTagFromIncludeClause(tag, query); + removeTagFromIncludeClause(tag, q); return; } - addTagToIncludeClause(tag, query); + addTagToIncludeClause(tag, q); }, [ hasTagInExclude, @@ -129,20 +122,20 @@ export function useTags({ const addOrRemoveExcludeTagFilter = useCallback( (tag: Tag) => { - let query: Query | undefined; + let q: Query | undefined; // Remove the tag in the "Include" list if it is there if (hasTagInInclude(tag)) { - query = removeTagFromIncludeClause(tag, undefined, false); + q = removeTagFromIncludeClause(tag, undefined, false); } - if (hasTagInExclude(tag, query)) { + if (hasTagInExclude(tag, q)) { // Already selected, remove the filter - removeTagFromExcludeClause(tag, query); + removeTagFromExcludeClause(tag, q); return; } - addTagToExcludeClause(tag, query); + addTagToExcludeClause(tag, q); }, [ hasTagInInclude, @@ -154,13 +147,10 @@ export function useTags({ ); const clearTagSelection = useCallback(() => { - if (!searchQuery.query) { - return; - } - const updatedQuery = searchQuery.query.removeOrFieldClauses('tag'); + const updatedQuery = query.removeOrFieldClauses('tag'); updateQuery(updatedQuery); return updateQuery; - }, [searchQuery.query, updateQuery]); + }, [query, updateQuery]); return { addOrRemoveIncludeTagFilter, From 31ce836d94e765d3b0acf960983bec91b4728992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 9 Nov 2022 12:13:08 +0000 Subject: [PATCH 39/46] Change ownership of tagging to the Global Exp team --- .github/CODEOWNERS | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2c7d27f19ce8f..db8caf22470ea 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 From 4af51ca37b5bc76459fc7a6823fb8a7169fa441f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 9 Nov 2022 12:29:26 +0000 Subject: [PATCH 40/46] Fix TS issue --- packages/content-management/table_list/src/actions.ts | 2 +- .../table_list/src/components/table.tsx | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/content-management/table_list/src/actions.ts b/packages/content-management/table_list/src/actions.ts index 3691748bbc3d3..ba706025b036a 100644 --- a/packages/content-management/table_list/src/actions.ts +++ b/packages/content-management/table_list/src/actions.ts @@ -72,7 +72,7 @@ export interface ShowConfirmDeleteItemsModalAction { export interface OnSearchQueryChangeAction { type: 'onSearchQueryChange'; data: { - query: Query | null; + query: Query; text: string; }; } diff --git a/packages/content-management/table_list/src/components/table.tsx b/packages/content-management/table_list/src/components/table.tsx index 09f14d6743d78..76cf10688d079 100644 --- a/packages/content-management/table_list/src/components/table.tsx +++ b/packages/content-management/table_list/src/components/table.tsx @@ -17,6 +17,7 @@ import { SearchFilterConfig, Direction, Query, + Ast, } from '@elastic/eui'; import { useServices } from '../services'; @@ -114,8 +115,13 @@ export function Table({ const onSearchQueryChange = useCallback( (arg: { query: Query | null; queryText: string }) => { - const { queryText, query } = arg; - dispatch({ type: 'onSearchQueryChange', data: { query, text: queryText } }); + dispatch({ + type: 'onSearchQueryChange', + data: { + query: arg.query ?? new Query(Ast.create([]), undefined, arg.queryText), + text: arg.queryText, + }, + }); }, [dispatch] ); From b930a61389609e9294ab23336fb775487286a864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 9 Nov 2022 14:16:18 +0000 Subject: [PATCH 41/46] Fix query instance target --- packages/content-management/table_list/src/use_tags.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/content-management/table_list/src/use_tags.ts b/packages/content-management/table_list/src/use_tags.ts index 20448151275c2..c72f550bc54b3 100644 --- a/packages/content-management/table_list/src/use_tags.ts +++ b/packages/content-management/table_list/src/use_tags.ts @@ -56,7 +56,7 @@ export function useTags({ const hasTagInClauseGetter = useCallback( (matchValue: 'must' | 'must_not') => (tag: Tag, _query?: Query) => { const q = Boolean(_query) ? _query! : query; - const tagsClauses = query.ast.getFieldClauses('tag'); + const tagsClauses = q.ast.getFieldClauses('tag'); if (tagsClauses) { const mustHaveTagClauses = q.ast @@ -102,9 +102,7 @@ export function useTags({ // Remove the tag in the "Exclude" list if it is there if (hasTagInExclude(tag)) { q = removeTagFromExcludeClause(tag, undefined, false); - } - - if (hasTagInInclude(tag, q)) { + } else if (hasTagInInclude(tag, q)) { // Already selected, remove the filter removeTagFromIncludeClause(tag, q); return; From e6165c9fa8eb08aa9a6d04fd775402737b1b6392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 10 Nov 2022 11:10:38 +0000 Subject: [PATCH 42/46] Use command (MacOS) and ctrl (Windows) as modified key to exclude tags --- .../src/components/ctrl_click_detect.tsx | 61 --------- .../table_list/src/components/tag_badge.tsx | 54 +++----- .../src/components/tag_filter_panel.tsx | 119 ++++++++++-------- .../table_list/src/table_list_view.tsx | 4 +- 4 files changed, 83 insertions(+), 155 deletions(-) delete mode 100644 packages/content-management/table_list/src/components/ctrl_click_detect.tsx diff --git a/packages/content-management/table_list/src/components/ctrl_click_detect.tsx b/packages/content-management/table_list/src/components/ctrl_click_detect.tsx deleted file mode 100644 index 91606867d7161..0000000000000 --- a/packages/content-management/table_list/src/components/ctrl_click_detect.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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, useRef, useCallback } from 'react'; -import type { FC, ReactNode, MutableRefObject, MouseEvent as ReactMouseEvent } from 'react'; - -type ClickHandlerWithMeta = (e: ReactMouseEvent, meta: { isCtrlKey: boolean }) => void; - -interface Props { - onClick: ClickHandlerWithMeta; - children: ( - ref: MutableRefObject, - onClick: (e: ReactMouseEvent) => void - ) => ReactNode; -} - -export const CtrlClickDetect: FC = ({ onClick, children }) => { - const elRef = useRef(null); - const isMounted = useRef(false); - - const onElClick = useCallback( - (e: ReactMouseEvent) => { - e.preventDefault(); - onClick(e, { isCtrlKey: e.ctrlKey }); - }, - [onClick] - ); - - const onElContextmenu = useCallback( - (e: MouseEvent) => { - // Disable context menu as on Mac "ctrl + click" equals "right clicking" - // which opens the context menu - e.preventDefault(); - onClick(e as unknown as ReactMouseEvent, { isCtrlKey: true }); - }, - [onClick] - ); - - useEffect(() => { - const el = elRef.current; - - if (el && !isMounted.current && onClick) { - el.addEventListener('contextmenu', onElContextmenu, false); - } - - isMounted.current = true; - - return () => { - if (el) { - el.removeEventListener('contextmenu', onElContextmenu); - } - isMounted.current = false; - }; - }, [onClick, onElContextmenu]); - - return <>{children(elRef, onElClick)}; -}; diff --git a/packages/content-management/table_list/src/components/tag_badge.tsx b/packages/content-management/table_list/src/components/tag_badge.tsx index c28ea91e4a376..2bd594f4079ce 100644 --- a/packages/content-management/table_list/src/components/tag_badge.tsx +++ b/packages/content-management/table_list/src/components/tag_badge.tsx @@ -9,56 +9,36 @@ import React, { FC } from 'react'; import { EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { css } from '@emotion/react'; import type { Tag } from '../types'; -import { CtrlClickDetect } from './ctrl_click_detect'; + +const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; export interface Props { tag: Tag; - onClick: (tag: Tag, isCtrlKey: boolean) => void; + 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 }) => { - const buttonCSS = css` - cursor: pointer; - &:hover { - text-decoration: underline; - } - `; - - const badgeCSS = css` - cursor: pointer; - text-decoration: inherit; - `; - return ( - { - onClick(tag, isCtrlKey); + { + 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, + }, + })} > - {(ref, onClickWrapped) => ( - - )} - + {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 index d2160a6f24391..0f0bc0659cc80 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -32,7 +32,9 @@ import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; import { useServices } from '../services'; import type { Tag } from '../types'; -import { CtrlClickDetect } from './ctrl_click_detect'; + +const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; +const modifierKeyPrefix = isMac ? '⌘' : '^'; const toArray = (item: unknown) => (Array.isArray(item) ? item : [item]); @@ -84,7 +86,10 @@ export const TagFilterPanel: FC = ({ background-color: ${euiTheme.colors.lightestShade}; border-top: ${euiTheme.border.thin}; padding: ${euiTheme.size.s}; - text-align: center; + & a { + text-align: center; + width: 100%; + } `; let searchProps: ExclusiveUnion< @@ -125,20 +130,17 @@ export const TagFilterPanel: FC = ({ ); const onOptionClick = useCallback( - (tag: Tag) => - (e: MouseEvent, { isCtrlKey }: { isCtrlKey: boolean }) => { - e.preventDefault(); - - if (isCtrlKey) { - addOrRemoveExcludeTagFilter(tag); - } else { - addOrRemoveIncludeTagFilter(tag); - } + (tag: Tag) => (e: MouseEvent) => { + const withModifierKey = (isMac && e.metaKey) || (!isMac && e.ctrlKey); - setIsPopoverOpen(false); + if (withModifierKey) { + addOrRemoveExcludeTagFilter(tag); + } else { + addOrRemoveIncludeTagFilter(tag); + } - e.stopPropagation(); - }, + setIsPopoverOpen(false); + }, [addOrRemoveIncludeTagFilter, addOrRemoveExcludeTagFilter] ); @@ -160,26 +162,22 @@ export const TagFilterPanel: FC = ({ tag, checked, view: ( - - {(ref, onClickWrapped) => ( - - - - {name} - - - - - {tagsToTableItemMap[id ?? '']?.length ?? 0} - - - - )} - + + + + {name} + + + + + {tagsToTableItemMap[id ?? '']?.length ?? 0} + + + ), }; }); @@ -274,37 +272,48 @@ export const TagFilterPanel: FC = ({ color="danger" onClick={clearTagSelection} > - Clear selection + {i18n.translate( + 'contentManagement.tableList.tagFilterPanel.clearSelectionButtonLabelLabel', + { + defaultMessage: 'Clear selection', + } + )} )} - Ctrl + click to filter out tags + + {i18n.translate('contentManagement.tableList.tagFilterPanel.modifierKeyHelpText', { + defaultMessage: '{modifierKeyPrefix} + click to filter out tags', + values: { + modifierKeyPrefix, + }, + })} + {/* Link to manage all tags */} - - - - {i18n.translate( - 'contentManagement.tableList.tagFilterPanel.manageAllTagsLinkLabel', - { - defaultMessage: 'Manage all tags', - } - )} - - - + + + {i18n.translate( + 'contentManagement.tableList.tagFilterPanel.manageAllTagsLinkLabel', + { + defaultMessage: 'Manage all tags', + } + )} + + +
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 631a9a1fee218..a42bc4cb9a1c7 100644 --- a/packages/content-management/table_list/src/table_list_view.tsx +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -271,8 +271,8 @@ function TableListViewComp({ item={record} getDetailViewLink={getDetailViewLink} onClickTitle={onClickTitle} - onClickTag={(tag, isCtrlKey) => { - if (isCtrlKey) { + onClickTag={(tag, withModifierKey) => { + if (withModifierKey) { addOrRemoveExcludeTagFilter(tag); } else { addOrRemoveIncludeTagFilter(tag); From b83409141a1b6a73dd7f677446aaf7cb61dab207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 10 Nov 2022 17:36:19 +0000 Subject: [PATCH 43/46] Moving tag filter panel state to hook --- .../table_list/src/components/table.tsx | 68 ++-- .../src/components/tag_filter_panel.tsx | 335 ++++++------------ .../src/components/use_tag_filter_panel.tsx | 190 ++++++++++ 3 files changed, 342 insertions(+), 251 deletions(-) create mode 100644 packages/content-management/table_list/src/components/use_tag_filter_panel.tsx diff --git a/packages/content-management/table_list/src/components/table.tsx b/packages/content-management/table_list/src/components/table.tsx index 76cf10688d079..14fd020ea7436 100644 --- a/packages/content-management/table_list/src/components/table.tsx +++ b/packages/content-management/table_list/src/components/table.tsx @@ -29,7 +29,8 @@ import type { } from '../table_list_view'; import { TableSortSelect } from './table_sort_select'; import { TagFilterPanel } from './tag_filter_panel'; -import type { Props as TagFilterPanelProps } 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< @@ -38,11 +39,8 @@ type State = Pick< >; type TagManagementProps = Pick< - TagFilterPanelProps, - | 'clearTagSelection' - | 'addOrRemoveIncludeTagFilter' - | 'addOrRemoveExcludeTagFilter' - | 'tagsToTableItemMap' + UseTagFilterPanelParams, + 'addOrRemoveIncludeTagFilter' | 'addOrRemoveExcludeTagFilter' | 'tagsToTableItemMap' >; interface Props extends State, TagManagementProps { @@ -56,6 +54,7 @@ interface Props extends State, TagManageme deleteItems: TableListViewProps['deleteItems']; onSortChange: (column: SortColumnField, direction: Direction) => void; onTableChange: (criteria: CriteriaWithPagination) => void; + clearTagSelection: () => void; } export function Table({ @@ -113,6 +112,22 @@ export function Table({ } : undefined; + const { + isPopoverOpen, + isTransitionOn, + closePopover, + onFilterButtonClick, + onSelectChange, + options, + totalActiveFilters, + } = useTagFilterPanel({ + query: searchQuery.query, + getTagList, + tagsToTableItemMap, + addOrRemoveExcludeTagFilter, + addOrRemoveIncludeTagFilter, + }); + const onSearchQueryChange = useCallback( (arg: { query: Query | null; queryText: string }) => { dispatch({ @@ -126,8 +141,8 @@ export function Table({ [dispatch] ); - const searchFilters = useMemo(() => { - const tableSortSelectFilter: SearchFilterConfig = { + const tableSortSelectFilter = useMemo(() => { + return { type: 'custom_component', component: () => { return ( @@ -139,36 +154,41 @@ export function Table({ ); }, }; + }, [hasUpdatedAtMetadata, onSortChange, tableSort]); - const tagFilterPanel: SearchFilterConfig = { + const tagFilterPanel = useMemo(() => { + return { type: 'custom_component', component: () => { return ( ); }, }; - - return [tableSortSelectFilter, tagFilterPanel]; }, [ - onSortChange, - hasUpdatedAtMetadata, - tableSort, - getTagList, - searchQuery.query, - tagsToTableItemMap, - addOrRemoveIncludeTagFilter, - addOrRemoveExcludeTagFilter, + isPopoverOpen, + isTransitionOn, + closePopover, + options, + totalActiveFilters, + onFilterButtonClick, + onSelectChange, clearTagSelection, ]); + const searchFilters = useMemo(() => { + return [tableSortSelectFilter, tagFilterPanel]; + }, [tableSortSelectFilter, tagFilterPanel]); + const search = useMemo(() => { return { onChange: onSearchQueryChange, 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 index 0f0bc0659cc80..57978192fd18c 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -6,10 +6,9 @@ * Side Public License, v 1. */ -import React, { useState, useEffect, useCallback } from 'react'; -import type { FC, MouseEvent } from 'react'; +import React from 'react'; +import type { FC } from 'react'; import { - Query, EuiPopover, EuiPopoverTitle, EuiSelectable, @@ -19,77 +18,63 @@ import { EuiText, EuiButtonEmpty, EuiTextColor, - EuiHealth, EuiSpacer, EuiLink, useEuiTheme, - EuiBadge, + EuiPopoverFooter, + EuiButton, } from '@elastic/eui'; -import type { EuiSelectableProps, ExclusiveUnion, FieldValueOptionType } 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 { Tag } from '../types'; +import type { TagOptionItem } from './use_tag_filter_panel'; const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; const modifierKeyPrefix = isMac ? '⌘' : '^'; -const toArray = (item: unknown) => (Array.isArray(item) ? item : [item]); +const clearSelectionBtnCSS = css` + height: auto; +`; -const testSubjFriendly = (name: string) => { - return name.replace(' ', '_'); -}; - -interface TagOptionItem extends FieldValueOptionType { - label: string; - checked?: 'on' | 'off'; - tag: Tag; -} +const saveBtnWrapperCSS = css` + width: 100%; +`; -interface TagSelection { - [tagId: string]: 'include' | 'exclude' | undefined; -} - -export interface Props { - query: Query | null; - tagsToTableItemMap: { [tagId: string]: string[] }; - getTagList: () => Tag[]; +interface Props { clearTagSelection: () => void; - addOrRemoveIncludeTagFilter: (tag: Tag) => void; - addOrRemoveExcludeTagFilter: (tag: Tag) => void; + closePopover: () => void; + isPopoverOpen: boolean; + isTransitionOn: boolean; + options: TagOptionItem[]; + totalActiveFilters: number; + onFilterButtonClick: () => void; + onSelectChange: (updatedOptions: TagOptionItem[]) => void; } export const TagFilterPanel: FC = ({ - query, - getTagList, - tagsToTableItemMap, + isPopoverOpen, + isTransitionOn, + options, + totalActiveFilters, + onFilterButtonClick, + onSelectChange, + closePopover, clearTagSelection, - addOrRemoveIncludeTagFilter, - addOrRemoveExcludeTagFilter, }) => { const { euiTheme } = useEuiTheme(); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [options, setOptions] = useState([]); - const [tagSelection, setTagSelection] = useState({}); const { navigateToUrl, currentAppId$, getTagManagementUrl } = useServices(); const isSearchVisible = options.length > 10; - const totalActiveFilters = Object.keys(tagSelection).length; - const footerCSS = css` - border-top: ${euiTheme.border.thin}; - text-align: center; + const searchBoxCSS = css` + padding: ${euiTheme.size.s}; + border-bottom: ${euiTheme.border.thin}; `; - const bottomBarCSS = css` - background-color: ${euiTheme.colors.lightestShade}; - border-top: ${euiTheme.border.thin}; - padding: ${euiTheme.size.s}; - & a { - text-align: center; - width: 100%; - } + const popoverTitleCSS = css` + height: ${euiTheme.size.xxxl}; `; let searchProps: ExclusiveUnion< @@ -111,110 +96,6 @@ export const TagFilterPanel: FC = ({ }; } - const togglePopOver = useCallback(() => { - setIsPopoverOpen((prev) => !prev); - }, []); - - const closePopover = useCallback(() => { - setIsPopoverOpen(false); - }, []); - - const onSelectChange = useCallback( - (updatedOptions: TagOptionItem[]) => { - 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); - } - - setIsPopoverOpen(false); - }, - [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]); - - useEffect(() => { - 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(); - } - }, [isPopoverOpen, updateTagList]); - return ( <> = ({ 0} numActiveFilters={totalActiveFilters} @@ -236,86 +117,86 @@ export const TagFilterPanel: FC = ({ panelPaddingSize="none" anchorPosition="downCenter" panelClassName="euiFilterGroup__popoverPanel" + panelStyle={isTransitionOn ? undefined : { transition: 'none' }} > - - {/* Selectable */} - - - 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) => ( - <> - {isSearchVisible ? ( - {search} - ) : ( - + + + Tags + + {totalActiveFilters > 0 && ( + + {i18n.translate( + 'contentManagement.tableList.tagFilterPanel.clearSelectionButtonLabelLabel', + { + defaultMessage: 'Clear selection', + } )} - {list} - + )} - - + + + + + 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) => ( + <> + {isSearchVisible ?
{search}
: } + {list} + + )} + + + + + + + {i18n.translate( + 'contentManagement.tableList.tagFilterPanel.modifierKeyHelpText', + { + defaultMessage: '{modifierKeyPrefix} + click exclude', + values: { + modifierKeyPrefix, + }, + } + )} + + + - {/* Clear selection + help text */} - - {totalActiveFilters > 0 && ( - - {i18n.translate( - 'contentManagement.tableList.tagFilterPanel.clearSelectionButtonLabelLabel', - { - defaultMessage: 'Clear selection', - } - )} - - )} - - - - {i18n.translate('contentManagement.tableList.tagFilterPanel.modifierKeyHelpText', { - defaultMessage: '{modifierKeyPrefix} + click to filter out tags', - values: { - modifierKeyPrefix, - }, - })} - - - - + + Save + - {/* Link to manage all tags */} - - - - {i18n.translate( - 'contentManagement.tableList.tagFilterPanel.manageAllTagsLinkLabel', - { - defaultMessage: 'Manage all tags', - } - )} - - - - - + + + + {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 0000000000000..42d266ebc7fc3 --- /dev/null +++ b/packages/content-management/table_list/src/components/use_tag_filter_panel.tsx @@ -0,0 +1,190 @@ +/* + * 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); + // Every time one of the prop of the changes it creates a new mount + // of that component as it is embedded as a "custom_component" SearchFilterConfig. + // This means that, as we keep the popover panel open for the tag selection we get an anoying + // "transition IN" effect from EUI. To avoid it we disable the transition after the popover is + // open and we reenable it when it is closed. + const [isTransitionOn, setIsTransitionOn] = useState(true); + 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 "isTransitionOn" flag. + setTimeout(() => { + setIsTransitionOn(false); + }, 250); + } else { + setIsTransitionOn(true); + } + }, [isPopoverOpen, updateTagList]); + + return { + isPopoverOpen, + isTransitionOn, + options, + totalActiveFilters, + onFilterButtonClick, + onSelectChange, + closePopover, + }; +}; From 2abc8af3c66f2a27442186079b2d366331f8cc76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 14 Nov 2022 10:39:00 +0000 Subject: [PATCH 44/46] Fix jest tests --- .../table_list/src/components/tag_badge.tsx | 1 + .../table_list/src/table_list_view.test.tsx | 67 ++++++++++++------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/packages/content-management/table_list/src/components/tag_badge.tsx b/packages/content-management/table_list/src/components/tag_badge.tsx index 2bd594f4079ce..bfbd758884940 100644 --- a/packages/content-management/table_list/src/components/tag_badge.tsx +++ b/packages/content-management/table_list/src/components/tag_badge.tsx @@ -27,6 +27,7 @@ export const TagBadge: FC = ({ tag, onClick }) => { { const withModifierKey = (isMac && e.metaKey) || (!isMac && e.ctrlKey); onClick(tag, withModifierKey); 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 4bf33dac4d912..92e1ddaa45cc0 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,10 +119,11 @@ 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', @@ -128,7 +133,8 @@ describe('TableListView', () => { { 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', @@ -255,7 +261,10 @@ 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 }, @@ -336,10 +345,11 @@ 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, // first asc, last desc + updatedAt: twoDaysAgo.toISOString(), // first asc, last desc + type: 'dashboard', attributes: { title: 'z-foo', // first desc, last asc }, @@ -347,7 +357,8 @@ describe('TableListView', () => { }, { 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 }, @@ -535,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', }, ]; @@ -589,10 +604,11 @@ 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(), + type: 'dashboard', attributes: { title: 'Item 1', description: 'Item 1 description', @@ -604,7 +620,8 @@ describe('TableListView', () => { }, { id: '456', - updatedAt: new Date(new Date().setDate(new Date().getDate() - 2)), + updatedAt: new Date(new Date().setDate(new Date().getDate() - 2)).toISOString(), + type: 'dashboard', attributes: { title: 'Item 2', description: 'Item 2 description', @@ -615,6 +632,7 @@ describe('TableListView', () => { test('should filter by tag from the table', async () => { let testBed: TestBed; + const findItems = jest.fn().mockResolvedValue({ total: hits.length, hits }); await act(async () => { @@ -627,6 +645,7 @@ describe('TableListView', () => { component.update(); const getSearchBoxValue = () => find('tableListSearchBox').props().defaultValue; + const getLastCallArgsFromFindItems = () => findItems.mock.calls[findItems.mock.calls.length - 1]; @@ -681,17 +700,20 @@ describe('TableListView', () => { component.update(); const getSearchBoxValue = () => find('tableListSearchBox').props().defaultValue; + const getLastCallArgsFromFindItems = () => findItems.mock.calls[findItems.mock.calls.length - 1]; - // Open the tag filter dropdown - await act(async () => { - find('tagFilterPopoverButton').simulate('click'); - }); - component.update(); + const openTagFilterDropdown = async () => { + await act(async () => { + find('tagFilterPopoverButton').simulate('click'); + }); + component.update(); + }; - expect(exists('tagSelectableList')).toBe(true); + await openTagFilterDropdown(); + expect(exists('tagSelectableList')).toBe(true); await act(async () => { find('tag-searchbar-option-tag-1').simulate('click'); }); @@ -703,11 +725,6 @@ describe('TableListView', () => { expect(getSearchBoxValue()).toBe(expected); expect(searchTerm).toBe(expected); - await act(async () => { - find('tagFilterPopoverButton').simulate('click'); - }); - component.update(); - // Ctrl + click one item await act(async () => { find('tag-searchbar-option-tag-2').simulate('click', { ctrlKey: true }); From 8b376efcdd9727a496dec13c29d57bea1ebc480c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 14 Nov 2022 10:44:46 +0000 Subject: [PATCH 45/46] Fix graphs to always return an array of refs --- x-pack/plugins/graph/public/apps/listing_route.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/graph/public/apps/listing_route.tsx b/x-pack/plugins/graph/public/apps/listing_route.tsx index af869f7afaa21..15f4898b84364 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, From 3edd0bb31e88c7030f7f722fd9659ba7c0f75876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 14 Nov 2022 13:31:20 +0000 Subject: [PATCH 46/46] Rename isInTransition by isInUse --- .../table_list/src/components/table.tsx | 6 +++--- .../src/components/tag_filter_panel.tsx | 20 ++++++++++--------- .../src/components/use_tag_filter_panel.tsx | 19 +++++++++--------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/content-management/table_list/src/components/table.tsx b/packages/content-management/table_list/src/components/table.tsx index 14fd020ea7436..1e4ee84204dd4 100644 --- a/packages/content-management/table_list/src/components/table.tsx +++ b/packages/content-management/table_list/src/components/table.tsx @@ -114,7 +114,7 @@ export function Table({ const { isPopoverOpen, - isTransitionOn, + isInUse, closePopover, onFilterButtonClick, onSelectChange, @@ -163,7 +163,7 @@ export function Table({ return ( ({ }; }, [ isPopoverOpen, - isTransitionOn, + isInUse, closePopover, options, totalActiveFilters, 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 index 57978192fd18c..03439f9dec161 100644 --- a/packages/content-management/table_list/src/components/tag_filter_panel.tsx +++ b/packages/content-management/table_list/src/components/tag_filter_panel.tsx @@ -47,7 +47,7 @@ interface Props { clearTagSelection: () => void; closePopover: () => void; isPopoverOpen: boolean; - isTransitionOn: boolean; + isInUse: boolean; options: TagOptionItem[]; totalActiveFilters: number; onFilterButtonClick: () => void; @@ -56,7 +56,7 @@ interface Props { export const TagFilterPanel: FC = ({ isPopoverOpen, - isTransitionOn, + isInUse, options, totalActiveFilters, onFilterButtonClick, @@ -117,7 +117,7 @@ export const TagFilterPanel: FC = ({ panelPaddingSize="none" anchorPosition="downCenter" panelClassName="euiFilterGroup__popoverPanel" - panelStyle={isTransitionOn ? undefined : { transition: 'none' }} + panelStyle={isInUse ? { transition: 'none' } : undefined} > @@ -147,12 +147,14 @@ export const TagFilterPanel: FC = ({ data-test-subj="tagSelectableList" {...searchProps} > - {(list, search) => ( - <> - {isSearchVisible ?
{search}
: } - {list} - - )} + {(list, search) => { + return ( + <> + {isSearchVisible ?
{search}
: } + {list} + + ); + }} 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 index 42d266ebc7fc3..ca7aab6f8bb08 100644 --- 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 @@ -46,12 +46,11 @@ export const useTagFilterPanel = ({ addOrRemoveIncludeTagFilter, }: Params) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - // Every time one of the prop of the changes it creates a new mount - // of that component as it is embedded as a "custom_component" SearchFilterConfig. - // This means that, as we keep the popover panel open for the tag selection we get an anoying - // "transition IN" effect from EUI. To avoid it we disable the transition after the popover is - // open and we reenable it when it is closed. - const [isTransitionOn, setIsTransitionOn] = useState(true); + // 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; @@ -169,18 +168,18 @@ export const useTagFilterPanel = ({ updateTagList(); // To avoid "cutting" the inflight css transition when opening the popover - // we add a slight delay to switch the "isTransitionOn" flag. + // we add a slight delay to switch the "isInUse" flag. setTimeout(() => { - setIsTransitionOn(false); + setIsInUse(true); }, 250); } else { - setIsTransitionOn(true); + setIsInUse(false); } }, [isPopoverOpen, updateTagList]); return { isPopoverOpen, - isTransitionOn, + isInUse, options, totalActiveFilters, onFilterButtonClick,