From 612061db1013d58ab49579a63140a7197d408138 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Thu, 1 Sep 2022 13:14:13 -0300 Subject: [PATCH] [Discover] Enable tags for saved searches (#136162) * [Discover] Add initial support for tags to saved search modal * [Discover] Add tags to savedSearch types * [Discover] Finish initial support for adding tags to saved searches * [Discover] Start to convert saved object finder to a table in order to support tags * [Discover] Add support for displaying saved search tags in open search flyout * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * [Discover] Continue support for tags in saved object finder * [Discover] Clean up saved object finder * [Discover] Finish initial support for tags in saved object finder * [Discover] Update SimpleSavedObject constructor to SimpleSavedObjectImpl * [Discover] Remove orig files * [Discover] Saved search tag type registration and telemetry * [Discover] Create new saved_objects_finder plugin * [Discover] Continue work creating saved_objects_finder plugin * [Discover] Revert some changes in saved_objects * [Discover] Revert some changes in saved_objects again * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * [Discover] Update saved_objects_finder i18n keys * [Discover] Update docs * [Discover] Add plugins to saved_object_finder and fix broken types * [Discover] Finish creating saved_objects_finder plugin and use it in Discover * [Discover] Update SavedObjectFinderProps type export, and update x-pack telemetry * [Discover] Fix broken jest tests * [Discover] Update saved_objects_finder API * [Discover] Remove unused translations * [Discover] Fix issue with initial saved object finder fetch * [Discover] Fix some of the saved object finder jest tests * [Discover] Clean up finder after merge * [Discover] Fixing saved_object_finder.tsx * [Discover] Add savedObjectsTaggingOss reference to saved_search plugin * [Discover] Fix broken open_search_panel test * [Discover] Removed allowed types from saved object finder * [Discover] Removing type column when there's only one saved object type, and adjusting column widths * [Discover] Fix issue where visible types were entirely removed, fixed pageSizeOptions * [Discover] Add showFilter to open_search_panel's saved_objects_finder, fallback to all available types when no type filter is applied to saved_objects_finder, hide type column and filter button when there is only one type in metadata list * [Discover] Fix remaining saved_object_finder Jest tests * [Discover] Update snapshot * [Discover] Fix failing functional tests * [Discover] Add tagging Jest tests for saved_objects_finder * [Discover] Fix small bugs in saved_object_finder, update Jest tests, add functional tests for Discover saved search tagging * [Discover] Removed unused variable in functional test * [Discover] Update Discover Jest tests with tagging tests * [Discover] Remove translations * [Discover] Updating saved_objects_finder to use static export vs preconfigured component, adding lazy load support * [Discover] Move saved_object_finder service deps to a 'services' prop, fix broken open_search_panel Jest test * [Discover] Fix broken Jest test * [Discover] Fix broken Jest test from merge * [Discover] Fix discover tags integration test description * - Updated tags prop to be `string | undefined` - Type imports cleanup - Added loading indicator for saved object finder - Changed `savedObjectsPlugin.settings.getListingLimit()` to `uiSettings.get(LISTING_LIMIT_SETTING)` - Removed old saved object finder comment - Removed tag changes from transform plugin - Change code owners of saved_objects_finder to Data Discovery * [Discover] Fixed LISTING_LIMIT_SETTING import * [Discover] Revert tags saving change that introduced a bug * [CI] Auto-commit changed files from 'node scripts/build_plugin_list_docs' * [Discover] Try again to fix LISTING_LIMIT_SETTINGS import * [Discover] Fix failing snapshot Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 1 + .i18nrc.json | 1 + docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 1 + src/plugins/discover/kibana.json | 11 +- .../discover/public/__mocks__/services.ts | 1 + .../open_search_panel.test.tsx.snap | 13 +- .../top_nav/on_save_search.test.tsx | 167 ++- .../components/top_nav/on_save_search.tsx | 53 +- .../top_nav/open_search_panel.test.tsx | 6 +- .../components/top_nav/open_search_panel.tsx | 19 +- .../application/main/discover_main_route.tsx | 2 + .../main/hooks/use_discover_state.ts | 1 + .../main/utils/persist_saved_search.ts | 7 +- src/plugins/discover/public/build_services.ts | 6 + .../embeddable/search_embeddable_factory.ts | 1 + src/plugins/discover/public/plugin.tsx | 6 +- src/plugins/discover/tsconfig.json | 4 +- src/plugins/saved_objects_finder/README.md | 3 + .../saved_objects_finder/jest.config.js | 18 + src/plugins/saved_objects_finder/kibana.json | 10 + .../public/finder/index.tsx | 27 + .../finder/saved_object_finder.test.tsx | 1196 +++++++++++++++++ .../public/finder/saved_object_finder.tsx | 386 ++++++ .../saved_objects_finder/public/index.ts | 13 + .../saved_objects_finder/public/plugin.ts | 19 + .../saved_objects_finder/tsconfig.json | 14 + .../saved_objects_management/public/mocks.ts | 2 + .../saved_objects_management/public/plugin.ts | 13 +- .../saved_searches/get_saved_searches.test.ts | 66 +- .../saved_searches/get_saved_searches.ts | 13 +- .../save_saved_searches.test.ts | 46 +- .../saved_searches/save_saved_searches.ts | 11 +- .../saved_searches_utils.test.ts | 15 +- .../saved_searches/saved_searches_utils.ts | 2 + .../public/services/saved_searches/types.ts | 1 + src/plugins/saved_search/tsconfig.json | 3 +- src/plugins/visualizations/public/plugin.ts | 5 + src/plugins/visualizations/public/services.ts | 4 + src/plugins/visualizations/public/vis.ts | 2 + .../utils/get_visualization_instance.ts | 4 +- test/functional/page_objects/discover_page.ts | 14 +- tsconfig.base.json | 2 + .../epm/kibana/assets/tag_assets.test.ts | 5 +- .../saved_objects_tagging/common/constants.ts | 2 +- .../server/usage/schema.ts | 1 + .../schema/xpack_plugins.json | 10 + .../translations/translations/fr-FR.json | 6 - .../translations/translations/ja-JP.json | 6 - .../translations/translations/zh-CN.json | 6 - .../apis/package_policy/create.ts | 4 +- .../apis/_get_assignable_types.ts | 2 +- .../fixtures/es_archiver/discover/data.json | 182 +++ .../functional/tests/discover_integration.ts | 168 +++ .../functional/tests/index.ts | 1 + 55 files changed, 2504 insertions(+), 82 deletions(-) create mode 100644 src/plugins/saved_objects_finder/README.md create mode 100644 src/plugins/saved_objects_finder/jest.config.js create mode 100644 src/plugins/saved_objects_finder/kibana.json create mode 100644 src/plugins/saved_objects_finder/public/finder/index.tsx create mode 100644 src/plugins/saved_objects_finder/public/finder/saved_object_finder.test.tsx create mode 100644 src/plugins/saved_objects_finder/public/finder/saved_object_finder.tsx create mode 100644 src/plugins/saved_objects_finder/public/index.ts create mode 100644 src/plugins/saved_objects_finder/public/plugin.ts create mode 100644 src/plugins/saved_objects_finder/tsconfig.json create mode 100644 x-pack/test/saved_object_tagging/common/fixtures/es_archiver/discover/data.json create mode 100644 x-pack/test/saved_object_tagging/functional/tests/discover_integration.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5c69f33b0f6c0..ea68df6a8bd8c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -17,6 +17,7 @@ /x-pack/plugins/graph/ @elastic/kibana-data-discovery /x-pack/test/functional/apps/graph @elastic/kibana-data-discovery /src/plugins/unified_field_list/ @elastic/kibana-data-discovery +/src/plugins/saved_objects_finder/ @elastic/kibana-data-discovery # Vis Editors /x-pack/plugins/lens/ @elastic/kibana-vis-editors diff --git a/.i18nrc.json b/.i18nrc.json index 2a301de5e7edf..3d223732dddaf 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -64,6 +64,7 @@ "newsfeed": "src/plugins/newsfeed", "presentationUtil": "src/plugins/presentation_util", "savedObjects": "src/plugins/saved_objects", + "savedObjectsFinder": "src/plugins/saved_objects_finder", "savedObjectsManagement": "src/plugins/saved_objects_management", "server": "src/legacy/server", "share": "src/plugins/share", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 2de6729e3ea4f..46159c7daa90b 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -237,6 +237,10 @@ Content is fetched from the remote (https://feeds.elastic.co) once a day, with p |NOTE: This plugin is deprecated and will be removed in 8.0. See https://github.com/elastic/kibana/issues/46435 for more information. +|{kib-repo}blob/{branch}/src/plugins/saved_objects_finder/README.md[savedObjectsFinder] +|The savedObjectsFinder plugin exposes a UI for finding saved objects on the client side. + + |{kib-repo}blob/{branch}/src/plugins/saved_objects_management/README.md[savedObjectsManagement] |The savedObjectsManagement plugin manages the Saved Objects management section. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 7883acc833ad3..71abf16bb8fcd 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -135,3 +135,4 @@ pageLoadAssetSize: kubernetesSecurity: 77234 threatIntelligence: 29195 files: 22673 + savedObjectsFinder: 21691 diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json index 78e5265bcb3ac..b691962bb582e 100644 --- a/src/plugins/discover/kibana.json +++ b/src/plugins/discover/kibana.json @@ -13,11 +13,20 @@ "navigation", "uiActions", "savedObjects", + "savedObjectsFinder", + "savedObjectsManagement", "dataViewFieldEditor", "dataViewEditor", "expressions" ], - "optionalPlugins": ["home", "share", "usageCollection", "spaces", "triggersActionsUi"], + "optionalPlugins": [ + "home", + "share", + "usageCollection", + "spaces", + "triggersActionsUi", + "savedObjectsTaggingOss" + ], "requiredBundles": ["kibanaUtils", "kibanaReact", "dataViews", "unifiedSearch", "savedSearch"], "extraPublicDirs": ["common"], "owner": { diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index ae361d961a6e8..a6c95405ccd5d 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -113,4 +113,5 @@ export const discoverServiceMock = { addWarning: jest.fn(), }, expressions: expressionsPlugin, + savedObjectsTagging: {}, } as unknown as DiscoverServices; diff --git a/src/plugins/discover/public/application/main/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap b/src/plugins/discover/public/application/main/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap index 6043a5d382598..7153724203adb 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap +++ b/src/plugins/discover/public/application/main/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap @@ -22,7 +22,7 @@ exports[`OpenSearchPanel render 1`] = ` - diff --git a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.test.tsx b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.test.tsx index e308b41219df8..66208e08c0ba0 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.test.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.test.tsx @@ -6,37 +6,160 @@ * Side Public License, v 1. */ -import { showSaveModal } from '@kbn/saved-objects-plugin/public'; +import * as savedObjectsPlugin from '@kbn/saved-objects-plugin/public'; jest.mock('@kbn/saved-objects-plugin/public'); - +jest.mock('../../utils/persist_saved_search', () => ({ + persistSavedSearch: jest.fn(() => ({ id: 'the-saved-search-id' })), +})); import { onSaveSearch } from './on_save_search'; import { dataViewMock } from '../../../../__mocks__/data_view'; import { savedSearchMock } from '../../../../__mocks__/saved_search'; import { DiscoverServices } from '../../../../build_services'; import { GetStateReturn } from '../../services/discover_state'; import { i18nServiceMock } from '@kbn/core/public/mocks'; +import { ReactElement } from 'react'; +import { discoverServiceMock } from '../../../../__mocks__/services'; +import * as persistSavedSearchUtils from '../../utils/persist_saved_search'; +import { SavedSearch } from '@kbn/saved-search-plugin/public'; + +describe('onSaveSearch', () => { + it('should call showSaveModal', async () => { + const serviceMock = { + core: { + i18n: i18nServiceMock.create(), + }, + } as unknown as DiscoverServices; + const stateMock = { + appStateContainer: { + getState: () => ({ + rowsPerPage: 250, + }), + }, + } as unknown as GetStateReturn; -test('onSaveSearch', async () => { - const serviceMock = { - core: { - i18n: i18nServiceMock.create(), - }, - } as unknown as DiscoverServices; - const stateMock = { - appStateContainer: { - getState: () => ({ - rowsPerPage: 250, - }), - }, - } as unknown as GetStateReturn; + await onSaveSearch({ + dataView: dataViewMock, + navigateTo: jest.fn(), + savedSearch: savedSearchMock, + services: serviceMock, + state: stateMock, + }); + + expect(savedObjectsPlugin.showSaveModal).toHaveBeenCalled(); + }); - await onSaveSearch({ - dataView: dataViewMock, - navigateTo: jest.fn(), - savedSearch: savedSearchMock, - services: serviceMock, - state: stateMock, + it('should pass tags to the save modal', async () => { + const serviceMock = discoverServiceMock; + const stateMock = { + appStateContainer: { + getState: () => ({ + rowsPerPage: 250, + }), + }, + } as unknown as GetStateReturn; + let saveModal: ReactElement | undefined; + jest.spyOn(savedObjectsPlugin, 'showSaveModal').mockImplementationOnce((modal) => { + saveModal = modal; + }); + await onSaveSearch({ + dataView: dataViewMock, + navigateTo: jest.fn(), + savedSearch: { + ...savedSearchMock, + tags: ['tag1', 'tag2'], + }, + services: serviceMock, + state: stateMock, + }); + expect(saveModal?.props.tags).toEqual(['tag1', 'tag2']); }); - expect(showSaveModal).toHaveBeenCalled(); + it('should update the saved search tags', async () => { + const serviceMock = discoverServiceMock; + const stateMock = { + appStateContainer: { + getState: () => ({ + rowsPerPage: 250, + }), + }, + resetInitialAppState: jest.fn(), + } as unknown as GetStateReturn; + let saveModal: ReactElement | undefined; + jest.spyOn(savedObjectsPlugin, 'showSaveModal').mockImplementationOnce((modal) => { + saveModal = modal; + }); + let savedSearch: SavedSearch = { + ...savedSearchMock, + tags: ['tag1', 'tag2'], + }; + await onSaveSearch({ + dataView: dataViewMock, + navigateTo: jest.fn(), + savedSearch, + services: serviceMock, + state: stateMock, + }); + expect(savedSearch.tags).toEqual(['tag1', 'tag2']); + jest + .spyOn(persistSavedSearchUtils, 'persistSavedSearch') + .mockImplementationOnce((newSavedSearch, _) => { + savedSearch = newSavedSearch; + return Promise.resolve({ id: newSavedSearch.id }); + }); + saveModal?.props.onSave({ + newTitle: savedSearch.title, + newCopyOnSave: false, + newDescription: savedSearch.description, + newTags: ['tag3', 'tag4'], + isTitleDuplicateConfirmed: false, + onTitleDuplicate: jest.fn(), + }); + expect(savedSearch.tags).toEqual(['tag3', 'tag4']); + }); + + it('should not update tags if savedObjectsTagging is undefined', async () => { + const serviceMock = discoverServiceMock; + const stateMock = { + appStateContainer: { + getState: () => ({ + rowsPerPage: 250, + }), + }, + resetInitialAppState: jest.fn(), + } as unknown as GetStateReturn; + let saveModal: ReactElement | undefined; + jest.spyOn(savedObjectsPlugin, 'showSaveModal').mockImplementationOnce((modal) => { + saveModal = modal; + }); + let savedSearch: SavedSearch = { + ...savedSearchMock, + tags: ['tag1', 'tag2'], + }; + await onSaveSearch({ + dataView: dataViewMock, + navigateTo: jest.fn(), + savedSearch, + services: { + ...serviceMock, + savedObjectsTagging: undefined, + }, + state: stateMock, + }); + expect(savedSearch.tags).toEqual(['tag1', 'tag2']); + jest + .spyOn(persistSavedSearchUtils, 'persistSavedSearch') + .mockImplementationOnce((newSavedSearch, _) => { + savedSearch = newSavedSearch; + return Promise.resolve({ id: newSavedSearch.id }); + }); + saveModal?.props.onSave({ + newTitle: savedSearch.title, + newCopyOnSave: false, + newDescription: savedSearch.description, + newTags: ['tag3', 'tag4'], + isTitleDuplicateConfirmed: false, + onTitleDuplicate: jest.fn(), + }); + expect(savedSearch.tags).toEqual(['tag1', 'tag2']); + }); }); diff --git a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx index e99295fee9e97..06461be86bbce 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx @@ -106,12 +106,13 @@ export async function onSaveSearch({ onClose?: () => void; onSaveCb?: () => void; }) { - const { uiSettings } = services; + const { uiSettings, savedObjectsTagging } = services; const onSave = async ({ newTitle, newCopyOnSave, newTimeRestore, newDescription, + newTags, isTitleDuplicateConfirmed, onTitleDuplicate, }: { @@ -119,18 +120,24 @@ export async function onSaveSearch({ newTimeRestore: boolean; newCopyOnSave: boolean; newDescription: string; + newTags: string[]; isTitleDuplicateConfirmed: boolean; onTitleDuplicate: () => void; }) => { const currentTitle = savedSearch.title; const currentTimeRestore = savedSearch.timeRestore; const currentRowsPerPage = savedSearch.rowsPerPage; + const currentDescription = savedSearch.description; + const currentTags = savedSearch.tags; savedSearch.title = newTitle; savedSearch.description = newDescription; savedSearch.timeRestore = newTimeRestore; savedSearch.rowsPerPage = uiSettings.get(DOC_TABLE_LEGACY) ? currentRowsPerPage : state.appStateContainer.getState().rowsPerPage; + if (savedObjectsTagging) { + savedSearch.tags = newTags; + } const saveOptions: SaveSavedSearchOptions = { onTitleDuplicate, copyOnSave: newCopyOnSave, @@ -151,6 +158,10 @@ export async function onSaveSearch({ savedSearch.title = currentTitle; savedSearch.timeRestore = currentTimeRestore; savedSearch.rowsPerPage = currentRowsPerPage; + savedSearch.description = currentDescription; + if (savedObjectsTagging) { + savedSearch.tags = currentTags; + } } else { state.resetInitialAppState(); } @@ -160,10 +171,12 @@ export async function onSaveSearch({ const saveModal = ( {})} /> @@ -172,23 +185,46 @@ export async function onSaveSearch({ } const SaveSearchObjectModal: React.FC<{ + services: DiscoverServices; title: string; showCopyOnSave: boolean; description?: string; timeRestore?: boolean; - onSave: (props: OnSaveProps & { newTimeRestore: boolean }) => void; + tags: string[]; + onSave: (props: OnSaveProps & { newTimeRestore: boolean; newTags: string[] }) => void; onClose: () => void; -}> = ({ title, description, showCopyOnSave, timeRestore: savedTimeRestore, onSave, onClose }) => { +}> = ({ + services, + title, + description, + tags, + showCopyOnSave, + timeRestore: savedTimeRestore, + onSave, + onClose, +}) => { + const { savedObjectsTagging } = services; const [timeRestore, setTimeRestore] = useState(savedTimeRestore || false); + const [currentTags, setCurrentTags] = useState(tags); const onModalSave = (params: OnSaveProps) => { onSave({ ...params, newTimeRestore: timeRestore, + newTags: currentTags, }); }; - const options = ( + const tagSelector = savedObjectsTagging ? ( + { + setCurrentTags(newTags); + }} + /> + ) : undefined; + + const timeSwitch = ( ); + const options = tagSelector ? ( + <> + {tagSelector} + {timeSwitch} + + ) : ( + timeSwitch + ); + return ( { test('render', async () => { jest.doMock('../../../../hooks/use_discover_services', () => ({ useDiscoverServices: jest.fn().mockImplementation(() => ({ - core: { uiSettings: {}, savedObjects: {} }, addBasePath: (path: string) => path, capabilities: { savedObjectsManagement: { edit: true } }, + savedObjectsFinder: { Finder: jest.fn() }, + core: {}, })), })); const { OpenSearchPanel } = await import('./open_search_panel'); @@ -33,9 +34,10 @@ describe('OpenSearchPanel', () => { test('should not render manage searches button without permissions', async () => { jest.doMock('../../../../hooks/use_discover_services', () => ({ useDiscoverServices: jest.fn().mockImplementation(() => ({ - core: { uiSettings: {}, savedObjects: {} }, addBasePath: (path: string) => path, capabilities: { savedObjectsManagement: { edit: false, delete: false } }, + savedObjectsFinder: { Finder: jest.fn() }, + core: {}, })), })); const { OpenSearchPanel } = await import('./open_search_panel'); diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_search_panel.tsx b/src/plugins/discover/public/application/main/components/top_nav/open_search_panel.tsx index 32668b99de0ed..81572cf1ebb53 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/open_search_panel.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/open_search_panel.tsx @@ -20,7 +20,7 @@ import { EuiFlyoutBody, EuiTitle, } from '@elastic/eui'; -import { SavedObjectFinderUi } from '@kbn/saved-objects-plugin/public'; +import { SavedObjectFinder } from '@kbn/saved-objects-finder-plugin/public'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; const SEARCH_OBJECT_TYPE = 'search'; @@ -32,11 +32,13 @@ interface OpenSearchPanelProps { export function OpenSearchPanel(props: OpenSearchPanelProps) { const { - core: { uiSettings, savedObjects }, addBasePath, capabilities, + core, + uiSettings, + savedObjectsManagement, + savedObjectsTagging, } = useDiscoverServices(); - const hasSavedObjectPermission = capabilities.savedObjectsManagement?.edit || capabilities.savedObjectsManagement?.delete; @@ -53,7 +55,13 @@ export function OpenSearchPanel(props: OpenSearchPanelProps) { - {hasSavedObjectPermission && ( diff --git a/src/plugins/discover/public/application/main/discover_main_route.tsx b/src/plugins/discover/public/application/main/discover_main_route.tsx index e8e1ec43bd42d..06727473771b3 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.tsx @@ -118,6 +118,7 @@ export function DiscoverMainRoute(props: Props) { search: services.data.search, savedObjectsClient: core.savedObjects.client, spaces: services.spaces, + savedObjectsTagging: services.savedObjectsTagging, }); const currentDataView = await loadDefaultOrCurrentDataView(currentSavedSearch.searchSource); @@ -172,6 +173,7 @@ export function DiscoverMainRoute(props: Props) { services.data, services.spaces, services.timefilter, + services.savedObjectsTagging, core.savedObjects.client, core.application.navigateToApp, core.theme, diff --git a/src/plugins/discover/public/application/main/hooks/use_discover_state.ts b/src/plugins/discover/public/application/main/hooks/use_discover_state.ts index b3aaed0d880db..dc06163a5f2c1 100644 --- a/src/plugins/discover/public/application/main/hooks/use_discover_state.ts +++ b/src/plugins/discover/public/application/main/hooks/use_discover_state.ts @@ -184,6 +184,7 @@ export function useDiscoverState({ search: services.data.search, savedObjectsClient: services.core.savedObjects.client, spaces: services.spaces, + savedObjectsTagging: services.savedObjectsTagging, }); const newDataView = newSavedSearch.searchSource.getField('index') || dataView; diff --git a/src/plugins/discover/public/application/main/utils/persist_saved_search.ts b/src/plugins/discover/public/application/main/utils/persist_saved_search.ts index 488f9598aaae7..edc7d96f9decd 100644 --- a/src/plugins/discover/public/application/main/utils/persist_saved_search.ts +++ b/src/plugins/discover/public/application/main/utils/persist_saved_search.ts @@ -82,7 +82,12 @@ export async function persistSavedSearch( : undefined; try { - const id = await saveSavedSearch(savedSearch, saveOptions, services.core.savedObjects.client); + const id = await saveSavedSearch( + savedSearch, + saveOptions, + services.core.savedObjects.client, + services.savedObjectsTagging + ); if (id) { onSuccess(id); } diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 3e83b149d351e..fe13516f85663 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -43,6 +43,8 @@ import { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import type { SpacesApi } from '@kbn/spaces-plugin/public'; import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; +import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; +import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public'; import { DiscoverAppLocator } from './locator'; import { getHistory } from './kibana_services'; import { DiscoverStartPlugins } from './plugin'; @@ -83,6 +85,8 @@ export interface DiscoverServices { triggersActionsUi: TriggersAndActionsUIPublicPluginStart; locator: DiscoverAppLocator; expressions: ExpressionsStart; + savedObjectsManagement: SavedObjectsManagementPluginStart; + savedObjectsTagging?: SavedObjectsTaggingApi; } export const buildServices = memoize(function ( @@ -128,5 +132,7 @@ export const buildServices = memoize(function ( triggersActionsUi: plugins.triggersActionsUi, locator, expressions: plugins.expressions, + savedObjectsTagging: plugins.savedObjectsTaggingOss?.getTaggingApi(), + savedObjectsManagement: plugins.savedObjectsManagement, }; }); diff --git a/src/plugins/discover/public/embeddable/search_embeddable_factory.ts b/src/plugins/discover/public/embeddable/search_embeddable_factory.ts index 331fa7103a825..185b0daf8055b 100644 --- a/src/plugins/discover/public/embeddable/search_embeddable_factory.ts +++ b/src/plugins/discover/public/embeddable/search_embeddable_factory.ts @@ -76,6 +76,7 @@ export class SearchEmbeddableFactory search: services.data.search, savedObjectsClient: services.core.savedObjects.client, spaces: services.spaces, + savedObjectsTagging: services.savedObjectsTagging, }); await throwErrorOnSavedSearchUrlConflict(savedSearch); diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index d89e7cbff5287..c06bdfaa1ca56 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -35,7 +35,9 @@ import { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; -import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; +import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; +import type { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss-plugin/public'; +import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public'; import { PLUGIN_ID } from '../common'; import { DocViewInput, DocViewInputFn } from './services/doc_views/doc_views_types'; import { DocViewsRegistry } from './services/doc_views/doc_views_registry'; @@ -176,6 +178,8 @@ export interface DiscoverStartPlugins { spaces?: SpacesPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; expressions: ExpressionsStart; + savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; + savedObjectsManagement: SavedObjectsManagementPluginStart; } /** diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index efadcc88443a1..7915736dee994 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -17,6 +17,7 @@ { "path": "../inspector/tsconfig.json" }, { "path": "../url_forwarding/tsconfig.json" }, { "path": "../saved_objects/tsconfig.json" }, + { "path": "../saved_objects_finder/tsconfig.json" }, { "path": "../navigation/tsconfig.json" }, { "path": "../ui_actions/tsconfig.json" }, { "path": "../home/tsconfig.json" }, @@ -30,6 +31,7 @@ { "path": "../unified_search/tsconfig.json" }, { "path": "../../../x-pack/plugins/spaces/tsconfig.json" }, { "path": "../data_view_editor/tsconfig.json" }, - { "path": "../../../x-pack/plugins/triggers_actions_ui/tsconfig.json" } + { "path": "../../../x-pack/plugins/triggers_actions_ui/tsconfig.json" }, + { "path": "../saved_objects_tagging_oss/tsconfig.json" } ] } diff --git a/src/plugins/saved_objects_finder/README.md b/src/plugins/saved_objects_finder/README.md new file mode 100644 index 0000000000000..dcb56840fa5cd --- /dev/null +++ b/src/plugins/saved_objects_finder/README.md @@ -0,0 +1,3 @@ +# `savedObjectsFinder` plugin + +The `savedObjectsFinder` plugin exposes a UI for finding saved objects on the client side. diff --git a/src/plugins/saved_objects_finder/jest.config.js b/src/plugins/saved_objects_finder/jest.config.js new file mode 100644 index 0000000000000..64aae1035ae1d --- /dev/null +++ b/src/plugins/saved_objects_finder/jest.config.js @@ -0,0 +1,18 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/saved_objects_finder'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/saved_objects_finder', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/saved_objects_finder/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/saved_objects_finder/kibana.json b/src/plugins/saved_objects_finder/kibana.json new file mode 100644 index 0000000000000..e50c2f1adeb0c --- /dev/null +++ b/src/plugins/saved_objects_finder/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "savedObjectsFinder", + "owner": { + "name": "Data Discovery", + "githubTeam": "kibana-data-discovery" + }, + "version": "kibana", + "ui": true, + "requiredBundles": ["savedObjects"] +} diff --git a/src/plugins/saved_objects_finder/public/finder/index.tsx b/src/plugins/saved_objects_finder/public/finder/index.tsx new file mode 100644 index 0000000000000..0819ebf8141b6 --- /dev/null +++ b/src/plugins/saved_objects_finder/public/finder/index.tsx @@ -0,0 +1,27 @@ +/* + * 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 { EuiDelayRender, EuiLoadingContent } from '@elastic/eui'; +import React from 'react'; +import type { SavedObjectFinderProps } from './saved_object_finder'; + +const LazySavedObjectFinder = React.lazy(() => import('./saved_object_finder')); +const SavedObjectFinder = (props: SavedObjectFinderProps) => ( + + + + } + > + + +); + +export type { SavedObjectMetaData, SavedObjectFinderProps } from './saved_object_finder'; +export { SavedObjectFinder }; diff --git a/src/plugins/saved_objects_finder/public/finder/saved_object_finder.test.tsx b/src/plugins/saved_objects_finder/public/finder/saved_object_finder.test.tsx new file mode 100644 index 0000000000000..cde6ce1ac0aee --- /dev/null +++ b/src/plugins/saved_objects_finder/public/finder/saved_object_finder.test.tsx @@ -0,0 +1,1196 @@ +/* + * 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. + */ + +const nextTick = () => new Promise((res) => process.nextTick(res)); + +import lodash from 'lodash'; +jest.spyOn(lodash, 'debounce').mockImplementation((fn: any) => fn); +import { EuiInMemoryTable, EuiLink, EuiSearchBarProps, Query } from '@elastic/eui'; +import { IconType } from '@elastic/eui'; +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import * as sinon from 'sinon'; +import { SavedObjectFinderUi as SavedObjectFinder } from './saved_object_finder'; +import { coreMock } from '@kbn/core/public/mocks'; +import { savedObjectsManagementPluginMock } from '@kbn/saved-objects-management-plugin/public/mocks'; +import { findTestSubject } from '@kbn/test-jest-helpers'; +import { SavedObjectManagementTypeInfo } from '@kbn/saved-objects-management-plugin/public'; +import { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; + +describe('SavedObjectsFinder', () => { + const doc = { + id: '1', + type: 'search', + attributes: { title: 'Example title' }, + }; + + const doc2 = { + id: '2', + type: 'search', + attributes: { title: 'Another title' }, + }; + + const doc3 = { type: 'vis', id: '3', attributes: { title: 'Vis' } }; + + const searchMetaData = [ + { + type: 'search', + name: 'Search', + getIconForSavedObject: () => 'search' as IconType, + showSavedObject: () => true, + defaultSearchField: 'name', + }, + ]; + + const metaDataConfig = [ + { + type: 'search', + name: 'Search', + getIconForSavedObject: () => 'search' as IconType, + }, + { + type: 'vis', + name: 'Vis', + getIconForSavedObject: () => 'document' as IconType, + }, + ]; + + const savedObjectsManagement = savedObjectsManagementPluginMock.createStartContract(); + savedObjectsManagement.parseQuery.mockImplementation( + (query: Query, types: SavedObjectManagementTypeInfo[]) => { + const queryTypes = query.ast.getFieldClauses('type')?.[0].value as string[] | undefined; + return { + queryText: query.ast + .getTermClauses() + .map((clause: any) => clause.value) + .join(' '), + visibleTypes: queryTypes?.filter((name) => types.some((type) => type.name === name)), + selectedTags: query.ast.getFieldClauses('tag')?.[0].value as string[] | undefined, + }; + } + ); + savedObjectsManagement.getTagFindReferences.mockImplementation( + ({ selectedTags }) => selectedTags as any + ); + + const savedObjectsTagging = { + ui: { + getTableColumnDefinition: jest.fn(() => ({ + field: 'references', + name: 'Tags', + description: 'Tags associated with this saved object', + 'data-test-subj': 'listingTableRowTags', + sortable: (item: any) => `tag-${item.id}`, + render: (_: any, item: any) => {`tag-${item.id}`}, + })), + getSearchBarFilter: jest.fn(() => ({ + type: 'field_value_selection', + field: 'tag', + name: 'Tags', + multiSelect: 'or', + options: [], + })), + }, + } as any as SavedObjectsTaggingApi; + + it('should call saved object client on startup', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = shallow( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + expect(core.savedObjects.client.find).toHaveBeenCalledWith({ + type: ['search'], + fields: ['title', 'name'], + search: undefined, + hasReference: undefined, + page: 1, + perPage: 10, + searchFields: ['title^3', 'description', 'name'], + defaultSearchOperator: 'AND', + }); + }); + + it('should list initial items', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = shallow( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + expect( + wrapper + .find(EuiInMemoryTable) + .prop('items') + .map((item: any) => item.attributes) + ).toEqual([doc.attributes]); + }); + + it('should call onChoose on item click', async () => { + const chooseStub = sinon.stub(); + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.update(); + findTestSubject(wrapper, 'savedObjectTitleExample-title').simulate('click'); + expect(chooseStub.calledWith('1', 'search', `${doc.attributes.title} (Search)`, doc)).toEqual( + true + ); + }); + + describe('sorting', () => { + it('should list items by type ascending', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc3, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + findTestSubject( + findTestSubject(wrapper, 'tableHeaderCell_type_0'), + 'tableHeaderSortButton' + ).simulate('click'); + const titleLinks = wrapper.find(EuiLink); + expect(titleLinks.at(0).text()).toEqual(doc.attributes.title); + expect(titleLinks.at(1).text()).toEqual(doc2.attributes.title); + expect(titleLinks.at(2).text()).toEqual(doc3.attributes.title); + }); + + it('should list items by type descending', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc3, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + findTestSubject( + findTestSubject(wrapper, 'tableHeaderCell_type_0'), + 'tableHeaderSortButton' + ).simulate('click'); + findTestSubject( + findTestSubject(wrapper, 'tableHeaderCell_type_0'), + 'tableHeaderSortButton' + ).simulate('click'); + const titleLinks = wrapper.find(EuiLink); + expect(titleLinks.at(0).text()).toEqual(doc3.attributes.title); + expect(titleLinks.at(1).text()).toEqual(doc.attributes.title); + expect(titleLinks.at(2).text()).toEqual(doc2.attributes.title); + }); + + it('should list items by title ascending', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.update(); + const titleLinks = wrapper.find(EuiLink); + expect(titleLinks.at(0).text()).toEqual(doc2.attributes.title); + expect(titleLinks.at(1).text()).toEqual(doc.attributes.title); + }); + + it('should list items by title descending', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + findTestSubject( + findTestSubject(wrapper, 'tableHeaderCell_title_0'), + 'tableHeaderSortButton' + ).simulate('click'); + const titleLinks = wrapper.find(EuiLink); + expect(titleLinks.at(0).text()).toEqual(doc.attributes.title); + expect(titleLinks.at(1).text()).toEqual(doc2.attributes.title); + }); + + it('should list items by tag ascending', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc3, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + findTestSubject( + findTestSubject(wrapper, 'tableHeaderCell_references_2'), + 'tableHeaderSortButton' + ).simulate('click'); + const titleLinks = wrapper.find(EuiLink); + expect(titleLinks.at(0).text()).toEqual(doc.attributes.title); + expect(titleLinks.at(1).text()).toEqual(doc2.attributes.title); + expect(titleLinks.at(2).text()).toEqual(doc3.attributes.title); + }); + + it('should list items by tag descending', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc3, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + findTestSubject( + findTestSubject(wrapper, 'tableHeaderCell_references_2'), + 'tableHeaderSortButton' + ).simulate('click'); + findTestSubject( + findTestSubject(wrapper, 'tableHeaderCell_references_2'), + 'tableHeaderSortButton' + ).simulate('click'); + const titleLinks = wrapper.find(EuiLink); + expect(titleLinks.at(0).text()).toEqual(doc3.attributes.title); + expect(titleLinks.at(1).text()).toEqual(doc2.attributes.title); + expect(titleLinks.at(2).text()).toEqual(doc.attributes.title); + }); + }); + + it('should not show the saved objects which get filtered by showSavedObject', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = shallow( + 'search', + showSavedObject: ({ id }) => id !== '1', + }, + ]} + /> + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + const items: any[] = wrapper.find(EuiInMemoryTable).prop('items'); + expect(items).toHaveLength(1); + expect(items[0].attributes.title).toBe(doc2.attributes.title); + }); + + describe('search', () => { + it('should request filtered list on search input', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper + .find('[data-test-subj="savedObjectFinderSearchInput"] input') + .simulate('keyup', { key: 'Enter', target: { value: 'abc' } }); + expect(core.savedObjects.client.find).toHaveBeenCalledWith({ + type: ['search'], + fields: ['title', 'name'], + search: 'abc*', + hasReference: undefined, + page: 1, + perPage: 10, + searchFields: ['title^3', 'description', 'name'], + defaultSearchOperator: 'AND', + }); + }); + + it('should include additional fields in search if listed in meta data', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as jest.Mock).mockResolvedValue({ savedObjects: [] }); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + 'search', + includeFields: ['field1', 'field2'], + }, + { + type: 'type2', + name: '', + getIconForSavedObject: () => 'search', + includeFields: ['field2', 'field3'], + }, + ]} + /> + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper + .find('[data-test-subj="savedObjectFinderSearchInput"] input') + .simulate('keyup', { key: 'Enter', target: { value: 'abc' } }); + expect(core.savedObjects.client.find).toHaveBeenCalledWith({ + type: ['type1', 'type2'], + fields: ['title', 'name', 'field1', 'field2', 'field3'], + search: 'abc*', + hasReference: undefined, + page: 1, + perPage: 10, + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + }); + }); + + it('should respect response order on search input', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper + .find('[data-test-subj="savedObjectFinderSearchInput"] input') + .simulate('keyup', { key: 'Enter', target: { value: 'abc' } }); + const titleLinks = wrapper.find(EuiLink); + expect(titleLinks.at(0).text()).toEqual(doc.attributes.title); + expect(titleLinks.at(1).text()).toEqual(doc2.attributes.title); + }); + }); + + it('should request multiple saved object types at once', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = shallow( + 'search', + }, + { + type: 'vis', + name: 'Vis', + getIconForSavedObject: () => 'visLine', + }, + ]} + /> + ); + + wrapper.instance().componentDidMount!(); + + expect(core.savedObjects.client.find).toHaveBeenCalledWith({ + type: ['search', 'vis'], + fields: ['title', 'name'], + search: undefined, + page: 1, + perPage: 10, + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + }); + }); + + describe('filter', () => { + it('should render filter buttons if enabled', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ + savedObjects: [doc, doc2, doc3], + }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + expect(wrapper.find('button.euiFilterButton')).toHaveLength(2); + expect(wrapper.find('button.euiFilterButton [data-text="Types"]')).toHaveLength(1); + expect(wrapper.find('button.euiFilterButton [data-text="Tags"]')).toHaveLength(1); + }); + + it('should not render filter buttons if disabled', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ + savedObjects: [doc, doc2, doc3], + }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + expect(wrapper.find('button.euiFilterButton')).toHaveLength(0); + }); + + it('should not render types filter button if there is only one type in the metadata list', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ + savedObjects: [doc, doc2], + }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + expect(wrapper.find('button.euiFilterButton [data-text="Types"]')).toHaveLength(0); + expect(wrapper.find('button.euiFilterButton')).toHaveLength(1); + }); + + it('should not render tags filter button if savedObjectsTagging is undefined', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ + savedObjects: [doc, doc2, doc3], + }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + expect(wrapper.find('button.euiFilterButton [data-text="Tags"]')).toHaveLength(0); + expect(wrapper.find('button.euiFilterButton')).toHaveLength(1); + }); + + it('should apply types filter if selected', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ + savedObjects: [doc, doc2, doc3], + }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + const table = wrapper.find>(EuiInMemoryTable); + const search = table.prop('search') as EuiSearchBarProps; + search.onChange?.({ query: Query.parse('type:(vis)'), queryText: '', error: null }); + expect(core.savedObjects.client.find).toHaveBeenLastCalledWith({ + type: ['vis'], + fields: ['title', 'name'], + search: undefined, + hasReference: undefined, + page: 1, + perPage: 10, + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + }); + search.onChange?.({ query: Query.parse('type:(search or vis)'), queryText: '', error: null }); + expect(core.savedObjects.client.find).toHaveBeenLastCalledWith({ + type: ['search', 'vis'], + fields: ['title', 'name'], + search: undefined, + hasReference: undefined, + page: 1, + perPage: 10, + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + }); + }); + + it('should apply tags filter if selected', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ + savedObjects: [doc, doc2, doc3], + }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + const table = wrapper.find>(EuiInMemoryTable); + const search = table.prop('search') as EuiSearchBarProps; + search.onChange?.({ query: Query.parse('tag:(tag1)'), queryText: '', error: null }); + expect(core.savedObjects.client.find).toHaveBeenLastCalledWith({ + type: ['search', 'vis'], + fields: ['title', 'name'], + search: undefined, + hasReference: ['tag1'], + page: 1, + perPage: 10, + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + }); + search.onChange?.({ query: Query.parse('tag:(tag1 or tag2)'), queryText: '', error: null }); + expect(core.savedObjects.client.find).toHaveBeenLastCalledWith({ + type: ['search', 'vis'], + fields: ['title', 'name'], + search: undefined, + hasReference: ['tag1', 'tag2'], + page: 1, + perPage: 10, + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + }); + }); + }); + + it('should display no items message if there are no items', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const noItemsMessage = ; + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + + expect(wrapper.find(EuiInMemoryTable).prop('message')).toEqual(noItemsMessage); + }); + + describe('pagination', () => { + const longItemList = new Array(50).fill(undefined).map((_, i) => ({ + id: String(i), + type: 'search', + attributes: { + title: `Title ${i < 10 ? '0' : ''}${i}`, + }, + })); + + it('should show a table pagination with initial per page', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: longItemList }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.update(); + const pagination = wrapper.find(EuiInMemoryTable).prop('pagination') as any; + expect(pagination.showPerPageOptions).toBe(true); + expect(pagination.initialPageSize).toEqual(15); + expect(wrapper.find(EuiInMemoryTable).find('tbody tr')).toHaveLength(15); + }); + + it('should allow switching the page size', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: longItemList }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + const table = wrapper.find>(EuiInMemoryTable); + const sort = table.prop('sorting'); + table.instance().onTableChange({ + page: { + index: 0, + size: 5, + }, + sort: typeof sort === 'object' ? sort?.sort : undefined, + }); + wrapper.update(); + expect(wrapper.find(EuiInMemoryTable).find('tbody tr')).toHaveLength(5); + }); + + it('should switch page correctly', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: longItemList }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.update(); + expect(wrapper.find(EuiInMemoryTable).find('tbody tr')).toHaveLength(15); + const table = wrapper.find>(EuiInMemoryTable); + const pagination = table.prop('pagination') as any; + const sort = table.prop('sorting'); + table.instance().onTableChange({ + page: { + index: 3, + size: pagination.initialPageSize, + }, + sort: typeof sort === 'object' ? sort?.sort : undefined, + }); + wrapper.update(); + expect(wrapper.find(EuiInMemoryTable).find('tbody tr')).toHaveLength(5); + }); + + it('should show an ordinary pagination for fixed page sizes', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: longItemList }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.update(); + const pagination = wrapper.find(EuiInMemoryTable).prop('pagination') as any; + expect(pagination.showPerPageOptions).toBe(false); + expect(pagination.initialPageSize).toEqual(33); + expect(wrapper.find(EuiInMemoryTable).find('tbody tr')).toHaveLength(33); + }); + + it('should switch page correctly for fixed page sizes', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: longItemList }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.update(); + expect(wrapper.find(EuiInMemoryTable).find('tbody tr')).toHaveLength(33); + const table = wrapper.find>(EuiInMemoryTable); + const pagination = table.prop('pagination') as any; + const sort = table.prop('sorting'); + table.instance().onTableChange({ + page: { + index: 1, + size: pagination.initialPageSize, + }, + sort: typeof sort === 'object' ? sort?.sort : undefined, + }); + wrapper.update(); + expect(wrapper.find(EuiInMemoryTable).find('tbody tr')).toHaveLength(17); + }); + }); + + describe('loading state', () => { + it('should display a loading indicator during initial loading', () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as jest.Mock).mockResolvedValue({ savedObjects: [] }); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + expect(wrapper.find('.euiBasicTable-loading')).toHaveLength(1); + }); + + it('should hide the loading indicator if data is shown', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + 'search', + }, + ]} + /> + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.update(); + expect(wrapper.find('.euiBasicTable-loading')).toHaveLength(0); + }); + + it('should show the loading indicator if there are already items and the search is updated', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.update(); + expect(wrapper.find('.euiBasicTable-loading')).toHaveLength(0); + wrapper + .find('[data-test-subj="savedObjectFinderSearchInput"] input') + .simulate('keyup', { key: 'Enter', target: { value: 'abc' } }); + wrapper.update(); + expect(wrapper.find('.euiBasicTable-loading')).toHaveLength(1); + }); + }); + + it('should render with children', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + 'search', + }, + { + type: 'vis', + name: 'Vis', + getIconForSavedObject: () => 'visLine', + }, + ]} + > + + + ); + expect(wrapper.exists('#testChildButton')).toBe(true); + }); + + describe('columns', () => { + it('should show all columns', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc2, doc3] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.update(); + expect(wrapper.find(EuiInMemoryTable).find('th')).toHaveLength(3); + expect(findTestSubject(wrapper, 'tableHeaderCell_type_0')).toHaveLength(1); + expect(findTestSubject(wrapper, 'tableHeaderCell_title_1')).toHaveLength(1); + expect(findTestSubject(wrapper, 'tableHeaderCell_references_2')).toHaveLength(1); + }); + + it('should hide the type column if there is only one type in the metadata list', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc2] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.update(); + expect(wrapper.find(EuiInMemoryTable).find('th')).toHaveLength(2); + expect(findTestSubject(wrapper, 'tableHeaderCell_type_0')).toHaveLength(0); + expect(findTestSubject(wrapper, 'tableHeaderCell_title_0')).toHaveLength(1); + expect(findTestSubject(wrapper, 'tableHeaderCell_references_1')).toHaveLength(1); + }); + + it('should hide the tags column if savedObjectsTagging is undefined', async () => { + const core = coreMock.createStart(); + (core.savedObjects.client.find as any as jest.SpyInstance).mockImplementation(() => + Promise.resolve({ savedObjects: [doc, doc2, doc3] }) + ); + core.uiSettings.get.mockImplementation(() => 10); + + const wrapper = mount( + + ); + + wrapper.instance().componentDidMount!(); + await nextTick(); + wrapper.update(); + expect(wrapper.find(EuiInMemoryTable).find('th')).toHaveLength(2); + expect(findTestSubject(wrapper, 'tableHeaderCell_type_0')).toHaveLength(1); + expect(findTestSubject(wrapper, 'tableHeaderCell_title_1')).toHaveLength(1); + expect(findTestSubject(wrapper, 'tableHeaderCell_references_2')).toHaveLength(0); + }); + }); +}); diff --git a/src/plugins/saved_objects_finder/public/finder/saved_object_finder.tsx b/src/plugins/saved_objects_finder/public/finder/saved_object_finder.tsx new file mode 100644 index 0000000000000..e74480a17e3bf --- /dev/null +++ b/src/plugins/saved_objects_finder/public/finder/saved_object_finder.tsx @@ -0,0 +1,386 @@ +/* + * 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 { debounce } from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; +import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public'; + +import { + EuiInMemoryTable, + EuiLink, + EuiTableFieldDataColumnType, + IconType, + EuiIcon, + EuiToolTip, + EuiSearchBarProps, + SearchFilterConfig, + Query, + PropertySort, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import type { + SimpleSavedObject, + SavedObject, + SavedObjectsStart, + IUiSettingsClient, +} from '@kbn/core/public'; +import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; +import { LISTING_LIMIT_SETTING } from '@kbn/saved-objects-plugin/public'; + +export interface SavedObjectMetaData { + type: string; + name: string; + getIconForSavedObject(savedObject: SimpleSavedObject): IconType; + getTooltipForSavedObject?(savedObject: SimpleSavedObject): string; + showSavedObject?(savedObject: SimpleSavedObject): boolean; + getSavedObjectSubType?(savedObject: SimpleSavedObject): string; + includeFields?: string[]; + defaultSearchField?: string; +} + +interface FinderAttributes { + title?: string; + name?: string; + type: string; +} + +interface SavedObjectFinderItem extends SavedObject { + title: string | null; + name: string | null; + simple: SimpleSavedObject; +} + +interface SavedObjectFinderState { + items: SavedObjectFinderItem[]; + query: Query; + isFetchingItems: boolean; + sort?: PropertySort; +} + +interface SavedObjectFinderServices { + savedObjects: SavedObjectsStart; + uiSettings: IUiSettingsClient; + savedObjectsManagement: SavedObjectsManagementPluginStart; + savedObjectsTagging: SavedObjectsTaggingApi | undefined; +} + +interface BaseSavedObjectFinder { + services: SavedObjectFinderServices; + onChoose?: ( + id: SimpleSavedObject['id'], + type: SimpleSavedObject['type'], + name: string, + savedObject: SimpleSavedObject + ) => void; + noItemsMessage?: React.ReactNode; + savedObjectMetaData: Array>; + showFilter?: boolean; +} + +interface SavedObjectFinderFixedPage extends BaseSavedObjectFinder { + initialPageSize?: undefined; + fixedPageSize: number; +} + +interface SavedObjectFinderInitialPageSize extends BaseSavedObjectFinder { + initialPageSize?: 5 | 10 | 15 | 25; + fixedPageSize?: undefined; +} + +export type SavedObjectFinderProps = SavedObjectFinderFixedPage | SavedObjectFinderInitialPageSize; + +export class SavedObjectFinderUi extends React.Component< + SavedObjectFinderProps, + SavedObjectFinderState +> { + public static propTypes = { + onChoose: PropTypes.func, + noItemsMessage: PropTypes.node, + savedObjectMetaData: PropTypes.array.isRequired, + initialPageSize: PropTypes.oneOf([5, 10, 15, 25]), + fixedPageSize: PropTypes.number, + showFilter: PropTypes.bool, + }; + + private isComponentMounted: boolean = false; + + private debouncedFetch = debounce(async (query: Query) => { + const metaDataMap = this.getSavedObjectMetaDataMap(); + const { queryText, visibleTypes, selectedTags } = + this.props.services.savedObjectsManagement.parseQuery( + query, + Object.values(metaDataMap).map((metadata) => ({ + name: metadata.type, + namespaceType: 'single', + hidden: false, + displayName: metadata.name, + })) + ); + + const fields = Object.values(metaDataMap) + .map((metaData) => metaData.includeFields || []) + .reduce((allFields, currentFields) => allFields.concat(currentFields), ['title', 'name']); + + const additionalSearchFields = Object.values(metaDataMap).reduce((col, item) => { + if (item.defaultSearchField) { + col.push(item.defaultSearchField); + } + return col; + }, []); + + const perPage = this.props.services.uiSettings.get(LISTING_LIMIT_SETTING); + const response = await this.props.services.savedObjects.client.find({ + type: visibleTypes ?? Object.keys(metaDataMap), + fields: [...new Set(fields)], + search: queryText ? `${queryText}*` : undefined, + page: 1, + perPage, + searchFields: ['title^3', 'description', ...additionalSearchFields], + defaultSearchOperator: 'AND', + hasReference: this.props.services.savedObjectsManagement.getTagFindReferences({ + selectedTags, + taggingApi: this.props.services.savedObjectsTagging, + }), + }); + + const savedObjects = response.savedObjects + .map((savedObject) => { + const { + attributes: { name, title }, + } = savedObject; + const titleToUse = typeof title === 'string' ? title : ''; + const nameToUse = name && typeof name === 'string' ? name : titleToUse; + return { + ...savedObject, + version: savedObject._version, + title: titleToUse, + name: nameToUse, + simple: savedObject, + }; + }) + .filter((savedObject) => { + const metaData = metaDataMap[savedObject.type]; + if (metaData.showSavedObject) { + return metaData.showSavedObject(savedObject.simple); + } else { + return true; + } + }); + + if (!this.isComponentMounted) { + return; + } + + // We need this check to handle the case where search results come back in a different + // order than they were sent out. Only load results for the most recent search. + if (query.text === this.state.query.text) { + this.setState({ + isFetchingItems: false, + items: savedObjects, + }); + } + }, 300); + + constructor(props: SavedObjectFinderProps) { + super(props); + + this.state = { + items: [], + isFetchingItems: false, + query: Query.parse(''), + }; + } + + public componentWillUnmount() { + this.isComponentMounted = false; + this.debouncedFetch.cancel(); + } + + public componentDidMount() { + this.isComponentMounted = true; + this.fetchItems(); + } + + private getSavedObjectMetaDataMap(): Record { + return this.props.savedObjectMetaData.reduce( + (map, metaData) => ({ ...map, [metaData.type]: metaData }), + {} + ); + } + + private fetchItems = () => { + this.setState( + { + isFetchingItems: true, + }, + this.debouncedFetch.bind(null, this.state.query) + ); + }; + + public render() { + const { onChoose, savedObjectMetaData } = this.props; + const taggingApi = this.props.services.savedObjectsTagging; + const originalTagColumn = taggingApi?.ui.getTableColumnDefinition(); + const tagColumn: EuiTableFieldDataColumnType | undefined = originalTagColumn + ? { + ...originalTagColumn, + sortable: (item) => + typeof originalTagColumn.sortable === 'function' + ? originalTagColumn.sortable(item) ?? '' + : '', + ['data-test-subj']: 'savedObjectFinderTags', + } + : undefined; + const typeColumn: EuiTableFieldDataColumnType | undefined = + savedObjectMetaData.length > 1 + ? { + field: 'type', + name: i18n.translate('savedObjectsFinder.typeName', { + defaultMessage: 'Type', + }), + width: '50px', + align: 'center', + description: i18n.translate('savedObjectsFinder.typeDescription', { + defaultMessage: 'Type of the saved object', + }), + sortable: ({ type }) => { + const currentSavedObjectMetaData = savedObjectMetaData.find( + (metaData) => metaData.type === type + ); + + return currentSavedObjectMetaData?.name ?? ''; + }, + 'data-test-subj': 'savedObjectFinderType', + render: (_, item) => { + const currentSavedObjectMetaData = savedObjectMetaData.find( + (metaData) => metaData.type === item.type + )!; + const iconType = ( + currentSavedObjectMetaData || + ({ + getIconForSavedObject: () => 'document', + } as Pick, 'getIconForSavedObject'>) + ).getIconForSavedObject(item.simple); + + return ( + + + + ); + }, + } + : undefined; + const columns: Array> = [ + ...(typeColumn ? [typeColumn] : []), + { + field: 'title', + name: i18n.translate('savedObjectsFinder.titleName', { + defaultMessage: 'Title', + }), + width: '55%', + description: i18n.translate('savedObjectsFinder.titleDescription', { + defaultMessage: 'Title of the saved object', + }), + dataType: 'string', + sortable: ({ name }) => name?.toLowerCase(), + 'data-test-subj': 'savedObjectFinderTitle', + render: (_, item) => { + const currentSavedObjectMetaData = savedObjectMetaData.find( + (metaData) => metaData.type === item.type + )!; + const fullName = currentSavedObjectMetaData.getTooltipForSavedObject + ? currentSavedObjectMetaData.getTooltipForSavedObject(item.simple) + : `${item.name} (${currentSavedObjectMetaData!.name})`; + + return ( + { + onChoose(item.id, item.type, fullName, item.simple); + } + : undefined + } + title={fullName} + data-test-subj={`savedObjectTitle${(item.title || '').split(' ').join('-')}`} + > + {item.name} + + ); + }, + }, + ...(tagColumn ? [tagColumn] : []), + ]; + const pagination = { + initialPageSize: this.props.initialPageSize || this.props.fixedPageSize || 10, + pageSizeOptions: [5, 10, 15, 25], + showPerPageOptions: !this.props.fixedPageSize, + }; + const sorting = { + sort: this.state.sort ?? { + field: this.state.query?.text ? '' : 'title', + direction: 'asc', + }, + }; + const typeFilter: SearchFilterConfig = { + type: 'field_value_selection', + field: 'type', + name: i18n.translate('savedObjectsFinder.filterButtonLabel', { + defaultMessage: 'Types', + }), + multiSelect: 'or', + options: this.props.savedObjectMetaData.map((metaData) => ({ + value: metaData.type, + name: metaData.name, + })), + }; + const search: EuiSearchBarProps = { + onChange: ({ query }) => { + this.setState({ query: query ?? Query.parse('') }, this.fetchItems); + }, + box: { + incremental: true, + 'data-test-subj': 'savedObjectFinderSearchInput', + }, + filters: this.props.showFilter + ? [ + ...(savedObjectMetaData.length > 1 ? [typeFilter] : []), + ...(taggingApi ? [taggingApi.ui.getSearchBarFilter({ useName: true })] : []), + ] + : undefined, + toolsRight: this.props.children ? <>{this.props.children} : undefined, + }; + + return ( + { + this.setState({ sort }); + }} + /> + ); + } +} + +// Needed for React.lazy +// eslint-disable-next-line import/no-default-export +export default SavedObjectFinderUi; diff --git a/src/plugins/saved_objects_finder/public/index.ts b/src/plugins/saved_objects_finder/public/index.ts new file mode 100644 index 0000000000000..b266860185365 --- /dev/null +++ b/src/plugins/saved_objects_finder/public/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { SavedObjectsFinderPublicPlugin } from './plugin'; +export type { SavedObjectMetaData, SavedObjectFinderProps } from './finder'; +export { SavedObjectFinder } from './finder'; + +export const plugin = () => new SavedObjectsFinderPublicPlugin(); diff --git a/src/plugins/saved_objects_finder/public/plugin.ts b/src/plugins/saved_objects_finder/public/plugin.ts new file mode 100644 index 0000000000000..6c5b143fd78dc --- /dev/null +++ b/src/plugins/saved_objects_finder/public/plugin.ts @@ -0,0 +1,19 @@ +/* + * 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 { Plugin } from '@kbn/core/public'; + +export class SavedObjectsFinderPublicPlugin implements Plugin<{}, {}, object, {}> { + public setup() { + return {}; + } + + public start() { + return {}; + } +} diff --git a/src/plugins/saved_objects_finder/tsconfig.json b/src/plugins/saved_objects_finder/tsconfig.json new file mode 100644 index 0000000000000..547ab2cc357f7 --- /dev/null +++ b/src/plugins/saved_objects_finder/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["common/**/*", "public/**/*", "server/**/*"], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../saved_objects_management/tsconfig.json" } + ] +} diff --git a/src/plugins/saved_objects_management/public/mocks.ts b/src/plugins/saved_objects_management/public/mocks.ts index 8e12d0bde3a0e..82fe0d419855c 100644 --- a/src/plugins/saved_objects_management/public/mocks.ts +++ b/src/plugins/saved_objects_management/public/mocks.ts @@ -26,6 +26,8 @@ const createStartContractMock = (): jest.Mocked Promise; getSavedObjectLabel: typeof getSavedObjectLabel; getDefaultTitle: typeof getDefaultTitle; + parseQuery: typeof parseQuery; + getTagFindReferences: typeof getTagFindReferences; } export interface SetupDependencies { @@ -125,6 +134,8 @@ export class SavedObjectsManagementPlugin getRelationships(_core.http, type, id, savedObjectTypes), getSavedObjectLabel, getDefaultTitle, + parseQuery, + getTagFindReferences, }; } } diff --git a/src/plugins/saved_search/public/services/saved_searches/get_saved_searches.test.ts b/src/plugins/saved_search/public/services/saved_searches/get_saved_searches.test.ts index 397f3c11990eb..ca405537c363d 100644 --- a/src/plugins/saved_search/public/services/saved_searches/get_saved_searches.test.ts +++ b/src/plugins/saved_search/public/services/saved_searches/get_saved_searches.test.ts @@ -12,6 +12,7 @@ import { savedObjectsServiceMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { getSavedSearch } from './get_saved_searches'; +import { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; describe('getSavedSearch', () => { let search: DataPublicPluginStart['search']; @@ -23,7 +24,10 @@ describe('getSavedSearch', () => { }); test('should return empty saved search in case of no id', async () => { - const savedSearch = await getSavedSearch(undefined, { savedObjectsClient, search }); + const savedSearch = await getSavedSearch(undefined, { + savedObjectsClient, + search, + }); expect(search.searchSource.createEmpty).toHaveBeenCalled(); expect(savedSearch).toHaveProperty('searchSource'); @@ -146,6 +150,7 @@ describe('getSavedSearch', () => { "desc", ], ], + "tags": undefined, "timeRange": undefined, "timeRestore": undefined, "title": "test1", @@ -242,6 +247,7 @@ describe('getSavedSearch', () => { "desc", ], ], + "tags": undefined, "timeRange": undefined, "timeRestore": undefined, "title": "test2", @@ -249,4 +255,62 @@ describe('getSavedSearch', () => { } `); }); + + it('should call savedObjectsTagging.ui.getTagIdsFromReferences', async () => { + savedObjectsClient.resolve = jest.fn().mockReturnValue({ + saved_object: { + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"query":{"sql":"SELECT * FROM foo"},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', + }, + title: 'test2', + sort: [['order_date', 'desc']], + columns: ['_source'], + description: 'description', + grid: {}, + hideChart: true, + isTextBasedQuery: true, + }, + id: 'ccf1af80-2297-11ec-86e0-1155ffb9c7a7', + type: 'search', + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + type: 'index-pattern', + }, + { + name: 'tag-1', + id: 'tag-1', + type: 'tag', + }, + ], + namespaces: ['default'], + }, + outcome: 'exactMatch', + }); + const savedObjectsTagging = { + ui: { + getTagIdsFromReferences: jest.fn((_, tags) => tags), + }, + } as unknown as SavedObjectsTaggingApi; + await getSavedSearch('ccf1af80-2297-11ec-86e0-1155ffb9c7a7', { + savedObjectsClient, + search, + savedObjectsTagging, + }); + expect(savedObjectsTagging.ui.getTagIdsFromReferences).toHaveBeenCalledWith([ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + type: 'index-pattern', + }, + { + name: 'tag-1', + id: 'tag-1', + type: 'tag', + }, + ]); + }); }); diff --git a/src/plugins/saved_search/public/services/saved_searches/get_saved_searches.ts b/src/plugins/saved_search/public/services/saved_searches/get_saved_searches.ts index 444e9f0978182..59f2104adbcad 100644 --- a/src/plugins/saved_search/public/services/saved_searches/get_saved_searches.ts +++ b/src/plugins/saved_search/public/services/saved_searches/get_saved_searches.ts @@ -6,11 +6,12 @@ * Side Public License, v 1. */ -import type { SavedObjectsStart } from '@kbn/core/public'; +import type { SavedObjectsClientContract } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { injectSearchSourceReferences, parseSearchSourceJSON } from '@kbn/data-plugin/public'; import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public'; import type { SpacesApi } from '@kbn/spaces-plugin/public'; +import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { SavedSearchAttributes, SavedSearch } from './types'; import { SAVED_SEARCH_TYPE } from './constants'; @@ -18,8 +19,9 @@ import { fromSavedSearchAttributes } from './saved_searches_utils'; interface GetSavedSearchDependencies { search: DataPublicPluginStart['search']; - savedObjectsClient: SavedObjectsStart['client']; + savedObjectsClient: SavedObjectsClientContract; spaces?: SpacesApi; + savedObjectsTagging?: SavedObjectsTaggingApi; } const getEmptySavedSearch = ({ @@ -32,7 +34,7 @@ const getEmptySavedSearch = ({ const findSavedSearch = async ( savedSearchId: string, - { search, savedObjectsClient, spaces }: GetSavedSearchDependencies + { search, savedObjectsClient, spaces, savedObjectsTagging }: GetSavedSearchDependencies ) => { const so = await savedObjectsClient.resolve( SAVED_SEARCH_TYPE, @@ -54,9 +56,14 @@ const findSavedSearch = async ( savedSearch.references ); + const tags = savedObjectsTagging + ? savedObjectsTagging.ui.getTagIdsFromReferences(savedSearch.references) + : undefined; + return fromSavedSearchAttributes( savedSearchId, savedSearch.attributes, + tags, await search.searchSource.create(searchSourceValues), { outcome: so.outcome, diff --git a/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.test.ts b/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.test.ts index 56b988b20121c..0d7769b46c19d 100644 --- a/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.test.ts +++ b/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.test.ts @@ -13,6 +13,7 @@ import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { saveSavedSearch } from './save_saved_searches'; import type { SavedSearch } from './types'; +import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; describe('saveSavedSearch', () => { let savedObjectsClient: SavedObjectsStart['client']; @@ -51,7 +52,8 @@ describe('saveSavedSearch', () => { onTitleDuplicate, copyOnSave: true, }, - savedObjectsClient + savedObjectsClient, + undefined ); expect(onTitleDuplicate).toHaveBeenCalled(); @@ -69,7 +71,8 @@ describe('saveSavedSearch', () => { onTitleDuplicate, copyOnSave: false, }, - savedObjectsClient + savedObjectsClient, + undefined ); expect(onTitleDuplicate).not.toHaveBeenCalled(); @@ -79,7 +82,7 @@ describe('saveSavedSearch', () => { test('should call savedObjectsClient.create for saving new search', async () => { delete savedSearch.id; - await saveSavedSearch(savedSearch, {}, savedObjectsClient); + await saveSavedSearch(savedSearch, {}, savedObjectsClient, undefined); expect(savedObjectsClient.create).toHaveBeenCalledWith( 'search', @@ -99,7 +102,7 @@ describe('saveSavedSearch', () => { }); test('should call savedObjectsClient.update for saving existing search', async () => { - await saveSavedSearch(savedSearch, {}, savedObjectsClient); + await saveSavedSearch(savedSearch, {}, savedObjectsClient, undefined); expect(savedObjectsClient.update).toHaveBeenCalledWith( 'search', @@ -118,4 +121,39 @@ describe('saveSavedSearch', () => { { references: [] } ); }); + + test('should call savedObjectsTagging.ui.updateTagsReferences', async () => { + const savedObjectsTagging = { + ui: { + updateTagsReferences: jest.fn((_, tags) => tags), + }, + } as unknown as SavedObjectsTaggingApi; + await saveSavedSearch( + { ...savedSearch, tags: ['tag-1', 'tag-2'] }, + {}, + savedObjectsClient, + savedObjectsTagging + ); + + expect(savedObjectsTagging.ui.updateTagsReferences).toHaveBeenCalledWith( + [], + ['tag-1', 'tag-2'] + ); + expect(savedObjectsClient.update).toHaveBeenCalledWith( + 'search', + 'id', + { + columns: [], + description: '', + grid: {}, + isTextBasedQuery: false, + hideChart: false, + kibanaSavedObjectMeta: { searchSourceJSON: '{}' }, + sort: [], + title: 'title', + timeRestore: false, + }, + { references: ['tag-1', 'tag-2'] } + ); + }); }); diff --git a/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts b/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts index e83685015dd46..029d738096859 100644 --- a/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts +++ b/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.ts @@ -5,7 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import type { SavedObjectsStart } from '@kbn/core/public'; +import type { SavedObjectsClientContract, SavedObjectsStart } from '@kbn/core/public'; +import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { SavedSearch, SavedSearchAttributes } from './types'; import { SAVED_SEARCH_TYPE } from './constants'; @@ -42,7 +43,8 @@ const hasDuplicatedTitle = async ( export const saveSavedSearch = async ( savedSearch: SavedSearch, options: SaveSavedSearchOptions, - savedObjectsClient: SavedObjectsStart['client'] + savedObjectsClient: SavedObjectsClientContract, + savedObjectsTagging: SavedObjectsTaggingApi | undefined ): Promise => { const isNew = options.copyOnSave || !savedSearch.id; @@ -58,7 +60,10 @@ export const saveSavedSearch = async ( } } - const { searchSourceJSON, references } = savedSearch.searchSource.serialize(); + const { searchSourceJSON, references: originalReferences } = savedSearch.searchSource.serialize(); + const references = savedObjectsTagging + ? savedObjectsTagging.ui.updateTagsReferences(originalReferences, savedSearch.tags ?? []) + : originalReferences; const resp = isNew ? await savedObjectsClient.create( SAVED_SEARCH_TYPE, diff --git a/src/plugins/saved_search/public/services/saved_searches/saved_searches_utils.test.ts b/src/plugins/saved_search/public/services/saved_searches/saved_searches_utils.test.ts index a6926aef50796..55d2b7f99009d 100644 --- a/src/plugins/saved_search/public/services/saved_searches/saved_searches_utils.test.ts +++ b/src/plugins/saved_search/public/services/saved_searches/saved_searches_utils.test.ts @@ -30,8 +30,15 @@ describe('saved_searches_utils', () => { isTextBasedQuery: false, }; - expect(fromSavedSearchAttributes('id', attributes, createSearchSourceMock(), {})) - .toMatchInlineSnapshot(` + expect( + fromSavedSearchAttributes( + 'id', + attributes, + ['tags-1', 'tags-2'], + createSearchSourceMock(), + {} + ) + ).toMatchInlineSnapshot(` Object { "columns": Array [ "a", @@ -67,6 +74,10 @@ describe('saved_searches_utils', () => { }, "sharingSavedObjectProps": Object {}, "sort": Array [], + "tags": Array [ + "tags-1", + "tags-2", + ], "timeRange": undefined, "timeRestore": undefined, "title": "saved search", diff --git a/src/plugins/saved_search/public/services/saved_searches/saved_searches_utils.ts b/src/plugins/saved_search/public/services/saved_searches/saved_searches_utils.ts index 52c25783c0e50..8ca6dde21d6fc 100644 --- a/src/plugins/saved_search/public/services/saved_searches/saved_searches_utils.ts +++ b/src/plugins/saved_search/public/services/saved_searches/saved_searches_utils.ts @@ -27,6 +27,7 @@ export const throwErrorOnSavedSearchUrlConflict = async (savedSearch: SavedSearc export const fromSavedSearchAttributes = ( id: string, attributes: SavedSearchAttributes, + tags: string[] | undefined, searchSource: SavedSearch['searchSource'], sharingSavedObjectProps: SavedSearch['sharingSavedObjectProps'] ): SavedSearch => ({ @@ -37,6 +38,7 @@ export const fromSavedSearchAttributes = ( sort: attributes.sort, columns: attributes.columns, description: attributes.description, + tags, grid: attributes.grid, hideChart: attributes.hideChart, viewMode: attributes.viewMode, diff --git a/src/plugins/saved_search/public/services/saved_searches/types.ts b/src/plugins/saved_search/public/services/saved_searches/types.ts index acd745374e265..58972dd40bfb8 100644 --- a/src/plugins/saved_search/public/services/saved_searches/types.ts +++ b/src/plugins/saved_search/public/services/saved_searches/types.ts @@ -58,6 +58,7 @@ export interface SavedSearch { sort?: SortOrder[]; columns?: string[]; description?: string; + tags?: string[] | undefined; grid?: { columns?: Record; }; diff --git a/src/plugins/saved_search/tsconfig.json b/src/plugins/saved_search/tsconfig.json index 3bfddf5ff5858..288f30441b922 100644 --- a/src/plugins/saved_search/tsconfig.json +++ b/src/plugins/saved_search/tsconfig.json @@ -16,6 +16,7 @@ { "path": "../../core/tsconfig.json" }, { "path": "../data/tsconfig.json" }, { "path": "../kibana_utils/tsconfig.json" }, - { "path": "../../../x-pack/plugins/spaces/tsconfig.json" } + { "path": "../../../x-pack/plugins/spaces/tsconfig.json" }, + { "path": "../saved_objects_tagging_oss/tsconfig.json" } ] } diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 2bd233a5db049..e98ba20fe3056 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -88,6 +88,7 @@ import { setTheme, setExecutionContext, setFieldFormats, + setSavedObjectTagging, } from './services'; import { VisualizeConstants } from '../common/constants'; @@ -378,6 +379,10 @@ export class VisualizationsPlugin setSpaces(spaces); } + if (savedObjectsTaggingOss) { + setSavedObjectTagging(savedObjectsTaggingOss); + } + return { ...types, showNewVisModal, diff --git a/src/plugins/visualizations/public/services.ts b/src/plugins/visualizations/public/services.ts index f87597b07462b..2474647917e1f 100644 --- a/src/plugins/visualizations/public/services.ts +++ b/src/plugins/visualizations/public/services.ts @@ -25,6 +25,7 @@ import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import type { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss-plugin/public'; import type { TypesStart } from './vis_types'; export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); @@ -68,3 +69,6 @@ export const [getExecutionContext, setExecutionContext] = createGetterSetter('ExecutionContext'); export const [getSpaces, setSpaces] = createGetterSetter('Spaces', false); + +export const [getSavedObjectTagging, setSavedObjectTagging] = + createGetterSetter('SavedObjectTagging', false); diff --git a/src/plugins/visualizations/public/vis.ts b/src/plugins/visualizations/public/vis.ts index 003a894fdfe11..1fbe84eee556c 100644 --- a/src/plugins/visualizations/public/vis.ts +++ b/src/plugins/visualizations/public/vis.ts @@ -35,6 +35,7 @@ import { getSavedObjects, getSpaces, getFieldsFormats, + getSavedObjectTagging, } from './services'; import { BaseVisType } from './vis_types'; import { SerializedVis, SerializedVisData, VisParams } from '../common/types'; @@ -58,6 +59,7 @@ const getSearchSource = async (inputSearchSource: ISearchSource, savedSearchId?: search: getSearch(), savedObjectsClient: getSavedObjects().client, spaces: getSpaces(), + savedObjectsTagging: getSavedObjectTagging()?.getTaggingApi(), }); } catch (e) { return inputSearchSource; diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_visualization_instance.ts b/src/plugins/visualizations/public/visualize_app/utils/get_visualization_instance.ts index 4049f8242a02a..40f789390214b 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_visualization_instance.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/get_visualization_instance.ts @@ -37,7 +37,8 @@ const createVisualizeEmbeddableAndLinkSavedSearch = async ( vis: Vis, visualizeServices: VisualizeServices ) => { - const { data, createVisEmbeddableFromObject, savedObjects, spaces } = visualizeServices; + const { data, createVisEmbeddableFromObject, savedObjects, spaces, savedObjectsTagging } = + visualizeServices; let savedSearch: SavedSearch | undefined; @@ -47,6 +48,7 @@ const createVisualizeEmbeddableAndLinkSavedSearch = async ( search: data.search, savedObjectsClient: savedObjects.client, spaces, + savedObjectsTagging, }); } catch (e) { // skip this catch block diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index ab453fc2378ed..da301a7d5d82d 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -49,7 +49,11 @@ export class DiscoverPageObject extends FtrService { await fieldSearch.clearValue(); } - public async saveSearch(searchName: string, saveAsNew?: boolean) { + public async saveSearch( + searchName: string, + saveAsNew?: boolean, + options: { tags: string[] } = { tags: [] } + ) { await this.clickSaveSearchButton(); // preventing an occasional flakiness when the saved object wasn't set and the form can't be submitted await this.retry.waitFor( @@ -61,6 +65,14 @@ export class DiscoverPageObject extends FtrService { } ); + if (options.tags.length) { + await this.testSubjects.click('savedObjectTagSelector'); + for (const tagName of options.tags) { + await this.testSubjects.click(`tagSelectorOption-${tagName.replace(' ', '_')}`); + } + await this.testSubjects.click('savedObjectTitle'); + } + if (saveAsNew !== undefined) { await this.retry.waitFor(`save as new switch is set`, async () => { await this.testSubjects.setEuiSwitch('saveAsNewCheckbox', saveAsNew ? 'check' : 'uncheck'); diff --git a/tsconfig.base.json b/tsconfig.base.json index 22aeada1359fa..e5decc27e2e39 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -139,6 +139,8 @@ "@kbn/newsfeed-plugin/*": ["src/plugins/newsfeed/*"], "@kbn/presentation-util-plugin": ["src/plugins/presentation_util"], "@kbn/presentation-util-plugin/*": ["src/plugins/presentation_util/*"], + "@kbn/saved-objects-finder-plugin": ["src/plugins/saved_objects_finder"], + "@kbn/saved-objects-finder-plugin/*": ["src/plugins/saved_objects_finder/*"], "@kbn/saved-objects-management-plugin": ["src/plugins/saved_objects_management"], "@kbn/saved-objects-management-plugin/*": ["src/plugins/saved_objects_management/*"], "@kbn/saved-objects-tagging-oss-plugin": ["src/plugins/saved_objects_tagging_oss"], diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts index 4d3a4e8557a68..33f674d226e89 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/tag_assets.test.ts @@ -92,6 +92,7 @@ describe('tagKibanaAssets', () => { const kibanaAssets = { dashboard: [{ id: 'dashboard1', type: 'dashboard' }], search: [{ id: 's1', type: 'search' }], + config: [{ id: 'c1', type: 'config' }], visualization: [{ id: 'v1', type: 'visualization' }], } as any; @@ -105,14 +106,14 @@ describe('tagKibanaAssets', () => { expect(savedObjectTagAssignmentService.updateTagAssignments).toHaveBeenCalledWith({ tags: ['managed', 'system'], - assign: [...kibanaAssets.dashboard, ...kibanaAssets.visualization], + assign: [...kibanaAssets.dashboard, ...kibanaAssets.search, ...kibanaAssets.visualization], unassign: [], refresh: false, }); }); it('should do nothing if no taggable assets', async () => { - const kibanaAssets = { search: [{ id: 's1', type: 'search' }] } as any; + const kibanaAssets = { config: [{ id: 'c1', type: 'config' }] } as any; await tagKibanaAssets({ savedObjectTagAssignmentService, diff --git a/x-pack/plugins/saved_objects_tagging/common/constants.ts b/x-pack/plugins/saved_objects_tagging/common/constants.ts index 7a49b3da64f9a..acd9800e03ce5 100644 --- a/x-pack/plugins/saved_objects_tagging/common/constants.ts +++ b/x-pack/plugins/saved_objects_tagging/common/constants.ts @@ -20,4 +20,4 @@ export const tagManagementSectionId = 'tags'; /** * The list of saved object types that are currently supporting tagging. */ -export const taggableTypes = ['dashboard', 'visualization', 'map', 'lens']; +export const taggableTypes = ['dashboard', 'visualization', 'map', 'lens', 'search']; diff --git a/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts b/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts index a05efd957e695..5f0c23a54f9e5 100644 --- a/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts +++ b/x-pack/plugins/saved_objects_tagging/server/usage/schema.ts @@ -22,5 +22,6 @@ export const tagUsageCollectorSchema: MakeSchemaFrom = { lens: perTypeSchema, visualization: perTypeSchema, map: perTypeSchema, + search: perTypeSchema, }, }; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index e31e2c0dd8257..95b80a252130c 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -9080,6 +9080,16 @@ "type": "integer" } } + }, + "search": { + "properties": { + "usedTags": { + "type": "integer" + }, + "taggedObjects": { + "type": "integer" + } + } } } } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 09d1599ec661d..45b2459bc185f 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -4447,12 +4447,6 @@ "savedObjects.advancedSettings.perPageTitle": "Objets par page", "savedObjects.confirmModal.cancelButtonLabel": "Annuler", "savedObjects.confirmModal.overwriteButtonLabel": "Écraser", - "savedObjects.finder.filterButtonLabel": "Types", - "savedObjects.finder.searchPlaceholder": "Rechercher…", - "savedObjects.finder.sortAsc": "Croissant", - "savedObjects.finder.sortAuto": "Meilleure correspondance", - "savedObjects.finder.sortButtonLabel": "Trier", - "savedObjects.finder.sortDesc": "Décroissant", "savedObjects.overwriteRejectedDescription": "La confirmation d'écrasement a été rejetée.", "savedObjects.saveDuplicateRejectedDescription": "La confirmation d'enregistrement avec un doublon de titre a été rejetée.", "savedObjects.saveModal.cancelButtonLabel": "Annuler", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 16ea9c49f15b1..fc33f830e164b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4445,12 +4445,6 @@ "savedObjects.advancedSettings.perPageTitle": "ページごとのオブジェクト数", "savedObjects.confirmModal.cancelButtonLabel": "キャンセル", "savedObjects.confirmModal.overwriteButtonLabel": "上書き", - "savedObjects.finder.filterButtonLabel": "タイプ", - "savedObjects.finder.searchPlaceholder": "検索…", - "savedObjects.finder.sortAsc": "昇順", - "savedObjects.finder.sortAuto": "ベストマッチ", - "savedObjects.finder.sortButtonLabel": "並べ替え", - "savedObjects.finder.sortDesc": "降順", "savedObjects.overwriteRejectedDescription": "上書き確認が拒否されました", "savedObjects.saveDuplicateRejectedDescription": "重複ファイルの保存確認が拒否されました", "savedObjects.saveModal.cancelButtonLabel": "キャンセル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index db9ac93a36076..2941cf0c2e58d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4450,12 +4450,6 @@ "savedObjects.advancedSettings.perPageTitle": "每页对象数", "savedObjects.confirmModal.cancelButtonLabel": "取消", "savedObjects.confirmModal.overwriteButtonLabel": "覆盖", - "savedObjects.finder.filterButtonLabel": "类型", - "savedObjects.finder.searchPlaceholder": "搜索……", - "savedObjects.finder.sortAsc": "升序", - "savedObjects.finder.sortAuto": "最佳匹配", - "savedObjects.finder.sortButtonLabel": "排序", - "savedObjects.finder.sortDesc": "降序", "savedObjects.overwriteRejectedDescription": "已拒绝覆盖确认", "savedObjects.saveDuplicateRejectedDescription": "已拒绝使用重复标题保存确认", "savedObjects.saveModal.cancelButtonLabel": "取消", diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts index 3bdbd06cf7f0d..a9825fecf783e 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts @@ -145,8 +145,8 @@ export default function (providerContext: FtrProviderContext) { const { body } = await supertest .get(`/internal/saved_objects_tagging/tags/_find?page=1&perPage=10000`) .expect(200); - expect(body.tags.find((tag: any) => tag.name === 'Managed').relationCount).to.be(6); - expect(body.tags.find((tag: any) => tag.name === 'For File Tests').relationCount).to.be(6); + expect(body.tags.find((tag: any) => tag.name === 'Managed').relationCount).to.be(9); + expect(body.tags.find((tag: any) => tag.name === 'For File Tests').relationCount).to.be(9); }); it('should return a 400 with an empty namespace', async function () { diff --git a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_get_assignable_types.ts b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_get_assignable_types.ts index 63a204cbdbb00..673ae6f73fac2 100644 --- a/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_get_assignable_types.ts +++ b/x-pack/test/saved_object_tagging/api_integration/security_and_spaces/apis/_get_assignable_types.ts @@ -32,7 +32,7 @@ export default function (ftrContext: FtrProviderContext) { }); const assignablePerUser = { - [USERS.SUPERUSER.username]: ['dashboard', 'visualization', 'map', 'lens'], + [USERS.SUPERUSER.username]: ['dashboard', 'visualization', 'map', 'lens', 'search'], [USERS.DEFAULT_SPACE_SO_TAGGING_READ_USER.username]: [], [USERS.DEFAULT_SPACE_READ_USER.username]: [], [USERS.DEFAULT_SPACE_ADVANCED_SETTINGS_READ_USER.username]: [], diff --git a/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/discover/data.json b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/discover/data.json new file mode 100644 index 0000000000000..bd3b305bc7d40 --- /dev/null +++ b/x-pack/test/saved_object_tagging/common/fixtures/es_archiver/discover/data.json @@ -0,0 +1,182 @@ +{ + "id": "tag-1", + "type": "tag", + "attributes": { + "name": "tag-1", + "description": "My first tag!", + "color": "#FF00FF" + }, + "references": [], + "updated_at": "2021-06-17T18:57:58.076Z" +} + +{ + "id": "tag-2", + "type": "tag", + "attributes": { + "name": "tag-2", + "description": "Another awesome tag", + "color": "#123456" + }, + "references": [], + "updated_at": "2021-06-17T18:57:58.076Z" +} + +{ + "id": "tag-3", + "type": "tag", + "attributes": { + "name": "tag-3", + "description": "Last but not least", + "color": "#000000" + }, + "references": [], + "updated_at": "2021-06-17T18:57:58.076Z" +} + +{ + "attributes": { + "fieldAttrs": "{\"referer\":{\"customLabel\":\"Referer custom\"}}", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"@message\"}}},{\"name\":\"@tags\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"@tags\"}}},{\"name\":\"@timestamp\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"agent\"}}},{\"name\":\"bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"extension\"}}},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"headings\"}}},{\"name\":\"host\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"host\"}}},{\"name\":\"id\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"index\"}}},{\"name\":\"ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"links\"}}},{\"name\":\"machine.os\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"machine.os\"}}},{\"name\":\"machine.ram\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"esTypes\":[\"double\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"nestedField.child\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"nested\":{\"path\":\"nestedField\"}}},{\"name\":\"phpmemory\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.article:section\"}}},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.article:tag\"}}},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:description\"}}},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:image\"}}},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:image:height\"}}},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:image:width\"}}},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:site_name\"}}},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:title\"}}},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:type\"}}},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:url\"}}},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:card\"}}},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:description\"}}},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:image\"}}},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:site\"}}},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:title\"}}},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.url\"}}},{\"name\":\"request\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"request\"}}},{\"name\":\"response\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"response\"}}},{\"name\":\"spaces\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"spaces\"}}},{\"name\":\"type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"url\"}}},{\"name\":\"utc_time\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"xss\"}}}]", + "timeFieldName": "@timestamp", + "title": "logstash-*" + }, + "coreMigrationVersion": "8.0.0", + "id": "logstash-*", + "migrationVersion": { + "index-pattern": "7.11.0" + }, + "references": [], + "type": "index-pattern", + "version": "WzQsMl0=" +} + +{ + "attributes": { + "columns": [ + "_source" + ], + "description": "A Saved Search Description", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"filter\":[],\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "@timestamp", + "desc" + ] + ], + "title": "A Saved Search", + "version": 1 + }, + "coreMigrationVersion": "8.0.0", + "id": "ab12e3c0-f231-11e6-9486-733b1ac9221a", + "migrationVersion": { + "search": "7.9.3" + }, + "references": [ + { + "id": "logstash-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "tag-1", + "name": "tag-1-ref", + "type": "tag" + }, + { + "id": "tag-2", + "name": "tag-2-ref", + "type": "tag" + } + ], + "type": "search", + "version": "WzUsMl0=" +} + +{ + "attributes": { + "columns": [ + "_source" + ], + "description": "A Different Saved Search Description", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"filter\":[],\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "@timestamp", + "desc" + ] + ], + "title": "A Different Saved Search", + "version": 1 + }, + "coreMigrationVersion": "8.0.0", + "id": "ab12e3c0-f231-11e6-9486-733b1ac9221b", + "migrationVersion": { + "search": "7.9.3" + }, + "references": [ + { + "id": "logstash-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "tag-2", + "name": "tag-2-ref", + "type": "tag" + }, + { + "id": "tag-3", + "name": "tag-3-ref", + "type": "tag" + } + ], + "type": "search", + "version": "WzUsMl0=" +} + +{ + "attributes": { + "columns": [ + "_source" + ], + "description": "An Untagged Saved Search Description", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"filter\":[],\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "@timestamp", + "desc" + ] + ], + "title": "A Third Saved Search", + "version": 1 + }, + "coreMigrationVersion": "8.0.0", + "id": "ab12e3c0-f231-11e6-9486-733b1ac9221c", + "migrationVersion": { + "search": "7.9.3" + }, + "references": [ + { + "id": "logstash-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + }, + { + "id": "tag-3", + "name": "tag-3-ref", + "type": "tag" + } + ], + "type": "search", + "version": "WzUsMl0=" +} \ No newline at end of file diff --git a/x-pack/test/saved_object_tagging/functional/tests/discover_integration.ts b/x-pack/test/saved_object_tagging/functional/tests/discover_integration.ts new file mode 100644 index 0000000000000..b2772cec74ea1 --- /dev/null +++ b/x-pack/test/saved_object_tagging/functional/tests/discover_integration.ts @@ -0,0 +1,168 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects([ + 'tagManagement', + 'common', + 'header', + 'timePicker', + 'discover', + ]); + + /** + * Select tags in the searchbar's tag filter. + */ + const selectFilterTags = async (...tagNames: string[]) => { + // open the filter dropdown + const filterButton = await testSubjects + .find('loadSearchForm') + .then((wrapper) => wrapper.findByCssSelector('.euiFilterGroup .euiFilterButton')); + await filterButton.click(); + // select the tags + for (const tagName of tagNames) { + await testSubjects.click( + `tag-searchbar-option-${PageObjects.tagManagement.testSubjFriendly(tagName)}` + ); + } + // click elsewhere to close the filter dropdown + const searchFilter = await testSubjects.find('savedObjectFinderSearchInput'); + await searchFilter.click(); + }; + + const expectSavedSearches = async (...savedSearchTitles: string[]) => { + await testSubjects.retry.try(async () => { + const searchTitleWrappers = await testSubjects.findAll('savedObjectFinderTitle'); + const searchTitles = await Promise.all( + searchTitleWrappers.map((entry) => entry.getVisibleText()) + ); + expect(searchTitles).to.eql(savedSearchTitles); + }); + }; + + describe('discover integration', () => { + before(async () => { + await kibanaServer.importExport.load( + 'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/discover/data.json' + ); + await esArchiver.loadIfNeeded( + 'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/logstash_functional' + ); + }); + after(async () => { + await kibanaServer.importExport.unload( + 'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/discover/data.json' + ); + await kibanaServer.savedObjects.clean({ types: ['tag'] }); + await esArchiver.unload( + 'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/logstash_functional' + ); + }); + + describe('open search', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + it('allows to manually type tag filter query', async () => { + await PageObjects.discover.openLoadSavedSearchPanel(); + await testSubjects.setValue('savedObjectFinderSearchInput', 'tag:(tag-1)'); + await expectSavedSearches('A Saved Search'); + }); + + it('allows to filter by selecting a tag in the filter menu', async () => { + await PageObjects.discover.openLoadSavedSearchPanel(); + await selectFilterTags('tag-2'); + await expectSavedSearches('A Saved Search', 'A Different Saved Search'); + }); + + it('allows to filter by multiple tags', async () => { + await PageObjects.discover.openLoadSavedSearchPanel(); + await selectFilterTags('tag-2', 'tag-3'); + await expectSavedSearches( + 'A Saved Search', + 'A Different Saved Search', + 'A Third Saved Search' + ); + }); + }); + + describe('creating', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.selectIndexPattern('logstash-*'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + it('allows to select tags for a new saved search', async () => { + await PageObjects.discover.saveSearch('My New Search', undefined, { + tags: ['tag-1', 'tag-2'], + }); + await PageObjects.discover.openLoadSavedSearchPanel(); + await selectFilterTags('tag-1', 'tag-2'); + await expectSavedSearches('A Saved Search', 'A Different Saved Search', 'My New Search'); + }); + + it('allows to create a tag from the tag selector', async () => { + await PageObjects.discover.clickSaveSearchButton(); + await testSubjects.setValue('savedObjectTitle', 'search-with-new-tag'); + await testSubjects.click('savedObjectTagSelector'); + await testSubjects.click(`tagSelectorOption-action__create`); + const { tagModal } = PageObjects.tagManagement; + expect(await tagModal.isOpened()).to.be(true); + await tagModal.fillForm( + { + name: 'my-new-tag', + color: '#FFCC33', + description: '', + }, + { + submit: true, + } + ); + expect(await tagModal.isOpened()).to.be(false); + await testSubjects.click('confirmSaveSavedObjectButton'); + await PageObjects.common.waitForSaveModalToClose(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.openLoadSavedSearchPanel(); + await selectFilterTags('my-new-tag'); + await expectSavedSearches('search-with-new-tag'); + }); + }); + + describe('editing', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + it('allows to select tags for an existing saved search', async () => { + await PageObjects.discover.loadSavedSearch('A Saved Search'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.saveSearch('A Saved Search', undefined, { + tags: ['tag-3'], + }); + await PageObjects.discover.openLoadSavedSearchPanel(); + await selectFilterTags('tag-3'); + await expectSavedSearches( + 'A Different Saved Search', + 'A Third Saved Search', + 'A Saved Search' + ); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_tagging/functional/tests/index.ts b/x-pack/test/saved_object_tagging/functional/tests/index.ts index 2d79d0a7a45ec..909207589e04e 100644 --- a/x-pack/test/saved_object_tagging/functional/tests/index.ts +++ b/x-pack/test/saved_object_tagging/functional/tests/index.ts @@ -25,5 +25,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { loadTestFile(require.resolve('./dashboard_integration')); loadTestFile(require.resolve('./feature_control')); loadTestFile(require.resolve('./maps_integration')); + loadTestFile(require.resolve('./discover_integration')); }); }