From 509248b0c6a4f4c7a7f658eab5e69254bc1bd9b5 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Mon, 12 Feb 2024 13:18:17 -0400 Subject: [PATCH] [Saved Queries] Improve saved query management (#170599) ## Summary This PR introduces a number of changes and improvements to saved query management: - Add server side pagination (5 queries per page) and search functionality to the "Load query" list, which improves UX and performance by no longer requesting all queries at once. - Redesign the "Load query" list to improve the UX and a11y, making it possible for keyboard users to effectively navigate the list and load/delete queries. - Add an "Active" badge to the "Load query" list to indicate which list entry represents the currently loaded query, and hoist the entry to the top of the first page for better visibility when no search term exists. - Deprecate the saved query `/_all` endpoint and update it to return only the first 100 queries instead of loading them all into memory at once. - Add a new `titleKeyword` field to the saved query SO, which allows sorting queries alphabetically by title when displaying them in the "Load query" list. - Improve the performance of the "has saved queries" check when Unified Search is mounted to no longer request actual queries, and instead just request the count. - Update the saved query duplicate title check to no longer rely on fetching all queries at once, and instead asynchronously check for duplicates by title on save. - Add server side duplicate title validation to the create and update saved query endpoints. - Various small fixes and cleanups throughout saved query management. https://github.com/elastic/kibana/assets/25592674/43328aea-0f7b-4b7a-a5fb-e33ed822f317 Resolves #172044. Resolves #176427. ## Testing To generate saved queries for testing, run the script below and replace `{KIBANA_REQUEST_COOKIE}` with the cookie header value from an API request of a Kibana user with an active session: ```shell for i in {1..100}; do curl 'http://localhost:5601/internal/saved_query/_create' \ -H 'Accept: */*' \ -H 'Accept-Language: en-US,en;q=0.9,az;q=0.8,es;q=0.7' \ -H 'Cache-Control: no-cache' \ -H 'Connection: keep-alive' \ -H 'Content-Type: application/json' \ -H 'Cookie: {KIBANA_REQUEST_COOKIE}' \ -H 'elastic-api-version: 1' \ -H 'kbn-build-number: 9007199254740991' \ -H 'kbn-version: 8.13.0' \ -H 'x-elastic-internal-origin: Kibana' \ --data-raw '{"title":"query '"$(echo $(($i - 1)) | tr '[0-9]' '[a-j]')"'","description":"","query":{"query":"bytes > 500","language":"kuery"},"filters":[]}' \ --compressed; done ``` ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Stratoula Kalafateli --- .../current_fields.json | 3 +- .../current_mappings.json | 3 + .../check_registered_types.test.ts | 2 +- src/plugins/data/public/query/mocks.ts | 2 +- .../saved_query/saved_query_service.test.ts | 27 +- .../query/saved_query/saved_query_service.ts | 22 +- .../data/public/query/saved_query/types.ts | 2 +- .../query/route_handler_context.test.ts | 116 ++- .../server/query/route_handler_context.ts | 178 +++-- src/plugins/data/server/query/route_types.ts | 2 +- src/plugins/data/server/query/routes.ts | 69 +- .../data/server/saved_objects/query.test.ts | 60 ++ .../data/server/saved_objects/query.ts | 38 +- .../server/saved_objects/schemas/query.ts | 40 +- src/plugins/data/tsconfig.json | 3 +- .../add_filter_popover.styles.ts | 20 - .../query_string_input/add_filter_popover.tsx | 5 +- .../public/query_string_input/panel_title.tsx | 97 +++ .../query_bar_menu.test.tsx | 1 + .../query_string_input/query_bar_menu.tsx | 112 +-- .../query_bar_menu_panels.tsx | 144 ++-- .../saved_query_form/save_query_form.tsx | 77 +- .../saved_query_management_list.scss | 4 - .../saved_query_management_list.test.tsx | 626 ++++++++++++++--- .../saved_query_management_list.tsx | 662 ++++++++++++------ .../public/search_bar/search_bar.test.tsx | 2 + .../public/search_bar/search_bar.tsx | 76 +- src/plugins/unified_search/public/types.ts | 3 + src/plugins/unified_search/tsconfig.json | 1 + test/accessibility/apps/discover.ts | 8 +- .../apis/saved_queries/{index.js => index.ts} | 4 +- .../apis/saved_queries/saved_queries.js | 154 ---- .../apis/saved_queries/saved_queries.ts | 426 +++++++++++ test/functional/page_objects/discover_page.ts | 10 +- .../saved_query_management_component.ts | 10 +- .../lens/public/app_plugin/lens_top_nav.tsx | 9 +- .../common/lib/kibana/kibana_react.mock.ts | 17 +- .../public/mocks/test_providers.tsx | 17 +- .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../cypress/tasks/api_calls/saved_queries.ts | 2 +- 42 files changed, 2204 insertions(+), 859 deletions(-) create mode 100644 src/plugins/data/server/saved_objects/query.test.ts delete mode 100644 src/plugins/unified_search/public/query_string_input/add_filter_popover.styles.ts create mode 100644 src/plugins/unified_search/public/query_string_input/panel_title.tsx rename test/api_integration/apis/saved_queries/{index.js => index.ts} (77%) delete mode 100644 test/api_integration/apis/saved_queries/saved_queries.js create mode 100644 test/api_integration/apis/saved_queries/saved_queries.ts diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 4d8c775710f09..01a7de459affb 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -716,7 +716,8 @@ ], "query": [ "description", - "title" + "title", + "titleKeyword" ], "risk-engine-configuration": [ "dataViewId", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 758fde639d00f..aaf612ed8ed56 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -2403,6 +2403,9 @@ }, "title": { "type": "text" + }, + "titleKeyword": { + "type": "keyword" } } }, diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index e99ca235bfad4..5f1c48c0391ff 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -130,7 +130,7 @@ describe('checking migration metadata changes on all registered SO types', () => "osquery-pack-asset": "cd140bc2e4b092e93692b587bf6e38051ef94c75", "osquery-saved-query": "6095e288750aa3164dfe186c74bc5195c2bf2bd4", "policy-settings-protection-updates-note": "33924bb246f9e5bcb876109cc83e3c7a28308352", - "query": "21cbbaa09abb679078145ce90087b1e88b7eae95", + "query": "501bece68f26fe561286a488eabb1a8ab12f1137", "risk-engine-configuration": "b105d4a3c6adce40708d729d12e5ef3c8fbd9508", "rules-settings": "892a2918ebaeba809a612b8d97cec0b07c800b5f", "sample-data-telemetry": "37441b12f5b0159c2d6d5138a494c9f440e950b5", diff --git a/src/plugins/data/public/query/mocks.ts b/src/plugins/data/public/query/mocks.ts index 14de815c0d793..74dd77c904165 100644 --- a/src/plugins/data/public/query/mocks.ts +++ b/src/plugins/data/public/query/mocks.ts @@ -38,7 +38,7 @@ const createStartContractMock = () => { addToQueryLog: jest.fn(), filterManager: createFilterManagerMock(), queryString: queryStringManagerMock.createStartContract(), - savedQueries: { getSavedQuery: jest.fn() } as any, + savedQueries: { getSavedQuery: jest.fn(), getSavedQueryCount: jest.fn() } as any, state$: new Observable(), getState: jest.fn(), timefilter: timefilterServiceMock.createStartContract(), diff --git a/src/plugins/data/public/query/saved_query/saved_query_service.test.ts b/src/plugins/data/public/query/saved_query/saved_query_service.test.ts index 3a223109dbd76..07b341fb3eaa5 100644 --- a/src/plugins/data/public/query/saved_query/saved_query_service.test.ts +++ b/src/plugins/data/public/query/saved_query/saved_query_service.test.ts @@ -14,12 +14,12 @@ import { SAVED_QUERY_BASE_URL } from '../../../common/constants'; const http = httpServiceMock.createStartContract(); const { + isDuplicateTitle, deleteSavedQuery, getSavedQuery, findSavedQueries, createQuery, updateQuery, - getAllSavedQueries, getSavedQueryCount, } = createSavedQueryService(http); @@ -42,6 +42,18 @@ describe('saved query service', () => { http.delete.mockReset(); }); + describe('isDuplicateTitle', function () { + it('should post the title and ID', async () => { + http.post.mockResolvedValue({ isDuplicate: true }); + await isDuplicateTitle('foo', 'bar'); + expect(http.post).toBeCalled(); + expect(http.post).toHaveBeenCalledWith(`${SAVED_QUERY_BASE_URL}/_is_duplicate_title`, { + body: '{"title":"foo","id":"bar"}', + version, + }); + }); + }); + describe('createQuery', function () { it('should post the stringified given attributes', async () => { await createQuery(savedQueryAttributes); @@ -64,19 +76,6 @@ describe('saved query service', () => { }); }); - describe('getAllSavedQueries', function () { - it('should post and extract the saved queries from the response', async () => { - http.post.mockResolvedValue({ - total: 0, - savedQueries: [{ attributes: savedQueryAttributes }], - }); - const result = await getAllSavedQueries(); - expect(http.post).toBeCalled(); - expect(http.post).toHaveBeenCalledWith(`${SAVED_QUERY_BASE_URL}/_all`, { version }); - expect(result).toEqual([{ attributes: savedQueryAttributes }]); - }); - }); - describe('findSavedQueries', function () { it('should post and return the total & saved queries', async () => { http.post.mockResolvedValue({ diff --git a/src/plugins/data/public/query/saved_query/saved_query_service.ts b/src/plugins/data/public/query/saved_query/saved_query_service.ts index 09afd75470dd0..e3847b357bdee 100644 --- a/src/plugins/data/public/query/saved_query/saved_query_service.ts +++ b/src/plugins/data/public/query/saved_query/saved_query_service.ts @@ -14,6 +14,17 @@ import { SAVED_QUERY_BASE_URL } from '../../../common/constants'; const version = '1'; export const createSavedQueryService = (http: HttpStart) => { + const isDuplicateTitle = async (title: string, id?: string) => { + const response = await http.post<{ isDuplicate: boolean }>( + `${SAVED_QUERY_BASE_URL}/_is_duplicate_title`, + { + body: JSON.stringify({ title, id }), + version, + } + ); + return response.isDuplicate; + }; + const createQuery = async (attributes: SavedQueryAttributes, { overwrite = false } = {}) => { const savedQuery = await http.post(`${SAVED_QUERY_BASE_URL}/_create`, { body: JSON.stringify(attributes), @@ -30,15 +41,6 @@ export const createSavedQueryService = (http: HttpStart) => { return savedQuery; }; - // we have to tell the saved objects client how many to fetch, otherwise it defaults to fetching 20 per page - const getAllSavedQueries = async (): Promise => { - const { savedQueries } = await http.post<{ savedQueries: SavedQuery[] }>( - `${SAVED_QUERY_BASE_URL}/_all`, - { version } - ); - return savedQueries; - }; - // findSavedQueries will do a 'match_all' if no search string is passed in const findSavedQueries = async ( search: string = '', @@ -69,9 +71,9 @@ export const createSavedQueryService = (http: HttpStart) => { }; return { + isDuplicateTitle, createQuery, updateQuery, - getAllSavedQueries, findSavedQueries, getSavedQuery, deleteSavedQuery, diff --git a/src/plugins/data/public/query/saved_query/types.ts b/src/plugins/data/public/query/saved_query/types.ts index 7b6b7408b4369..984ca9e804b01 100644 --- a/src/plugins/data/public/query/saved_query/types.ts +++ b/src/plugins/data/public/query/saved_query/types.ts @@ -17,9 +17,9 @@ export type SavedQueryTimeFilter = TimeRange & { export type { SavedQuery, SavedQueryAttributes }; export interface SavedQueryService { + isDuplicateTitle: (title: string, id?: string) => Promise; createQuery: (attributes: SavedQueryAttributes) => Promise; updateQuery: (id: string, attributes: SavedQueryAttributes) => Promise; - getAllSavedQueries: () => Promise; findSavedQueries: ( searchText?: string, perPage?: number, diff --git a/src/plugins/data/server/query/route_handler_context.test.ts b/src/plugins/data/server/query/route_handler_context.test.ts index 08052944cb283..5976db550f182 100644 --- a/src/plugins/data/server/query/route_handler_context.test.ts +++ b/src/plugins/data/server/query/route_handler_context.test.ts @@ -10,7 +10,10 @@ import { coreMock } from '@kbn/core/server/mocks'; import { FilterStateStore, Query } from '@kbn/es-query'; import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../common'; import type { SavedObject, SavedQueryAttributes } from '../../common'; -import { registerSavedQueryRouteHandlerContext } from './route_handler_context'; +import { + InternalSavedQueryAttributes, + registerSavedQueryRouteHandlerContext, +} from './route_handler_context'; import { SavedObjectsFindResponse, SavedObjectsUpdateResponse } from '@kbn/core/server'; const mockContext = { @@ -31,6 +34,10 @@ const savedQueryAttributes: SavedQueryAttributes = { }, filters: [], }; +const internalSavedQueryAttributes: InternalSavedQueryAttributes = { + ...savedQueryAttributes, + titleKeyword: 'foo', +}; const savedQueryAttributesBar: SavedQueryAttributes = { title: 'bar', description: 'baz', @@ -90,19 +97,29 @@ describe('saved query route handler context', () => { describe('create', function () { it('should create a saved object for the given attributes', async () => { - const mockResponse: SavedObject = { + const mockResponse: SavedObject = { id: 'foo', type: 'query', - attributes: savedQueryAttributes, + attributes: internalSavedQueryAttributes, references: [], }; + mockSavedObjectsClient.find.mockResolvedValue({ + total: 0, + page: 0, + per_page: 0, + saved_objects: [], + }); mockSavedObjectsClient.create.mockResolvedValue(mockResponse); const response = await context.create(savedQueryAttributes); - expect(mockSavedObjectsClient.create).toHaveBeenCalledWith('query', savedQueryAttributes, { - references: [], - }); + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith( + 'query', + { ...internalSavedQueryAttributes, timefilter: null }, + { + references: [], + } + ); expect(response).toEqual({ id: 'foo', attributes: savedQueryAttributes, @@ -117,17 +134,29 @@ describe('saved query route handler context', () => { query: { match_all: {} }, }, }; - const mockResponse: SavedObject = { + const mockResponse: SavedObject = { id: 'foo', type: 'query', - attributes: savedQueryAttributesWithQueryObject, + attributes: { + ...savedQueryAttributesWithQueryObject, + titleKeyword: 'foo', + }, references: [], }; + mockSavedObjectsClient.find.mockResolvedValue({ + total: 0, + page: 0, + per_page: 0, + saved_objects: [], + }); mockSavedObjectsClient.create.mockResolvedValue(mockResponse); - const { attributes } = await context.create(savedQueryAttributesWithQueryObject); + const result = await context.create(savedQueryAttributesWithQueryObject); - expect(attributes).toEqual(savedQueryAttributesWithQueryObject); + expect(result).toEqual({ + id: 'foo', + attributes: savedQueryAttributesWithQueryObject, + }); }); it('should optionally accept filters and timefilters in object format', async () => { @@ -136,12 +165,21 @@ describe('saved query route handler context', () => { filters: savedQueryAttributesWithFilters.filters, timefilter: savedQueryAttributesWithFilters.timefilter, }; - const mockResponse: SavedObject = { + const mockResponse: SavedObject = { id: 'foo', type: 'query', - attributes: serializedSavedQueryAttributesWithFilters, + attributes: { + ...serializedSavedQueryAttributesWithFilters, + titleKeyword: 'foo', + }, references: [], }; + mockSavedObjectsClient.find.mockResolvedValue({ + total: 0, + page: 0, + per_page: 0, + saved_objects: [], + }); mockSavedObjectsClient.create.mockResolvedValue(mockResponse); await context.create(savedQueryAttributesWithFilters); @@ -154,6 +192,12 @@ describe('saved query route handler context', () => { }); it('should throw an error when saved objects client returns error', async () => { + mockSavedObjectsClient.find.mockResolvedValue({ + total: 0, + page: 0, + per_page: 0, + saved_objects: [], + }); mockSavedObjectsClient.create.mockResolvedValue({ error: { error: '123', @@ -169,19 +213,25 @@ describe('saved query route handler context', () => { it('should throw an error if the saved query does not have a title', async () => { const response = context.create({ ...savedQueryAttributes, title: '' }); expect(response).rejects.toMatchInlineSnapshot( - `[Error: Cannot create saved query without a title]` + `[Error: Cannot create query without a title]` ); }); }); describe('update', function () { it('should update a saved object for the given attributes', async () => { - const mockResponse: SavedObject = { + const mockResponse: SavedObject = { id: 'foo', type: 'query', - attributes: savedQueryAttributes, + attributes: internalSavedQueryAttributes, references: [], }; + mockSavedObjectsClient.find.mockResolvedValue({ + total: 0, + page: 0, + per_page: 0, + saved_objects: [], + }); mockSavedObjectsClient.update.mockResolvedValue(mockResponse); const response = await context.update('foo', savedQueryAttributes); @@ -189,7 +239,7 @@ describe('saved query route handler context', () => { expect(mockSavedObjectsClient.update).toHaveBeenCalledWith( 'query', 'foo', - savedQueryAttributes, + { ...internalSavedQueryAttributes, timefilter: null }, { references: [], } @@ -201,6 +251,12 @@ describe('saved query route handler context', () => { }); it('should throw an error when saved objects client returns error', async () => { + mockSavedObjectsClient.find.mockResolvedValue({ + total: 0, + page: 0, + per_page: 0, + saved_objects: [], + }); mockSavedObjectsClient.update.mockResolvedValue({ error: { error: '123', @@ -216,7 +272,7 @@ describe('saved query route handler context', () => { it('should throw an error if the saved query does not have a title', async () => { const response = context.create({ ...savedQueryAttributes, title: '' }); expect(response).rejects.toMatchInlineSnapshot( - `[Error: Cannot create saved query without a title]` + `[Error: Cannot create query without a title]` ); }); }); @@ -241,6 +297,13 @@ describe('saved query route handler context', () => { const response = await context.find(); + expect(mockSavedObjectsClient.find).toHaveBeenCalledWith({ + type: 'query', + page: 1, + perPage: 50, + sortField: 'titleKeyword', + sortOrder: 'asc', + }); expect(response.savedQueries).toEqual([{ id: 'foo', attributes: savedQueryAttributes }]); }); @@ -271,13 +334,15 @@ describe('saved query route handler context', () => { }; mockSavedObjectsClient.find.mockResolvedValue(mockResponse); - const response = await context.find({ search: 'foo' }); + const response = await context.find({ search: 'Foo < And > Bar' }); expect(mockSavedObjectsClient.find).toHaveBeenCalledWith({ + type: 'query', page: 1, perPage: 50, - search: 'foo', - type: 'query', + filter: 'query.attributes.title:(*Foo AND \\And AND Bar*)', + sortField: 'titleKeyword', + sortOrder: 'asc', }); expect(response.savedQueries).toEqual([{ id: 'foo', attributes: savedQueryAttributes }]); }); @@ -360,7 +425,8 @@ describe('saved query route handler context', () => { expect(mockSavedObjectsClient.find).toHaveBeenCalledWith({ page: 1, perPage: 2, - search: '', + sortField: 'titleKeyword', + sortOrder: 'asc', type: 'query', }); expect(response.savedQueries).toEqual( @@ -378,7 +444,6 @@ describe('saved query route handler context', () => { attributes: { description: 'baz', query: { language: 'kuery', query: 'response:200' }, - filters: [], title: 'bar', }, id: 'bar', @@ -529,7 +594,7 @@ describe('saved query route handler context', () => { }); const response = await context.get('food'); - expect(response.attributes.filters[0].meta.index).toBe('my-new-index'); + expect(response.attributes.filters?.[0].meta.index).toBe('my-new-index'); }); it('should throw if conflict', async () => { @@ -568,6 +633,11 @@ describe('saved query route handler context', () => { const response = await context.count(); + expect(mockSavedObjectsClient.find).toHaveBeenCalledWith({ + type: 'query', + page: 0, + perPage: 0, + }); expect(response).toEqual(1); }); }); diff --git a/src/plugins/data/server/query/route_handler_context.ts b/src/plugins/data/server/query/route_handler_context.ts index fcca59ece59fd..7971ae5104a41 100644 --- a/src/plugins/data/server/query/route_handler_context.ts +++ b/src/plugins/data/server/query/route_handler_context.ts @@ -6,17 +6,51 @@ * Side Public License, v 1. */ -import { CustomRequestHandlerContext, RequestHandlerContext, SavedObject } from '@kbn/core/server'; -import { isFilters, isOfQueryType } from '@kbn/es-query'; +import { badRequest, internal, conflict } from '@hapi/boom'; +import type { + CustomRequestHandlerContext, + RequestHandlerContext, + SavedObject, +} from '@kbn/core/server'; +import { escapeKuery, escapeQuotes, isFilters, isOfQueryType } from '@kbn/es-query'; +import { omit } from 'lodash'; import { isQuery, SavedQueryAttributes } from '../../common'; import { extract, inject } from '../../common/query/filters/persistable_state'; +import type { SavedQueryRestResponse } from './route_types'; + +export interface InternalSavedQueryAttributes + extends Omit { + titleKeyword: string; + filters?: SavedQueryAttributes['filters'] | null; + timefilter?: SavedQueryAttributes['timefilter'] | null; +} function injectReferences({ id, - attributes, + attributes: internalAttributes, namespaces, references, -}: Pick, 'id' | 'attributes' | 'namespaces' | 'references'>) { +}: Pick< + SavedObject, + 'id' | 'attributes' | 'namespaces' | 'references' +>) { + const attributes: SavedQueryAttributes = omit( + internalAttributes, + 'titleKeyword', + 'filters', + 'timefilter' + ); + + // filters or timefilter can be null if previously removed in an update, + // which isn't valid for the client model, so we conditionally add them + if (internalAttributes.filters) { + attributes.filters = inject(internalAttributes.filters, references); + } + + if (internalAttributes.timefilter) { + attributes.timefilter = internalAttributes.timefilter; + } + const { query } = attributes; if (isOfQueryType(query) && typeof query.query === 'string') { try { @@ -26,18 +60,21 @@ function injectReferences({ // Just keep it as a string } } - const filters = inject(attributes.filters ?? [], references); - return { id, attributes: { ...attributes, filters }, namespaces }; + + return { id, attributes, namespaces }; } function extractReferences({ title, description, query, - filters = [], + filters, timefilter, }: SavedQueryAttributes) { - const { state: extractedFilters, references } = extract(filters); + const { state: extractedFilters, references } = filters + ? extract(filters) + : { state: undefined, references: [] }; + const isOfQueryTypeQuery = isOfQueryType(query); let queryString = ''; if (isOfQueryTypeQuery) { @@ -48,15 +85,19 @@ function extractReferences({ } } - const attributes: SavedQueryAttributes = { + const attributes: InternalSavedQueryAttributes = { title: title.trim(), + titleKeyword: title.trim(), description: description.trim(), query: { ...query, ...(queryString && { query: queryString }), }, - filters: extractedFilters, - ...(timefilter && { timefilter }), + // Pass null instead of undefined for filters and timefilter + // to ensure they are removed from the saved object on update + // since the saved objects client ignores undefined values + filters: extractedFilters ?? null, + timefilter: timefilter ?? null, }; return { attributes, references }; @@ -64,109 +105,134 @@ function extractReferences({ function verifySavedQuery({ title, query, filters = [] }: SavedQueryAttributes) { if (!isQuery(query)) { - throw new Error(`Invalid query: ${query}`); + throw badRequest(`Invalid query: ${JSON.stringify(query, null, 2)}`); } if (!isFilters(filters)) { - throw new Error(`Invalid filters: ${filters}`); + throw badRequest(`Invalid filters: ${JSON.stringify(filters, null, 2)}`); } if (!title.trim().length) { - throw new Error('Cannot create saved query without a title'); + throw badRequest('Cannot create query without a title'); } } export async function registerSavedQueryRouteHandlerContext(context: RequestHandlerContext) { const soClient = (await context.core).savedObjects.client; - const createSavedQuery = async (attrs: SavedQueryAttributes) => { + const isDuplicateTitle = async ({ title, id }: { title: string; id?: string }) => { + const preparedTitle = title.trim(); + const { saved_objects: savedQueries } = await soClient.find({ + type: 'query', + page: 1, + perPage: 1, + filter: `query.attributes.titleKeyword:"${escapeQuotes(preparedTitle)}"`, + }); + const existingQuery = savedQueries[0]; + + return Boolean( + existingQuery && + existingQuery.attributes.titleKeyword === preparedTitle && + (!id || existingQuery.id !== id) + ); + }; + + const validateSavedQueryTitle = async (title: string, id?: string) => { + if (await isDuplicateTitle({ title, id })) { + throw badRequest(`Query with title "${title.trim()}" already exists`); + } + }; + + const createSavedQuery = async (attrs: SavedQueryAttributes): Promise => { verifySavedQuery(attrs); - const { attributes, references } = extractReferences(attrs); + await validateSavedQueryTitle(attrs.title); - const savedObject = await soClient.create('query', attributes, { + const { attributes, references } = extractReferences(attrs); + const savedObject = await soClient.create('query', attributes, { references, }); // TODO: Handle properly - if (savedObject.error) throw new Error(savedObject.error.message); + if (savedObject.error) throw internal(savedObject.error.message); return injectReferences(savedObject); }; - const updateSavedQuery = async (id: string, attrs: SavedQueryAttributes) => { + const updateSavedQuery = async ( + id: string, + attrs: SavedQueryAttributes + ): Promise => { verifySavedQuery(attrs); - const { attributes, references } = extractReferences(attrs); + await validateSavedQueryTitle(attrs.title, id); - const savedObject = await soClient.update('query', id, attributes, { - references, - }); + const { attributes, references } = extractReferences(attrs); + const savedObject = await soClient.update( + 'query', + id, + attributes, + { + references, + } + ); // TODO: Handle properly - if (savedObject.error) throw new Error(savedObject.error.message); + if (savedObject.error) throw internal(savedObject.error.message); return injectReferences({ id, attributes, references }); }; - const getSavedQuery = async (id: string) => { - const { saved_object: savedObject, outcome } = await soClient.resolve( - 'query', - id - ); + const getSavedQuery = async (id: string): Promise => { + const { saved_object: savedObject, outcome } = + await soClient.resolve('query', id); if (outcome === 'conflict') { - throw new Error(`Multiple saved queries found with ID: ${id} (legacy URL alias conflict)`); + throw conflict(`Multiple saved queries found with ID: ${id} (legacy URL alias conflict)`); } else if (savedObject.error) { - throw new Error(savedObject.error.message); + throw internal(savedObject.error.message); } return injectReferences(savedObject); }; const getSavedQueriesCount = async () => { - const { total } = await soClient.find({ + const { total } = await soClient.find({ type: 'query', + page: 0, + perPage: 0, }); return total; }; - const findSavedQueries = async ({ page = 1, perPage = 50, search = '' } = {}) => { - const { total, saved_objects: savedObjects } = await soClient.find({ - type: 'query', - page, - perPage, - search, - }); + const findSavedQueries = async ({ page = 1, perPage = 50, search = '' } = {}): Promise<{ + total: number; + savedQueries: SavedQueryRestResponse[]; + }> => { + const cleanedSearch = search.replace(/\W/g, ' ').trim(); + const preparedSearch = escapeKuery(cleanedSearch).split(/\s+/).join(' AND '); + const { total, saved_objects: savedObjects } = + await soClient.find({ + type: 'query', + page, + perPage, + filter: preparedSearch.length ? `query.attributes.title:(*${preparedSearch}*)` : undefined, + sortField: 'titleKeyword', + sortOrder: 'asc', + }); const savedQueries = savedObjects.map(injectReferences); return { total, savedQueries }; }; - const getAllSavedQueries = async () => { - const finder = soClient.createPointInTimeFinder({ - type: 'query', - perPage: 100, - }); - - const savedObjects: Array> = []; - for await (const response of finder.find()) { - savedObjects.push(...(response.saved_objects ?? [])); - } - await finder.close(); - - const savedQueries = savedObjects.map(injectReferences); - return { total: savedQueries.length, savedQueries }; - }; - const deleteSavedQuery = async (id: string) => { return await soClient.delete('query', id, { force: true }); }; return { + isDuplicateTitle, create: createSavedQuery, update: updateSavedQuery, get: getSavedQuery, count: getSavedQueriesCount, find: findSavedQueries, - getAll: getAllSavedQueries, delete: deleteSavedQuery, }; } diff --git a/src/plugins/data/server/query/route_types.ts b/src/plugins/data/server/query/route_types.ts index 656d52dad9fa7..535eaeecbeefe 100644 --- a/src/plugins/data/server/query/route_types.ts +++ b/src/plugins/data/server/query/route_types.ts @@ -120,7 +120,7 @@ type SavedQueryTimeFilterRestResponse = TimeRangeRestResponse & { export interface SavedQueryRestResponse { id: string; attributes: { - filters: FilterRestResponse[]; + filters?: FilterRestResponse[]; title: string; description: string; query: QueryRestResponse; diff --git a/src/plugins/data/server/query/routes.ts b/src/plugins/data/server/query/routes.ts index 5ac18df37d544..5bed196b6373b 100644 --- a/src/plugins/data/server/query/routes.ts +++ b/src/plugins/data/server/query/routes.ts @@ -10,7 +10,6 @@ import { schema } from '@kbn/config-schema'; import { CoreSetup } from '@kbn/core/server'; import { reportServerError } from '@kbn/kibana-utils-plugin/server'; import { SavedQueryRouteHandlerContext } from './route_handler_context'; -import { SavedQueryRestResponse } from './route_types'; import { SAVED_QUERY_BASE_URL } from '../../common/constants'; const SAVED_QUERY_ID_CONFIG = schema.object({ @@ -39,6 +38,37 @@ const version = '1'; export function registerSavedQueryRoutes({ http }: CoreSetup): void { const router = http.createRouter(); + router.versioned.post({ path: `${SAVED_QUERY_BASE_URL}/_is_duplicate_title`, access }).addVersion( + { + version, + validate: { + request: { + body: schema.object({ + title: schema.string(), + id: schema.maybe(schema.string()), + }), + }, + response: { + 200: { + body: schema.object({ + isDuplicate: schema.boolean(), + }), + }, + }, + }, + }, + async (context, request, response) => { + try { + const savedQuery = await context.savedQuery; + const isDuplicate = await savedQuery.isDuplicateTitle(request.body); + return response.ok({ body: { isDuplicate } }); + } catch (e) { + const err = e.output?.payload ?? e; + return reportServerError(response, err); + } + } + ); + router.versioned.post({ path: `${SAVED_QUERY_BASE_URL}/_create`, access }).addVersion( { version, @@ -56,7 +86,7 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void { async (context, request, response) => { try { const savedQuery = await context.savedQuery; - const body: SavedQueryRestResponse = await savedQuery.create(request.body); + const body = await savedQuery.create(request.body); return response.ok({ body }); } catch (e) { const err = e.output?.payload ?? e; @@ -84,7 +114,7 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void { const { id } = request.params; try { const savedQuery = await context.savedQuery; - const body: SavedQueryRestResponse = await savedQuery.update(id, request.body); + const body = await savedQuery.update(id, request.body); return response.ok({ body }); } catch (e) { const err = e.output?.payload ?? e; @@ -111,7 +141,7 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void { const { id } = request.params; try { const savedQuery = await context.savedQuery; - const body: SavedQueryRestResponse = await savedQuery.get(id); + const body = await savedQuery.get(id); return response.ok({ body }); } catch (e) { const err = e.output?.payload ?? e; @@ -168,36 +198,7 @@ export function registerSavedQueryRoutes({ http }: CoreSetup): void { async (context, request, response) => { try { const savedQuery = await context.savedQuery; - const body: { total: number; savedQueries: SavedQueryRestResponse[] } = - await savedQuery.find(request.body); - return response.ok({ body }); - } catch (e) { - const err = e.output?.payload ?? e; - return reportServerError(response, err); - } - } - ); - - router.versioned.post({ path: `${SAVED_QUERY_BASE_URL}/_all`, access }).addVersion( - { - version, - validate: { - request: {}, - response: { - 200: { - body: schema.object({ - total: schema.number(), - savedQueries: schema.arrayOf(savedQueryResponseSchema), - }), - }, - }, - }, - }, - async (context, request, response) => { - try { - const savedQuery = await context.savedQuery; - const body: { total: number; savedQueries: SavedQueryRestResponse[] } = - await savedQuery.getAll(); + const body = await savedQuery.find(request.body); return response.ok({ body }); } catch (e) { const err = e.output?.payload ?? e; diff --git a/src/plugins/data/server/saved_objects/query.test.ts b/src/plugins/data/server/saved_objects/query.test.ts new file mode 100644 index 0000000000000..0968413c27d83 --- /dev/null +++ b/src/plugins/data/server/saved_objects/query.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { + createModelVersionTestMigrator, + type ModelVersionTestMigrator, +} from '@kbn/core-test-helpers-model-versions'; +import { querySavedObjectType } from './query'; + +describe('saved query model version transformations', () => { + let migrator: ModelVersionTestMigrator; + + beforeEach(() => { + migrator = createModelVersionTestMigrator({ type: querySavedObjectType }); + }); + + describe('model version 2', () => { + const query = { + id: 'some-id', + type: 'query', + attributes: { + title: 'Some Title', + description: 'some description', + query: { language: 'kuery', query: 'some query' }, + }, + references: [], + }; + + it('should properly backfill the titleKeyword field when converting from v1 to v2', () => { + const migrated = migrator.migrate({ + document: query, + fromVersion: 1, + toVersion: 2, + }); + + expect(migrated.attributes).toEqual({ + ...query.attributes, + titleKeyword: query.attributes.title, + }); + }); + + it('should properly remove the titleKeyword field when converting from v2 to v1', () => { + const migrated = migrator.migrate({ + document: { + ...query, + attributes: { ...query.attributes, titleKeyword: query.attributes.title }, + }, + fromVersion: 2, + toVersion: 1, + }); + + expect(migrated.attributes).toEqual(query.attributes); + }); + }); +}); diff --git a/src/plugins/data/server/saved_objects/query.ts b/src/plugins/data/server/saved_objects/query.ts index 59ff2483fbefb..4a96b4e2777aa 100644 --- a/src/plugins/data/server/saved_objects/query.ts +++ b/src/plugins/data/server/saved_objects/query.ts @@ -9,7 +9,11 @@ import { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; import { SavedObjectsType } from '@kbn/core/server'; import { savedQueryMigrations } from './migrations/query'; -import { SCHEMA_QUERY_V8_8_0 } from './schemas/query'; +import { + SCHEMA_QUERY_V8_8_0, + SCHEMA_QUERY_MODEL_VERSION_1, + SCHEMA_QUERY_MODEL_VERSION_2, +} from './schemas/query'; export const querySavedObjectType: SavedObjectsType = { name: 'query', @@ -31,10 +35,42 @@ export const querySavedObjectType: SavedObjectsType = { }; }, }, + modelVersions: { + 1: { + changes: [], + schemas: { + forwardCompatibility: SCHEMA_QUERY_MODEL_VERSION_1.extends({}, { unknowns: 'ignore' }), + create: SCHEMA_QUERY_MODEL_VERSION_1, + }, + }, + 2: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + titleKeyword: { type: 'keyword' }, + }, + }, + { + type: 'data_backfill', + backfillFn: (doc) => { + return { + attributes: { ...doc.attributes, titleKeyword: doc.attributes.title }, + }; + }, + }, + ], + schemas: { + forwardCompatibility: SCHEMA_QUERY_MODEL_VERSION_2.extends({}, { unknowns: 'ignore' }), + create: SCHEMA_QUERY_MODEL_VERSION_2, + }, + }, + }, mappings: { dynamic: false, properties: { title: { type: 'text' }, + titleKeyword: { type: 'keyword' }, description: { type: 'text' }, }, }, diff --git a/src/plugins/data/server/saved_objects/schemas/query.ts b/src/plugins/data/server/saved_objects/schemas/query.ts index c460a06b9727a..ae6e50340510f 100644 --- a/src/plugins/data/server/saved_objects/schemas/query.ts +++ b/src/plugins/data/server/saved_objects/schemas/query.ts @@ -8,25 +8,37 @@ import { schema } from '@kbn/config-schema'; +const FILTERS_SCHEMA = schema.arrayOf(schema.object({}, { unknowns: 'allow' })); + +const TIME_FILTER_SCHEMA = schema.object({ + from: schema.string(), + to: schema.string(), + refreshInterval: schema.maybe( + schema.object({ + value: schema.number(), + pause: schema.boolean(), + }) + ), +}); + // As per `SavedQueryAttributes` -export const SCHEMA_QUERY_V8_8_0 = schema.object({ +export const SCHEMA_QUERY_BASE = schema.object({ title: schema.string(), description: schema.string({ defaultValue: '' }), query: schema.object({ language: schema.string(), query: schema.oneOf([schema.string(), schema.object({}, { unknowns: 'allow' })]), }), - filters: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), - timefilter: schema.maybe( - schema.object({ - from: schema.string(), - to: schema.string(), - refreshInterval: schema.maybe( - schema.object({ - value: schema.number(), - pause: schema.boolean(), - }) - ), - }) - ), + filters: schema.maybe(FILTERS_SCHEMA), + timefilter: schema.maybe(TIME_FILTER_SCHEMA), +}); + +export const SCHEMA_QUERY_V8_8_0 = SCHEMA_QUERY_BASE; + +export const SCHEMA_QUERY_MODEL_VERSION_1 = SCHEMA_QUERY_BASE; + +export const SCHEMA_QUERY_MODEL_VERSION_2 = SCHEMA_QUERY_BASE.extends({ + titleKeyword: schema.string(), + filters: schema.maybe(schema.nullable(FILTERS_SCHEMA)), + timefilter: schema.maybe(schema.nullable(TIME_FILTER_SCHEMA)), }); diff --git a/src/plugins/data/tsconfig.json b/src/plugins/data/tsconfig.json index 53cdd2e1f5d9f..74fc83691ec53 100644 --- a/src/plugins/data/tsconfig.json +++ b/src/plugins/data/tsconfig.json @@ -52,7 +52,8 @@ "@kbn/shared-ux-link-redirect-app", "@kbn/bfetch-error", "@kbn/es-types", - "@kbn/code-editor" + "@kbn/code-editor", + "@kbn/core-test-helpers-model-versions" ], "exclude": [ "target/**/*", diff --git a/src/plugins/unified_search/public/query_string_input/add_filter_popover.styles.ts b/src/plugins/unified_search/public/query_string_input/add_filter_popover.styles.ts deleted file mode 100644 index 21e3d6b649175..0000000000000 --- a/src/plugins/unified_search/public/query_string_input/add_filter_popover.styles.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { euiShadowMedium, UseEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; - -/** @todo important style should be remove after fixing elastic/eui/issues/6314. */ -export const popoverDragAndDropCss = (euiTheme: UseEuiTheme) => - css` - // Always needed for popover with drag & drop in them - transform: none !important; - transition: none !important; - filter: none !important; - ${euiShadowMedium(euiTheme)} - `; diff --git a/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx b/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx index 48cf1a0f481e1..7954abed26c85 100644 --- a/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx +++ b/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx @@ -13,13 +13,11 @@ import { EuiPopover, EuiButtonIconProps, EuiToolTip, - useEuiTheme, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Filter } from '@kbn/es-query'; import type { DataView } from '@kbn/data-views-plugin/public'; import { FilterEditorWrapper } from './filter_editor_wrapper'; -import { popoverDragAndDropCss } from './add_filter_popover.styles'; import { withCloseFilterEditorConfirmModal, WithCloseFilterEditorConfirmModalProps, @@ -57,7 +55,6 @@ const AddFilterPopoverComponent = React.memo(function AddFilterPopover({ onLocalFilterCreate, suggestionsAbstraction, }: AddFilterPopoverProps) { - const euiTheme = useEuiTheme(); const [showAddFilterPopover, setShowAddFilterPopover] = useState(false); const button = ( @@ -91,11 +88,11 @@ const AddFilterPopoverComponent = React.memo(function AddFilterPopover({ panelPaddingSize="none" panelProps={{ 'data-test-subj': 'addFilterPopover', - css: popoverDragAndDropCss(euiTheme), }} initialFocus=".filterEditor__hiddenItem" ownFocus repositionOnScroll + hasDragDrop > ; + title: string; + append?: ReactNode; +}) => { + const { euiTheme } = useEuiTheme(); + const titleRef = useRef(null); + + const onTitleClick = useCallback( + () => queryBarMenuRef.current?.showPanel(QueryBarMenuPanel.main, 'previous'), + [queryBarMenuRef] + ); + + const onTitleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== keys.ARROW_LEFT) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + queryBarMenuRef.current?.showPreviousPanel(); + queryBarMenuRef.current?.onUseKeyboardToNavigate(); + }, + [queryBarMenuRef] + ); + + useEffectOnce(() => { + const panel = titleRef.current?.closest('.euiContextMenuPanel'); + const focus = () => titleRef.current?.focus(); + + panel?.addEventListener('animationend', focus, { once: true }); + + return () => panel?.removeEventListener('animationend', focus); + }); + + return ( + + + + + {title} + + + + {append && ( + + {append} + + )} + + ); +}; diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx index bdab607b2030c..85956745dff98 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx @@ -119,6 +119,7 @@ describe('Querybar Menu component', () => { ], }), }, + queryBarMenuRef: React.createRef(), }; }); it('should not render the popover if the openQueryBarMenu prop is false', async () => { diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx index 14b11737e8844..ef16d9b3e48d8 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, RefObject } from 'react'; import { EuiButtonIcon, EuiContextMenu, @@ -15,15 +15,22 @@ import { useGeneratedHtmlId, EuiButtonIconProps, EuiToolTip, - useEuiTheme, } from '@elastic/eui'; +import { + EuiContextMenuClass, + EuiContextMenuPanelId, +} from '@elastic/eui/src/components/context_menu/context_menu'; import { i18n } from '@kbn/i18n'; import type { Filter, Query, TimeRange } from '@kbn/es-query'; import type { DataView } from '@kbn/data-views-plugin/public'; -import type { SavedQueryService, SavedQuery } from '@kbn/data-plugin/public'; -import { QueryBarMenuPanels, QueryBarMenuPanelsProps } from './query_bar_menu_panels'; +import type { SavedQueryService, SavedQuery, SavedQueryTimeFilter } from '@kbn/data-plugin/public'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { + QueryBarMenuPanel, + useQueryBarMenuPanels, + QueryBarMenuPanelsProps, +} from './query_bar_menu_panels'; import { FilterEditorWrapper } from './filter_editor_wrapper'; -import { popoverDragAndDropCss } from './add_filter_popover.styles'; import { withCloseFilterEditorConfirmModal, WithCloseFilterEditorConfirmModalProps, @@ -51,6 +58,7 @@ export interface QueryBarMenuProps extends WithCloseFilterEditorConfirmModalProp disableQueryLanguageSwitcher?: boolean; dateRangeFrom?: string; dateRangeTo?: string; + timeFilter?: SavedQueryTimeFilter; savedQueryService: SavedQueryService; saveAsNewQueryFormComponent?: JSX.Element; saveFormComponent?: JSX.Element; @@ -71,6 +79,7 @@ export interface QueryBarMenuProps extends WithCloseFilterEditorConfirmModalProp isDisabled?: boolean; suggestionsAbstraction?: SuggestionsAbstraction; renderQueryInputAppend?: () => React.ReactNode; + queryBarMenuRef: RefObject; } function QueryBarMenuComponent({ @@ -79,6 +88,7 @@ function QueryBarMenuComponent({ disableQueryLanguageSwitcher, dateRangeFrom, dateRangeTo, + timeFilter, onQueryChange, onQueryBarSubmit, savedQueryService, @@ -105,13 +115,16 @@ function QueryBarMenuComponent({ onLocalFilterCreate, onLocalFilterUpdate, suggestionsAbstraction, + queryBarMenuRef, }: QueryBarMenuProps) { const [renderedComponent, setRenderedComponent] = useState('menu'); - - const euiTheme = useEuiTheme(); + const [currentPanelId, setCurrentPanelId] = useState( + QueryBarMenuPanel.main + ); useEffect(() => { if (openQueryBarMenu) { + setCurrentPanelId(QueryBarMenuPanel.main); setRenderedComponent('menu'); } }, [openQueryBarMenu]); @@ -141,7 +154,7 @@ function QueryBarMenuComponent({ onClick={onButtonClick} isDisabled={isDisabled} {...buttonProps} - style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }} + css={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }} iconType="filter" aria-label={strings.getFilterSetButtonLabel()} data-test-subj="showQueryBarMenu" @@ -149,22 +162,25 @@ function QueryBarMenuComponent({ ); - const panels = QueryBarMenuPanels({ + const panels = useQueryBarMenuPanels({ filters, savedQuery, language, dateRangeFrom, dateRangeTo, + timeFilter, query, showSaveQuery, showFilterBar, showQueryInput, savedQueryService, + saveFormComponent, saveAsNewQueryFormComponent, manageFilterSetComponent, hiddenPanelOptions, nonKqlMode, disableQueryLanguageSwitcher, + queryBarMenuRef, closePopover: plainClosePopover, onQueryBarSubmit, onFiltersUpdated, @@ -176,21 +192,41 @@ function QueryBarMenuComponent({ const renderComponent = () => { switch (renderedComponent) { case 'menu': - default: - return ( - - ); - case 'saveForm': return ( - {saveFormComponent}]} - /> - ); - case 'saveAsNewForm': - return ( - {saveAsNewQueryFormComponent}]} + setCurrentPanelId(panelId)} + data-test-subj="queryBarMenuPanel" + css={[ + { + // Add width to transition properties to smooth + // the animation when the panel width changes + transitionProperty: 'width, height !important', + // Add a white background to panels since panels + // of different widths can overlap each other + // when transitioning, but the background colour + // ensures the incoming panel always overlays + // the outgoing panel which improves the effect + '.euiContextMenuPanel': { + backgroundColor: euiThemeVars.euiColorEmptyShade, + }, + }, + // Fix the update button underline on hover, and + // the button focus outline being cut off + currentPanelId === QueryBarMenuPanel.main && { + '.euiContextMenuPanel__title': { + ':hover': { + textDecoration: 'none !important', + }, + '.euiContextMenuItem__text': { + overflow: 'visible', + }, + }, + }, + ]} /> ); case 'addFilter': @@ -217,23 +253,19 @@ function QueryBarMenuComponent({ }; return ( - <> - - {renderComponent()} - - + + {renderComponent()} + ); } diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx index b55377d618b3d..6ca40656467e5 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useState, useRef, useEffect, RefObject } from 'react'; import { isEqual } from 'lodash'; import { EuiContextMenuPanelDescriptor, @@ -14,6 +14,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiButton, + EuiContextMenuPanelItemDescriptor, } from '@elastic/eui'; import { Filter, @@ -24,6 +25,8 @@ import { toggleFilterNegated, pinFilter, unpinFilter, + compareFilters, + COMPARE_ALL_OPTIONS, } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; @@ -33,11 +36,14 @@ import { KQL_TELEMETRY_ROUTE_LATEST_VERSION, UI_SETTINGS, } from '@kbn/data-plugin/common'; -import type { SavedQueryService, SavedQuery } from '@kbn/data-plugin/public'; +import type { SavedQueryService, SavedQuery, SavedQueryTimeFilter } from '@kbn/data-plugin/public'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { EuiContextMenuClass } from '@elastic/eui/src/components/context_menu/context_menu'; import type { IUnifiedSearchPluginServices } from '../types'; import { fromUser } from './from_user'; import { QueryLanguageSwitcher } from './language_switcher'; import { FilterPanelOption } from '../types'; +import { PanelTitle } from './panel_title'; const MAP_ITEMS_TO_FILTER_OPTION: Record = { 'filter-sets-pinAllFilters': 'pinFilter', @@ -81,7 +87,7 @@ export const strings = { i18n.translate('unifiedSearch.filter.options.saveFilterSetLabel', { defaultMessage: 'Save query', }), - getClearllFiltersButtonLabel: () => + getClearAllFiltersButtonLabel: () => i18n.translate('unifiedSearch.filter.options.clearllFiltersButtonLabel', { defaultMessage: 'Clear all', }), @@ -136,22 +142,34 @@ export const strings = { }), }; +export enum QueryBarMenuPanel { + main = 'main', + applyToAllFilters = 'applyToAllFilters', + updateCurrentQuery = 'updateCurrentQuery', + saveAsNewQuery = 'saveAsNewQuery', + loadQuery = 'loadQuery', + selectLanguage = 'selectLanguage', +} + export interface QueryBarMenuPanelsProps { filters?: Filter[]; savedQuery?: SavedQuery; language: string; dateRangeFrom?: string; dateRangeTo?: string; + timeFilter?: SavedQueryTimeFilter; query?: Query; showSaveQuery?: boolean; showQueryInput?: boolean; showFilterBar?: boolean; savedQueryService: SavedQueryService; + saveFormComponent?: JSX.Element; saveAsNewQueryFormComponent?: JSX.Element; manageFilterSetComponent?: JSX.Element; hiddenPanelOptions?: FilterPanelOption[]; nonKqlMode?: 'lucene' | 'text'; disableQueryLanguageSwitcher?: boolean; + queryBarMenuRef: RefObject; closePopover: () => void; onQueryBarSubmit: (payload: { dateRange: TimeRange; query?: Query }) => void; onFiltersUpdated?: (filters: Filter[]) => void; @@ -160,22 +178,25 @@ export interface QueryBarMenuPanelsProps { setRenderedComponent: (component: string) => void; } -export function QueryBarMenuPanels({ +export function useQueryBarMenuPanels({ filters, savedQuery, language, dateRangeFrom, dateRangeTo, + timeFilter, query, showSaveQuery, showFilterBar, showQueryInput, savedQueryService, + saveFormComponent, saveAsNewQueryFormComponent, manageFilterSetComponent, hiddenPanelOptions, nonKqlMode, disableQueryLanguageSwitcher = false, + queryBarMenuRef, closePopover, onQueryBarSubmit, onFiltersUpdated, @@ -188,10 +209,17 @@ export function QueryBarMenuPanels({ const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName); const cancelPendingListingRequest = useRef<() => void>(() => {}); - const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); + const [hasSavedQueries, setHasSavedQueries] = useState(false); const [hasFiltersOrQuery, setHasFiltersOrQuery] = useState(false); const [savedQueryHasChanged, setSavedQueryHasChanged] = useState(false); + useEffect(() => { + if (savedQuery) { + cancelPendingListingRequest.current(); + setHasSavedQueries(true); + } + }, [savedQuery]); + useEffect(() => { const fetchSavedQueries = async () => { cancelPendingListingRequest.current(); @@ -200,35 +228,39 @@ export function QueryBarMenuPanels({ requestGotCancelled = true; }; - const { queries: savedQueryItems } = await savedQueryService.findSavedQueries(''); + const queryCount = await savedQueryService.getSavedQueryCount(); if (requestGotCancelled) return; - setSavedQueries(savedQueryItems.reverse().slice(0, 5)); + setHasSavedQueries(queryCount > 0); }; if (showQueryInput && showFilterBar) { fetchSavedQueries(); } - }, [savedQueryService, savedQuery, showQueryInput, showFilterBar]); + }, [savedQueryService, showQueryInput, showFilterBar]); useEffect(() => { if (savedQuery) { - let filtersHaveChanged = filters?.length !== savedQuery.attributes?.filters?.length; - if (filters?.length === savedQuery.attributes?.filters?.length) { - filtersHaveChanged = Boolean( - filters?.some( - (filter, index) => - !isEqual(filter.query, savedQuery.attributes?.filters?.[index]?.query) - ) - ); - } - if (filtersHaveChanged || !isEqual(query, savedQuery?.attributes.query)) { + const filtersHaveChanged = Boolean( + savedQuery?.attributes.filters && + !compareFilters(filters ?? [], savedQuery.attributes.filters, COMPARE_ALL_OPTIONS) + ); + + const timeFilterHasChanged = Boolean( + savedQuery?.attributes.timefilter && !isEqual(timeFilter, savedQuery?.attributes.timefilter) + ); + + if ( + filtersHaveChanged || + timeFilterHasChanged || + !isEqual(query, savedQuery?.attributes.query) + ) { setSavedQueryHasChanged(true); } else { setSavedQueryHasChanged(false); } } - }, [filters, query, savedQuery, savedQuery?.attributes.filters, savedQuery?.attributes.query]); + }, [filters, query, savedQuery, timeFilter]); useEffect(() => { const hasFilters = Boolean(filters && filters.length > 0); @@ -244,10 +276,6 @@ export function QueryBarMenuPanels({ }; }; - const handleSave = useCallback(() => { - setRenderedComponent('saveForm'); - }, [setRenderedComponent]); - const onEnableAll = () => { reportUiCounter?.(METRIC_TYPE.CLICK, `filter:enable_all`); const enabledFilters = filters?.map(enableFilter); @@ -320,7 +348,7 @@ export function QueryBarMenuPanels({ const luceneLabel = strings.getLuceneLanguageName(); const kqlLabel = strings.getKqlLanguageName(); - const filtersRelatedPanels = [ + const filtersRelatedPanels: EuiContextMenuPanelItemDescriptor[] = [ { name: strings.getOptionsAddFilterButtonLabel(), icon: 'plus', @@ -331,35 +359,34 @@ export function QueryBarMenuPanels({ { name: strings.getOptionsApplyAllFiltersButtonLabel(), icon: 'filter', - panel: 2, + panel: QueryBarMenuPanel.applyToAllFilters, disabled: !Boolean(filters && filters.length > 0), 'data-test-subj': 'filter-sets-applyToAllFilters', }, ]; - const queryAndFiltersRelatedPanels = [ + const queryAndFiltersRelatedPanels: EuiContextMenuPanelItemDescriptor[] = [ { name: savedQuery ? strings.getLoadOtherFilterSetLabel() : strings.getLoadCurrentFilterSetLabel(), - panel: 4, - width: 350, + panel: QueryBarMenuPanel.loadQuery, icon: 'filter', 'data-test-subj': 'saved-query-management-load-button', - disabled: !savedQueries.length, + disabled: !hasSavedQueries, }, { name: savedQuery ? strings.getSaveAsNewFilterSetLabel() : strings.getSaveFilterSetLabel(), icon: 'save', disabled: !Boolean(showSaveQuery) || !hasFiltersOrQuery || (savedQuery && !savedQueryHasChanged), - panel: 1, + panel: QueryBarMenuPanel.saveAsNewQuery, 'data-test-subj': 'saved-query-management-save-button', }, { isSeparator: true }, ]; - const items = []; + const items: EuiContextMenuPanelItemDescriptor[] = []; // apply to all actions are only shown when there are filters if (showFilterBar) { items.push(...filtersRelatedPanels); @@ -368,7 +395,7 @@ export function QueryBarMenuPanels({ if (showFilterBar || showQueryInput) { items.push( { - name: strings.getClearllFiltersButtonLabel(), + name: strings.getClearAllFiltersButtonLabel(), disabled: !hasFiltersOrQuery && !Boolean(savedQuery), icon: 'cross', 'data-test-subj': 'filter-sets-removeAllFilters', @@ -394,14 +421,14 @@ export function QueryBarMenuPanels({ if (showQueryInput && !disableQueryLanguageSwitcher) { items.push({ name: `Language: ${language === 'kuery' ? kqlLabel : luceneLabel}`, - panel: 3, + panel: QueryBarMenuPanel.selectLanguage, 'data-test-subj': 'switchQueryLanguageButton', }); } - let panels = [ + let panels: EuiContextMenuPanelDescriptor[] = [ { - id: 0, + id: QueryBarMenuPanel.main, title: savedQuery?.attributes.title ? ( <> @@ -419,7 +446,12 @@ export function QueryBarMenuPanels({ { + queryBarMenuRef.current?.showPanel( + QueryBarMenuPanel.updateCurrentQuery, + 'next' + ); + }} aria-label={strings.getSavedQueryPopoverSaveChangesButtonAriaLabel( savedQuery?.attributes.title )} @@ -435,13 +467,7 @@ export function QueryBarMenuPanels({ items, }, { - id: 1, - title: strings.getSaveCurrentFilterSetLabel(), - disabled: !Boolean(showSaveQuery), - content:
{saveAsNewQueryFormComponent}
, - }, - { - id: 2, + id: QueryBarMenuPanel.applyToAllFilters, initialFocusedItemIndex: 1, title: strings.getApplyAllFiltersButtonLabel(), items: [ @@ -493,7 +519,29 @@ export function QueryBarMenuPanels({ ], }, { - id: 3, + id: QueryBarMenuPanel.updateCurrentQuery, + content: ( + <> + +
{saveFormComponent}
+ + ), + }, + { + id: QueryBarMenuPanel.saveAsNewQuery, + title: strings.getSaveCurrentFilterSetLabel(), + content:
{saveAsNewQueryFormComponent}
, + }, + { + id: QueryBarMenuPanel.loadQuery, + width: 400, + content:
{manageFilterSetComponent}
, + }, + { + id: QueryBarMenuPanel.selectLanguage, title: strings.getFilterLanguageLabel(), content: ( ), }, - { - id: 4, - title: strings.getLoadCurrentFilterSetLabel(), - width: 400, - content:
{manageFilterSetComponent}
, - }, - ] as EuiContextMenuPanelDescriptor[]; + ]; if (hiddenPanelOptions && hiddenPanelOptions.length > 0) { panels = panels.map((panel) => ({ diff --git a/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx b/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx index 6f70944bae972..e9d7a548fadaa 100644 --- a/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx +++ b/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx @@ -6,10 +6,10 @@ * Side Public License, v 1. */ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; import { EuiButton, EuiForm, EuiFormRow, EuiFieldText, EuiSwitch } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { sortBy, isEqual } from 'lodash'; +import { isEqual } from 'lodash'; import { SavedQuery, SavedQueryService } from '@kbn/data-plugin/public'; interface Props { @@ -38,22 +38,22 @@ export function SaveQueryForm({ showTimeFilterOption = true, }: Props) { const [title, setTitle] = useState(savedQuery?.attributes.title ?? ''); - const [savedQueries, setSavedQueries] = useState([]); const [shouldIncludeFilters, setShouldIncludeFilters] = useState( - Boolean(savedQuery?.attributes.filters ?? true) + Boolean(savedQuery ? savedQuery.attributes.filters : true) ); // Defaults to false because saved queries are meant to be as portable as possible and loading // a saved query with a time filter will override whatever the current value of the global timepicker // is. We expect this option to be used rarely and only when the user knows they want this behavior. const [shouldIncludeTimefilter, setIncludeTimefilter] = useState( - Boolean(savedQuery?.attributes.timefilter ?? false) + Boolean(savedQuery ? savedQuery.attributes.timefilter : false) ); const [formErrors, setFormErrors] = useState([]); + const [saveIsDisabled, setSaveIsDisabled] = useState(false); const titleConflictErrorText = i18n.translate( 'unifiedSearch.search.searchBar.savedQueryForm.titleConflictText', { - defaultMessage: 'Name conflicts with an existing query', + defaultMessage: 'Name conflicts with an existing query.', } ); @@ -64,47 +64,48 @@ export function SaveQueryForm({ } ); - useEffect(() => { - const fetchQueries = async () => { - const allSavedQueries = await savedQueryService.getAllSavedQueries(); - const sortedAllSavedQueries = sortBy(allSavedQueries, 'attributes.title'); - setSavedQueries(sortedAllSavedQueries); - }; - fetchQueries(); - }, [savedQueryService]); - - const validate = useCallback(() => { + const validate = useCallback(async () => { const errors = []; - if ( - !!savedQueries.find( - (existingSavedQuery) => !savedQuery && existingSavedQuery.attributes.title === title - ) - ) { - errors.push(titleConflictErrorText); - } if (!title) { errors.push(titleExistsErrorText); } + if (await savedQueryService.isDuplicateTitle(title, savedQuery?.id)) { + errors.push(titleConflictErrorText); + } + if (!isEqual(errors, formErrors)) { setFormErrors(errors); return false; } return !formErrors.length; - }, [savedQueries, formErrors, title, savedQuery, titleConflictErrorText, titleExistsErrorText]); - - const onClickSave = useCallback(() => { - if (validate()) { - onSave({ - id: savedQuery?.id, - title, - description: '', - shouldIncludeFilters, - shouldIncludeTimefilter, - }); - onClose(); + }, [ + formErrors, + savedQuery, + savedQueryService, + title, + titleConflictErrorText, + titleExistsErrorText, + ]); + + const onClickSave = useCallback(async () => { + try { + setSaveIsDisabled(true); + + if (await validate()) { + onSave({ + id: savedQuery?.id, + title, + description: '', + shouldIncludeFilters, + shouldIncludeTimefilter, + }); + onClose(); + } + } finally { + setSaveIsDisabled(false); } }, [ validate, @@ -136,10 +137,6 @@ export function SaveQueryForm({ label={i18n.translate('unifiedSearch.search.searchBar.savedQueryNameLabelText', { defaultMessage: 'Name', })} - helpText={i18n.translate('unifiedSearch.search.searchBar.savedQueryNameHelpText', { - defaultMessage: - 'Name cannot contain a leading or trailing whitespace and must be unique.', - })} isInvalid={hasErrors} display="rowCompressed" > @@ -200,7 +197,7 @@ export function SaveQueryForm({ onClick={onClickSave} fill data-test-subj="savedQueryFormSaveButton" - disabled={hasErrors} + disabled={hasErrors || saveIsDisabled} > {i18n.translate('unifiedSearch.search.searchBar.savedQueryFormSaveButtonText', { defaultMessage: 'Save query', diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.scss b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.scss index 2e6f639ea792d..ad78b43fb1963 100644 --- a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.scss +++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.scss @@ -4,10 +4,6 @@ overflow-y: hidden; } -.kbnSavedQueryManagement__text { - padding: $euiSizeM $euiSizeM calc($euiSizeM / 2) $euiSizeM; -} - .kbnSavedQueryManagement__list { @include euiYScrollWithShadows; max-height: inherit; // Fixes overflow for applied max-height diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx index 3dbdfaf7588fc..c5103c49b93fe 100644 --- a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx +++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx @@ -7,12 +7,7 @@ */ import React from 'react'; -import { EuiSelectable } from '@elastic/eui'; import { I18nProvider } from '@kbn/i18n-react'; -import { act } from 'react-dom/test-utils'; -import { findTestSubject } from '@elastic/eui/lib/test'; -import { mountWithIntl as mount } from '@kbn/test-jest-helpers'; -import { ReactWrapper } from 'enzyme'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { coreMock, applicationServiceMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; @@ -20,6 +15,8 @@ import { SavedQueryManagementListProps, SavedQueryManagementList, } from './saved_query_management_list'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; describe('Saved query management list component', () => { const startMock = coreMock.createStart(); @@ -32,11 +29,16 @@ describe('Saved query management list component', () => { savedObjectsManagement: { edit: true }, }, }; - function wrapSavedQueriesListComponentInContext(testProps: SavedQueryManagementListProps) { + + const wrapSavedQueriesListComponentInContext = ( + testProps: SavedQueryManagementListProps, + applicationService = application + ) => { const services = { uiSettings: startMock.uiSettings, http: startMock.http, - application, + application: applicationService, + notifications: startMock.notifications, }; return ( @@ -46,119 +48,203 @@ describe('Saved query management list component', () => { ); - } + }; + + const generateSavedQueries = (total: number) => { + const queries = []; + for (let i = 0; i < total; i++) { + queries.push({ + id: `8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a${i}`, + attributes: { + title: `Test ${i}`, + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + namespaces: ['default'], + }); + } + return queries; + }; + + const fooQuery = { + id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9', + attributes: { + title: 'Foo', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + namespaces: ['default'], + }; + + const barQuery = { + id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a8', + attributes: { + title: 'Bar', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + namespaces: ['default'], + }; + + const testQuery = { + id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9', + attributes: { + title: 'Test', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + namespaces: ['default'], + }; - function flushEffect(component: ReactWrapper) { - return act(async () => { - await component; - await new Promise((r) => setImmediate(r)); - component.update(); - }); - } let props: SavedQueryManagementListProps; + beforeEach(() => { props = { onLoad: jest.fn(), onClearSavedQuery: jest.fn(), onClose: jest.fn(), showSaveQuery: true, - hasFiltersOrQuery: false, savedQueryService: { ...dataMock.query.savedQueries, - getAllSavedQueries: jest.fn().mockResolvedValue([ - { - id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9', - attributes: { - title: 'Test', - description: '', - query: { - query: 'category.keyword : "Men\'s Shoes" ', - language: 'kuery', - }, - filters: [], - }, - namespaces: ['default'], - }, - ]), + findSavedQueries: jest.fn().mockResolvedValue({ + total: 1, + queries: [testQuery], + }), deleteSavedQuery: jest.fn(), }, + queryBarMenuRef: React.createRef(), }; }); + it('should render the list component if saved queries exist', async () => { - const component = mount(wrapSavedQueriesListComponentInContext(props)); - await flushEffect(component); - expect(component.find('[data-test-subj="saved-query-management-list"]').length).toBe(1); + render(wrapSavedQueriesListComponentInContext(props)); + expect(await screen.findByRole('listbox', { name: 'Query list' })).toBeInTheDocument(); }); - it('should not rendet the list component if not saved queries exist', async () => { + it('should not render the list component if saved queries do not exist', async () => { const newProps = { ...props, savedQueryService: { ...dataMock.query.savedQueries, - getAllSavedQueries: jest.fn().mockResolvedValue([]), + findSavedQueries: jest.fn().mockResolvedValue({ total: 0, queries: [] }), }, }; - const component = mount(wrapSavedQueriesListComponentInContext(newProps)); - await flushEffect(component); - expect(component.find('[data-test-subj="saved-query-management-empty"]').length).toBeTruthy(); + render(wrapSavedQueriesListComponentInContext(newProps)); + await waitFor(() => { + expect(screen.queryByRole('listbox', { name: 'Query list' })).not.toBeInTheDocument(); + }); + expect(screen.queryAllByText(/No saved queries/)[0]).toBeInTheDocument(); }); it('should render the saved queries on the selectable component', async () => { - const component = mount(wrapSavedQueriesListComponentInContext(props)); - await flushEffect(component); - expect(component.find(EuiSelectable).prop('options').length).toBe(1); - expect(component.find(EuiSelectable).prop('options')[0].label).toBe('Test'); + render(wrapSavedQueriesListComponentInContext(props)); + expect(await screen.findAllByRole('option')).toHaveLength(1); + expect(screen.getByRole('option', { name: 'Test' })).toBeInTheDocument(); + }); + + it('should display the total and selected count', async () => { + const newProps = { + ...props, + savedQueryService: { + ...props.savedQueryService, + findSavedQueries: jest.fn().mockResolvedValue({ + total: 6, + queries: generateSavedQueries(5), + }), + }, + }; + render(wrapSavedQueriesListComponentInContext(newProps)); + expect(await screen.findByText('6 queries')).toBeInTheDocument(); + expect(screen.queryByText('6 queries | 1 selected')).not.toBeInTheDocument(); + userEvent.click(screen.getByRole('option', { name: 'Test 0' })); + expect(screen.queryByText('6 queries')).not.toBeInTheDocument(); + expect(screen.getByText('6 queries | 1 selected')).toBeInTheDocument(); + }); + + it('should not display the "Manage queries" link if application.capabilities.savedObjectsManagement.edit is false', async () => { + render( + wrapSavedQueriesListComponentInContext(props, { + ...application, + capabilities: { + ...application.capabilities, + savedObjectsManagement: { edit: false }, + }, + }) + ); + await waitFor(() => { + expect(screen.queryByRole('link', { name: 'Manage queries' })).not.toBeInTheDocument(); + }); + }); + + it('should display the "Manage queries" link if application.capabilities.savedObjectsManagement.edit is true', async () => { + render(wrapSavedQueriesListComponentInContext(props)); + expect(await screen.findByRole('link', { name: 'Manage queries' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Manage queries' })).toHaveAttribute( + 'href', + '/app/management/kibana/objects?initialQuery=type:("query")' + ); }); - it('should call the onLoad function', async () => { + it('should call the onLoad and onClose function', async () => { const onLoadSpy = jest.fn(); + const onCloseSpy = jest.fn(); const newProps = { ...props, onLoad: onLoadSpy, + onClose: onCloseSpy, }; - const component = mount(wrapSavedQueriesListComponentInContext(newProps)); - await flushEffect(component); - component.find('[data-test-subj="load-saved-query-Test-button"]').first().simulate('click'); - expect( - component.find('[data-test-subj="saved-query-management-apply-changes-button"]').length - ).toBeTruthy(); - component - .find('button[data-test-subj="saved-query-management-apply-changes-button"]') - .first() - .simulate('click'); + render(wrapSavedQueriesListComponentInContext(newProps)); + expect(await screen.findByLabelText('Load query')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Delete query' })).toBeDisabled(); + userEvent.click(screen.getByRole('option', { name: 'Test' })); + expect(screen.getByLabelText('Load query')).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Delete query' })).toBeEnabled(); + userEvent.click(screen.getByLabelText('Load query')); expect(onLoadSpy).toBeCalled(); + expect(onCloseSpy).toBeCalled(); }); it('should render the button with the correct text', async () => { - const component = mount(wrapSavedQueriesListComponentInContext(props)); - await flushEffect(component); + render(wrapSavedQueriesListComponentInContext(props)); expect( - component - .find('[data-test-subj="saved-query-management-apply-changes-button"]') - .first() - .text() - ).toBe('Load query'); + await screen.findByTestId('saved-query-management-apply-changes-button') + ).toHaveTextContent('Load query'); + }); + it('should not render the delete button if showSaveQuery is false', async () => { const newProps = { ...props, - hasFiltersOrQuery: true, + showSaveQuery: false, }; - const updatedComponent = mount(wrapSavedQueriesListComponentInContext(newProps)); - await flushEffect(component); - expect( - updatedComponent - .find('[data-test-subj="saved-query-management-apply-changes-button"]') - .first() - .text() - ).toBe('Load query'); + render(wrapSavedQueriesListComponentInContext(newProps)); + expect(await screen.findByLabelText('Load query')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Delete query' })).not.toBeInTheDocument(); }); it('should render the modal on delete', async () => { - const component = mount(wrapSavedQueriesListComponentInContext(props)); - await flushEffect(component); - findTestSubject(component, 'delete-saved-query-Test-button').simulate('click'); - expect(component.find('[data-test-subj="confirmModalConfirmButton"]').length).toBeTruthy(); - expect(component.text()).not.toContain('you remove it from every space'); + render(wrapSavedQueriesListComponentInContext(props)); + userEvent.click(await screen.findByRole('option', { name: 'Test' })); + userEvent.click(screen.getByRole('button', { name: 'Delete query' })); + expect(screen.getByRole('heading', { name: 'Delete "Test"?' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + expect(screen.queryByText(/you remove it from every space/)).not.toBeInTheDocument(); }); it('should render the modal with warning for multiple namespaces on delete', async () => { @@ -166,55 +252,369 @@ describe('Saved query management list component', () => { ...props, savedQueryService: { ...props.savedQueryService, - getAllSavedQueries: jest.fn().mockResolvedValue([ - { - id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9', - attributes: { - title: 'Test', - description: '', - query: { - query: 'category.keyword : "Men\'s Shoes" ', - language: 'kuery', - }, - filters: [], - }, - namespaces: ['one', 'two'], - }, - ]), + findSavedQueries: jest.fn().mockResolvedValue({ + total: 1, + queries: [{ ...testQuery, namespaces: ['one', 'two'] }], + }), deleteSavedQuery: jest.fn(), }, }; - const component = mount(wrapSavedQueriesListComponentInContext(newProps)); - await flushEffect(component); - findTestSubject(component, 'delete-saved-query-Test-button').simulate('click'); - - expect(component.find('[data-test-subj="confirmModalConfirmButton"]').length).toBeTruthy(); - expect(component.text()).toContain('you remove it from every space'); + render(wrapSavedQueriesListComponentInContext(newProps)); + userEvent.click(await screen.findByRole('option', { name: 'Test' })); + userEvent.click(screen.getByRole('button', { name: 'Delete query' })); + expect(screen.getByRole('heading', { name: 'Delete "Test"?' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + expect(screen.queryByText(/you remove it from every space/)).toBeInTheDocument(); }); - it('should render the onClearSavedQuery on delete of the current selected query', async () => { + it('should call deleteSavedQuery and onClearSavedQuery on delete of the current selected query', async () => { + const deleteSavedQuerySpy = jest.fn(); const onClearSavedQuerySpy = jest.fn(); const newProps = { ...props, - loadedSavedQuery: { - id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9', - attributes: { - title: 'Test', - description: '', - query: { - query: 'category.keyword : "Men\'s Shoes" ', - language: 'kuery', - }, - filters: [], - }, - namespaces: ['default'], + loadedSavedQuery: testQuery, + savedQueryService: { + ...props.savedQueryService, + findSavedQueries: jest.fn().mockResolvedValue({ + total: 2, + queries: generateSavedQueries(1), + }), + deleteSavedQuery: deleteSavedQuerySpy, }, onClearSavedQuery: onClearSavedQuerySpy, }; - const component = mount(wrapSavedQueriesListComponentInContext(newProps)); - await flushEffect(component); - findTestSubject(component, 'delete-saved-query-Test-button').simulate('click'); - findTestSubject(component, 'confirmModalConfirmButton').simulate('click'); + render(wrapSavedQueriesListComponentInContext(newProps)); + expect(await screen.findByText('2 queries | 1 selected')).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(2); + expect(screen.getByLabelText('Load query')).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Delete query' })).toBeEnabled(); + userEvent.click(screen.getByRole('button', { name: 'Delete query' })); + userEvent.click(screen.getByRole('button', { name: 'Delete' })); + expect(screen.getByText('1 query')).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(1); + expect(screen.getByLabelText('Load query')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Delete query' })).toBeDisabled(); + expect(deleteSavedQuerySpy).toHaveBeenLastCalledWith('8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9'); expect(onClearSavedQuerySpy).toBeCalled(); }); + + it('should not render pagination if there are less than 5 saved queries', async () => { + render(wrapSavedQueriesListComponentInContext(props)); + await waitFor(() => { + expect(screen.queryByText(/1 of/)).not.toBeInTheDocument(); + }); + }); + + it('should render pagination if there are more than 5 saved queries', async () => { + const newProps = { + ...props, + savedQueryService: { + ...props.savedQueryService, + findSavedQueries: jest.fn().mockResolvedValue({ + total: 6, + queries: generateSavedQueries(5), + }), + }, + }; + render(wrapSavedQueriesListComponentInContext(newProps)); + expect(await screen.findByText(/1 of 2/)).toBeInTheDocument(); + }); + + it('should allow navigating between saved query pages', async () => { + const findSavedQueriesSpy = jest.fn().mockResolvedValue({ + total: 6, + queries: generateSavedQueries(5), + }); + const newProps = { + ...props, + savedQueryService: { + ...props.savedQueryService, + findSavedQueries: findSavedQueriesSpy, + }, + }; + render(wrapSavedQueriesListComponentInContext(newProps)); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1); + }); + expect(screen.getByText(/1 of 2/)).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(5); + findSavedQueriesSpy.mockResolvedValue({ + total: 6, + queries: generateSavedQueries(1), + }); + userEvent.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2); + }); + expect(screen.getByText(/2 of 2/)).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(1); + findSavedQueriesSpy.mockResolvedValue({ + total: 6, + queries: generateSavedQueries(5), + }); + userEvent.click(screen.getByRole('button', { name: 'Previous page' })); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1); + }); + expect(screen.getByText(/1 of 2/)).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(5); + }); + + it('should not clear the currently selected saved query when navigating between pages', async () => { + const findSavedQueriesSpy = jest.fn().mockResolvedValue({ + total: 6, + queries: generateSavedQueries(5), + }); + const newProps = { + ...props, + savedQueryService: { + ...props.savedQueryService, + findSavedQueries: findSavedQueriesSpy, + }, + }; + render(wrapSavedQueriesListComponentInContext(newProps)); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1); + }); + expect(screen.getByRole('option', { name: 'Test 0', checked: false })).toBeInTheDocument(); + userEvent.click(screen.getByRole('option', { name: 'Test 0' })); + expect(screen.getByRole('option', { name: 'Test 0', checked: true })).toBeInTheDocument(); + findSavedQueriesSpy.mockResolvedValue({ + total: 6, + queries: generateSavedQueries(1), + }); + userEvent.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2); + }); + findSavedQueriesSpy.mockResolvedValue({ + total: 6, + queries: generateSavedQueries(5), + }); + userEvent.click(screen.getByRole('button', { name: 'Previous page' })); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1); + }); + expect(screen.getByRole('option', { name: 'Test 0', checked: true })).toBeInTheDocument(); + }); + + it('should allow providing a search term', async () => { + const findSavedQueriesSpy = jest.fn().mockResolvedValue({ + total: 6, + queries: generateSavedQueries(5), + }); + const newProps = { + ...props, + savedQueryService: { + ...props.savedQueryService, + findSavedQueries: findSavedQueriesSpy, + }, + }; + render(wrapSavedQueriesListComponentInContext(newProps)); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1); + }); + expect(screen.getByText(/1 of 2/)).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(5); + findSavedQueriesSpy.mockResolvedValue({ + total: 6, + queries: generateSavedQueries(1), + }); + userEvent.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2); + }); + expect(screen.getByText(/2 of 2/)).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(1); + findSavedQueriesSpy.mockResolvedValue({ + total: 1, + queries: generateSavedQueries(1), + }); + userEvent.type(screen.getByRole('combobox', { name: 'Query list' }), ' Test And Search '); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith('Test And Search', 5, 1); + }); + expect(screen.queryByText(/1 of/)).not.toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(1); + }); + + it('should correctly handle out of order responses', async () => { + const completionOrder: number[] = []; + let triggerResolve = () => {}; + const findSavedQueriesSpy = jest.fn().mockImplementation(async (_, __, page) => { + let queries: ReturnType = []; + if (page === 1) { + queries = generateSavedQueries(5); + completionOrder.push(1); + } else if (page === 2) { + queries = await new Promise((resolve) => { + triggerResolve = () => resolve(generateSavedQueries(5)); + }); + completionOrder.push(2); + } else if (page === 3) { + queries = generateSavedQueries(1); + completionOrder.push(3); + } + return { + total: 11, + queries, + }; + }); + const newProps = { + ...props, + savedQueryService: { + ...props.savedQueryService, + findSavedQueries: findSavedQueriesSpy, + }, + }; + render(wrapSavedQueriesListComponentInContext(newProps)); + await waitFor(() => { + expect(completionOrder).toEqual([1]); + }); + expect(screen.getByText(/1 of 3/)).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(5); + userEvent.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2); + }); + expect(completionOrder).toEqual([1]); + expect(screen.getByText(/2 of 3/)).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(5); + userEvent.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 3); + }); + expect(completionOrder).toEqual([1, 3]); + triggerResolve(); + await waitFor(() => { + expect(completionOrder).toEqual([1, 3, 2]); + }); + expect(screen.getByText(/3 of 3/)).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(1); + userEvent.click(screen.getByRole('button', { name: 'Previous page' })); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2); + }); + expect(completionOrder).toEqual([1, 3, 2]); + expect(screen.getByText(/2 of 3/)).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(1); + userEvent.click(screen.getByRole('button', { name: 'Previous page' })); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 1); + }); + expect(completionOrder).toEqual([1, 3, 2, 1]); + triggerResolve(); + await waitFor(() => { + expect(completionOrder).toEqual([1, 3, 2, 1, 2]); + }); + }); + + it('should not display an "Active" badge if there is no currently loaded saved query', async () => { + render(wrapSavedQueriesListComponentInContext(props)); + await waitFor(() => { + expect(screen.queryByText(/Active/)).not.toBeInTheDocument(); + }); + }); + + it('should display an "Active" badge for the currently loaded saved query', async () => { + const newProps = { + ...props, + loadedSavedQuery: testQuery, + }; + render(wrapSavedQueriesListComponentInContext(newProps)); + expect(await screen.findByText(/Active/)).toBeInTheDocument(); + }); + + it('should hoist the currently loaded saved query to the top of the list', async () => { + const newProps = { + ...props, + loadedSavedQuery: fooQuery, + savedQueryService: { + ...props.savedQueryService, + findSavedQueries: jest.fn().mockResolvedValue({ + total: 2, + queries: [barQuery, fooQuery], + }), + }, + }; + render(wrapSavedQueriesListComponentInContext(newProps)); + expect(await screen.findAllByRole('option')).toHaveLength(2); + expect(screen.getAllByRole('option')[0]).toHaveTextContent('Foo'); + expect(screen.getAllByRole('option')[0]).toHaveTextContent('Active'); + expect(screen.getAllByRole('option')[1]).toHaveTextContent('Bar'); + expect(screen.getAllByRole('option')[1]).not.toHaveTextContent('Active'); + }); + + it('should hoist the currently loaded saved query to the top of the list even if it is not in the first page of results', async () => { + const newProps = { + ...props, + loadedSavedQuery: fooQuery, + savedQueryService: { + ...props.savedQueryService, + findSavedQueries: jest.fn().mockResolvedValue({ + total: 6, + queries: generateSavedQueries(5), + }), + }, + }; + render(wrapSavedQueriesListComponentInContext(newProps)); + expect(await screen.findAllByRole('option')).toHaveLength(6); + expect(screen.getAllByRole('option')[0]).toHaveTextContent('Foo'); + expect(screen.getAllByRole('option')[0]).toHaveTextContent('Active'); + expect(screen.getAllByRole('option')[1]).toHaveTextContent('Test 0'); + expect(screen.getAllByRole('option')[1]).not.toHaveTextContent('Active'); + }); + + it('should not hoist the currently loaded saved query to the top of the list if there is a search term', async () => { + const findSavedQueriesSpy = jest.fn().mockResolvedValue({ + total: 2, + queries: [barQuery, fooQuery], + }); + const newProps = { + ...props, + loadedSavedQuery: fooQuery, + savedQueryService: { + ...props.savedQueryService, + findSavedQueries: findSavedQueriesSpy, + }, + }; + render(wrapSavedQueriesListComponentInContext(newProps)); + expect(await screen.findAllByRole('option')).toHaveLength(2); + expect(screen.getAllByRole('option')[0]).toHaveTextContent('Foo'); + expect(screen.getAllByRole('option')[0]).toHaveTextContent('Active'); + userEvent.type(screen.getByRole('searchbox', { name: 'Query list' }), ' Test And Search '); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith('Test And Search', 5, 1); + }); + expect(screen.getAllByRole('option')).toHaveLength(2); + expect(screen.getAllByRole('option')[0]).toHaveTextContent('Bar'); + expect(screen.getAllByRole('option')[0]).not.toHaveTextContent('Active'); + }); + + it('should not hoist the currently loaded saved query to the top of the list if not on the first page', async () => { + const findSavedQueriesSpy = jest.fn().mockResolvedValue({ + total: 6, + queries: generateSavedQueries(5), + }); + const newProps = { + ...props, + loadedSavedQuery: fooQuery, + savedQueryService: { + ...props.savedQueryService, + findSavedQueries: findSavedQueriesSpy, + }, + }; + render(wrapSavedQueriesListComponentInContext(newProps)); + expect(await screen.findAllByRole('option')).toHaveLength(6); + expect(screen.getAllByRole('option')[0]).toHaveTextContent('Foo'); + expect(screen.getAllByRole('option')[0]).toHaveTextContent('Active'); + userEvent.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => { + expect(findSavedQueriesSpy).toHaveBeenLastCalledWith(undefined, 5, 2); + }); + expect(screen.getAllByRole('option')).toHaveLength(5); + expect(screen.getAllByRole('option')[0]).toHaveTextContent('Test 0'); + expect(screen.getAllByRole('option')[0]).not.toHaveTextContent('Active'); + }); }); diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx index 0cff3baa90883..d62061d7d6cf6 100644 --- a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx +++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx @@ -13,33 +13,42 @@ import { EuiIcon, EuiPanel, EuiSelectable, - EuiText, EuiPopoverFooter, EuiButtonIcon, - EuiButtonEmpty, EuiConfirmModal, - usePrettyDuration, ShortDate, + EuiPagination, + EuiBadge, + EuiToolTip, + EuiText, + EuiHorizontalRule, + EuiProgress, + PrettyDuration, } from '@elastic/eui'; - +import { EuiContextMenuClass } from '@elastic/eui/src/components/context_menu/context_menu'; import { i18n } from '@kbn/i18n'; -import React, { useCallback, useEffect, useState, useRef } from 'react'; -import { css } from '@emotion/react'; -import { sortBy } from 'lodash'; +import React, { useCallback, useState, useRef, useEffect, useMemo, RefObject } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { SavedQuery, SavedQueryService } from '@kbn/data-plugin/public'; +import type { SavedQuery, SavedQueryService } from '@kbn/data-plugin/public'; import type { SavedQueryAttributes } from '@kbn/data-plugin/common'; import './saved_query_management_list.scss'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { debounce } from 'lodash'; +import useLatest from 'react-use/lib/useLatest'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import type { IUnifiedSearchPluginServices } from '../types'; +import { strings as queryBarMenuPanelsStrings } from '../query_string_input/query_bar_menu_panels'; +import { PanelTitle } from '../query_string_input/panel_title'; export interface SavedQueryManagementListProps { showSaveQuery?: boolean; loadedSavedQuery?: SavedQuery; savedQueryService: SavedQueryService; + queryBarMenuRef: RefObject; onLoad: (savedQuery: SavedQuery) => void; onClearSavedQuery: () => void; onClose: () => void; - hasFiltersOrQuery: boolean; } interface SelectableProps { @@ -60,34 +69,90 @@ interface DurationRange { } const commonDurationRanges: DurationRange[] = [ - { start: 'now/d', end: 'now/d', label: 'Today' }, - { start: 'now/w', end: 'now/w', label: 'This week' }, - { start: 'now/M', end: 'now/M', label: 'This month' }, - { start: 'now/y', end: 'now/y', label: 'This year' }, - { start: 'now-1d/d', end: 'now-1d/d', label: 'Yesterday' }, - { start: 'now/w', end: 'now', label: 'Week to date' }, - { start: 'now/M', end: 'now', label: 'Month to date' }, - { start: 'now/y', end: 'now', label: 'Year to date' }, + { + start: 'now/d', + end: 'now/d', + label: i18n.translate('unifiedSearch.search.searchBar.savedQueryTodayLabel', { + defaultMessage: 'Today', + }), + }, + { + start: 'now/w', + end: 'now/w', + label: i18n.translate('unifiedSearch.search.searchBar.savedQueryWeekLabel', { + defaultMessage: 'This week', + }), + }, + { + start: 'now/M', + end: 'now/M', + label: i18n.translate('unifiedSearch.search.searchBar.savedQueryMonthLabel', { + defaultMessage: 'This month', + }), + }, + { + start: 'now/y', + end: 'now/y', + label: i18n.translate('unifiedSearch.search.searchBar.savedQueryYearLabel', { + defaultMessage: 'This year', + }), + }, + { + start: 'now-1d/d', + end: 'now-1d/d', + label: i18n.translate('unifiedSearch.searchBar.savedQueryYesterdayLabel', { + defaultMessage: 'Yesterday', + }), + }, + { + start: 'now/w', + end: 'now', + label: i18n.translate('unifiedSearch.searchBar.savedQueryWeekToDateLabel', { + defaultMessage: 'Week to date', + }), + }, + { + start: 'now/M', + end: 'now', + label: i18n.translate('unifiedSearch.searchBar.savedQueryMonthToDateLabel', { + defaultMessage: 'Month to date', + }), + }, + { + start: 'now/y', + end: 'now', + label: i18n.translate('unifiedSearch.searchBar.savedQueryYearToDateLabel', { + defaultMessage: 'Year to date', + }), + }, ]; -const itemTitle = (attributes: SavedQueryAttributes, format: string) => { - let label = attributes.title; - const prettifier = usePrettyDuration; +const itemTitle = (attributes: SavedQueryAttributes, services: IUnifiedSearchPluginServices) => { + const label = [attributes.title]; if (attributes.description) { - label += `; ${attributes.description}`; + label.push(attributes.description); } if (attributes.timefilter) { - label += `; ${prettifier({ - timeFrom: attributes.timefilter?.from, - timeTo: attributes.timefilter?.to, - quickRanges: commonDurationRanges, - dateFormat: format, - })}`; + label.push( + // This is a hack to render the PrettyDuration component to a string since itemTitle + // is called in a loop, so the usePrettyDuration hook is not an option, and it must + // return a string, but there is no non-hook alternative that returns a string + renderToStaticMarkup( + + + + ) + ); } - return label; + return label.join('; '); }; const itemLabel = (attributes: SavedQueryAttributes) => { @@ -112,41 +177,103 @@ const itemLabel = (attributes: SavedQueryAttributes) => { return label; }; -export function SavedQueryManagementList({ +const noSavedQueriesDescriptionText = [ + i18n.translate('unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText', { + defaultMessage: 'No saved queries.', + }), + i18n.translate('unifiedSearch.search.searchBar.savedQueryDescriptionText', { + defaultMessage: 'Save query text and filters that you want to use again.', + }), +].join(' '); + +const savedQueryMultipleNamespacesDeleteWarning = i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryMultipleNamespacesDeleteWarning', + { + defaultMessage: `This saved query is shared in multiple spaces. When you delete it, you remove it from every space it is shared in. You can't undo this action.`, + } +); + +const SAVED_QUERY_PAGE_SIZE = 5; +const SAVED_QUERY_SEARCH_DEBOUNCE = 500; +const LOADING_INDICATOR_DELAY = 250; + +export const SavedQueryManagementList = ({ showSaveQuery, loadedSavedQuery, + savedQueryService, + queryBarMenuRef, onLoad, onClearSavedQuery, - savedQueryService, onClose, - hasFiltersOrQuery, -}: SavedQueryManagementListProps) { - const kibana = useKibana(); - const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); +}: SavedQueryManagementListProps) => { + const services = useKibana().services; + const [searchTerm, setSearchTerm] = useState(''); + const [currentPageNumber, setCurrentPageNumber] = useState(0); + const [totalQueryCount, setTotalQueryCount] = useState(0); + const [currentPageQueries, setCurrentPageQueries] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isInitializing, setIsInitializing] = useState(true); + const currentPageFetchId = useRef(0); + const selectableRef = useRef(null); const [selectedSavedQuery, setSelectedSavedQuery] = useState(loadedSavedQuery); - const [toBeDeletedSavedQuery, setToBeDeletedSavedQuery] = useState(null as SavedQuery | null); + const [toBeDeletedSavedQuery, setToBeDeletedSavedQuery] = useState(null); const [showDeletionConfirmationModal, setShowDeletionConfirmationModal] = useState(false); - const cancelPendingListingRequest = useRef<() => void>(() => {}); - const { uiSettings, http, application } = kibana.services; - const format = uiSettings.get('dateFormat'); - useEffect(() => { - const fetchCountAndSavedQueries = async () => { - cancelPendingListingRequest.current(); - let requestGotCancelled = false; - cancelPendingListingRequest.current = () => { - requestGotCancelled = true; - }; + const debouncedSetSearchTerm = useMemo(() => { + return debounce((newSearchTerm: string) => { + setSearchTerm((currentSearchTerm) => { + if (currentSearchTerm !== newSearchTerm) { + setCurrentPageNumber(0); + } - const savedQueryItems = await savedQueryService.getAllSavedQueries(); + return newSearchTerm; + }); + }, SAVED_QUERY_SEARCH_DEBOUNCE); + }, []); - if (requestGotCancelled) return; + const fetchPage = useLatest(async () => { + const fetchIdValue = ++currentPageFetchId.current; + const loadingTimeout = setTimeout(() => { + setIsLoading(true); + }, LOADING_INDICATOR_DELAY); + + try { + const preparedSearch = searchTerm.trim(); + const { total, queries } = await savedQueryService.findSavedQueries( + preparedSearch || undefined, + SAVED_QUERY_PAGE_SIZE, + currentPageNumber + 1 + ); + + if (fetchIdValue !== currentPageFetchId.current) { + return; + } + + let filteredQueries = queries; + + if (loadedSavedQuery && !preparedSearch && currentPageNumber === 0) { + filteredQueries = [ + loadedSavedQuery, + ...queries.filter((savedQuery) => savedQuery.id !== loadedSavedQuery.id), + ]; + } + + setTotalQueryCount(total); + setCurrentPageQueries(filteredQueries); + selectableRef.current?.scrollToItem(0); + } finally { + clearTimeout(loadingTimeout); + + if (fetchIdValue === currentPageFetchId.current) { + setIsLoading(false); + setIsInitializing(false); + } + } + }); - const sortedSavedQueryItems = sortBy(savedQueryItems, 'attributes.title'); - setSavedQueries(sortedSavedQueryItems); - }; - fetchCountAndSavedQueries(); - }, [savedQueryService]); + useEffect(() => { + fetchPage.current(); + }, [currentPageNumber, fetchPage, searchTerm]); const handleLoad = useCallback(() => { if (selectedSavedQuery) { @@ -165,194 +292,275 @@ export function SavedQueryManagementList({ }, []); const onDelete = useCallback( - (savedQueryToDelete: string) => { + (savedQueryToDelete: SavedQuery) => { const onDeleteSavedQuery = async (savedQueryId: string) => { - cancelPendingListingRequest.current(); - setSavedQueries( - savedQueries.filter((currentSavedQuery) => currentSavedQuery.id !== savedQueryId) + setTotalQueryCount((currentTotalQueryCount) => Math.max(0, currentTotalQueryCount - 1)); + setCurrentPageQueries( + currentPageQueries.filter((currentSavedQuery) => currentSavedQuery.id !== savedQueryId) ); + setSelectedSavedQuery(undefined); if (loadedSavedQuery && loadedSavedQuery.id === savedQueryId) { onClearSavedQuery(); - setSelectedSavedQuery(undefined); } - await savedQueryService.deleteSavedQuery(savedQueryId); + try { + await savedQueryService.deleteSavedQuery(savedQueryId); + + services.notifications.toasts.addSuccess( + i18n.translate('unifiedSearch.search.searchBar.deleteQuerySuccessMessage', { + defaultMessage: 'Query "{queryTitle}" was deleted', + values: { + queryTitle: savedQueryToDelete.attributes.title, + }, + }) + ); + } catch (error) { + services.notifications.toasts.addDanger( + i18n.translate('unifiedSearch.search.searchBar.deleteQueryErrorMessage', { + defaultMessage: + 'An error occured while deleting query "{queryTitle}": {errorMessage}', + values: { + queryTitle: savedQueryToDelete.attributes.title, + errorMessage: error.message, + }, + }) + ); + throw error; + } }; - onDeleteSavedQuery(savedQueryToDelete); + onDeleteSavedQuery(savedQueryToDelete.id); }, - [loadedSavedQuery, onClearSavedQuery, savedQueries, savedQueryService] + [ + currentPageQueries, + loadedSavedQuery, + onClearSavedQuery, + savedQueryService, + services.notifications.toasts, + ] ); - const savedQueryDescriptionText = i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryDescriptionText', - { - defaultMessage: 'Save query text and filters that you want to use again.', - } - ); - - const noSavedQueriesDescriptionText = - i18n.translate('unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText', { - defaultMessage: 'No saved queries.', - }) + - ' ' + - savedQueryDescriptionText; - - const savedQueryMultipleNamespacesDeleteWarning = i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryMultipleNamespacesDeleteWarning', - { - defaultMessage: `This saved query is shared in multiple spaces. When you delete it, you remove it from every space it is shared in. You can't undo this action.`, - } - ); - - const savedQueriesOptions = () => { - const savedQueriesWithoutCurrent = savedQueries.filter((savedQuery) => { - if (!loadedSavedQuery) return true; - return savedQuery.id !== loadedSavedQuery.id; - }); - const savedQueriesReordered = - loadedSavedQuery && savedQueriesWithoutCurrent.length !== savedQueries.length - ? [loadedSavedQuery, ...savedQueriesWithoutCurrent] - : [...savedQueriesWithoutCurrent]; - - return savedQueriesReordered.map((savedQuery) => { + const savedQueriesOptions = useMemo(() => { + return currentPageQueries.map((savedQuery) => { return { key: savedQuery.id, label: savedQuery.attributes.title, - title: itemTitle(savedQuery.attributes, format), + title: itemTitle(savedQuery.attributes, services), 'data-test-subj': `load-saved-query-${savedQuery.attributes.title}-button`, value: savedQuery.id, checked: selectedSavedQuery && savedQuery.id === selectedSavedQuery.id ? 'on' : undefined, data: { attributes: savedQuery.attributes, }, - append: !!showSaveQuery && ( - handleDelete(savedQuery)} - color="danger" - /> - ), }; - }) as unknown as SelectableProps[]; - }; + }); + }, [currentPageQueries, selectedSavedQuery, services]); - const renderOption = (option: RenderOptionProps) => { - return <>{option.attributes ? itemLabel(option.attributes) : option.label}; - }; + const renderOption = useCallback( + (option: RenderOptionProps) => { + return ( + <> + {option.attributes ? itemLabel(option.attributes) : option.label} + {option.value === loadedSavedQuery?.id && ( + + {i18n.translate('unifiedSearch.search.searchBar.savedQueryActiveBadgeText', { + defaultMessage: 'Active', + })} + + )} + + ); + }, + [loadedSavedQuery?.id] + ); - const canEditSavedObjects = application.capabilities.savedObjectsManagement.edit; + const countDisplay = useMemo(() => { + const parts = [ + i18n.translate('unifiedSearch.search.searchBar.savedQueryTotalQueryCount', { + defaultMessage: '{totalQueryCount, plural, one {# query} other {# queries}}', + values: { totalQueryCount }, + }), + ]; + + if (Boolean(selectedSavedQuery)) { + parts.push( + i18n.translate('unifiedSearch.search.searchBar.savedQuerySelectedQueryCount', { + defaultMessage: '1 selected', + }) + ); + } + + return parts.join(' | '); + }, [selectedSavedQuery, totalQueryCount]); - const listComponent = ( + return ( <> - {savedQueries.length > 0 ? ( - <> -
- - aria-label="Basic example" - options={savedQueriesOptions()} - searchable - singleSelection="always" - onChange={(choices) => { - const choice = choices.find(({ checked }) => checked) as unknown as { - value: string; - }; - if (choice) { - handleSelect(savedQueries.find((savedQuery) => savedQuery.id === choice.value)); - } - }} - searchProps={{ - compressed: true, - placeholder: i18n.translate( - 'unifiedSearch.query.queryBar.indexPattern.findFilterSet', - { - defaultMessage: 'Find a query', - } - ), - }} - listProps={{ - isVirtualized: true, - }} - renderOption={renderOption} - > - {(list, search) => ( - <> - - {search} - - {list} - - )} - -
- - ) : ( - <> - -

{noSavedQueriesDescriptionText}

-
- - )} - - - - - {i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel', + + + + {isLoading && } + + ref={selectableRef} + aria-label={i18n.translate('unifiedSearch.search.searchBar.savedQueryListAriaLabel', { + defaultMessage: 'Query list', + })} + isLoading={isInitializing} + singleSelection="always" + options={savedQueriesOptions} + listProps={{ onFocusBadge: false }} + isPreFiltered + searchable + searchProps={{ + compressed: true, + placeholder: i18n.translate( + 'unifiedSearch.query.queryBar.indexPattern.findFilterSet', { - defaultMessage: 'Load query', + defaultMessage: 'Find a query', } - )} - + ), + onChange: debouncedSetSearchTerm, + 'data-test-subj': 'saved-query-management-search-input', + }} + loadingMessage={i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryLoadingQueriesText', + { + defaultMessage: 'Loading queries', + } + )} + emptyMessage={ + + {noSavedQueriesDescriptionText} + + } + onChange={(choices) => { + const choice = choices.find(({ checked }) => checked); + if (choice) { + handleSelect( + currentPageQueries.find((savedQuery) => savedQuery.id === choice.value) + ); + } + }} + renderOption={renderOption} + css={{ + '.euiSelectableList__list': { + WebkitMaskImage: 'unset', + maskImage: 'unset', + }, + }} + > + {(list, search) => ( + <> + + {search} + + + + {countDisplay} + + + + {list} + + )} +
+ + {totalQueryCount > SAVED_QUERY_PAGE_SIZE && ( + + + + setCurrentPageNumber(activePage)} + compressed + /> + + - {canEditSavedObjects && ( + )} +
+ + + {Boolean(showSaveQuery) && ( - - {i18n.translate('unifiedSearch.search.searchBar.savedQueryPopoverManageLabel', { - defaultMessage: 'Manage saved objects', - })} - + { + if (selectedSavedQuery) { + handleDelete(selectedSavedQuery); + } + }} + /> + )} + + + + {i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel', + { + defaultMessage: 'Load query', + } + )} + + + {showDeletionConfirmationModal && toBeDeletedSavedQuery && ( @@ -379,7 +587,7 @@ export function SavedQueryManagementList({ } )} onConfirm={() => { - onDelete(toBeDeletedSavedQuery.id); + onDelete(toBeDeletedSavedQuery); setShowDeletionConfirmationModal(false); }} buttonColor="danger" @@ -395,6 +603,40 @@ export function SavedQueryManagementList({ )} ); +}; - return listComponent; -} +const ListTitle = ({ queryBarMenuRef }: { queryBarMenuRef: RefObject }) => { + const { application, http } = useKibana().services; + const canEditSavedObjects = application.capabilities.savedObjectsManagement.edit; + + return ( + + + + ) + } + /> + ); +}; diff --git a/src/plugins/unified_search/public/search_bar/search_bar.test.tsx b/src/plugins/unified_search/public/search_bar/search_bar.test.tsx index b7c7e83b7c7f5..e9fce0f749928 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.test.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.test.tsx @@ -100,6 +100,7 @@ function wrapSearchBarInContext(testProps: any) { savedQueries: { findSavedQueries: () => Promise.resolve({ + total: 1, queries: [ { id: 'testwewe', @@ -115,6 +116,7 @@ function wrapSearchBarInContext(testProps: any) { }, ], }), + getSavedQueryCount: jest.fn(), }, }, dataViewEditor: dataViewEditorMock, diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index a8a81224df534..77755ccd6a990 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -9,18 +9,24 @@ import { compact } from 'lodash'; import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; import classNames from 'classnames'; -import React, { Component } from 'react'; +import React, { Component, createRef } from 'react'; import { EuiIconProps, withEuiTheme, WithEuiThemeProps } from '@elastic/eui'; +import { EuiContextMenuClass } from '@elastic/eui/src/components/context_menu/context_menu'; import { get, isEqual } from 'lodash'; import memoizeOne from 'memoize-one'; import { METRIC_TYPE } from '@kbn/analytics'; import { Query, Filter, TimeRange, AggregateQuery, isOfQueryType } from '@kbn/es-query'; import { withKibana, KibanaReactContextValue } from '@kbn/kibana-react-plugin/public'; -import type { TimeHistoryContract, SavedQuery } from '@kbn/data-plugin/public'; +import type { + TimeHistoryContract, + SavedQuery, + SavedQueryTimeFilter, +} from '@kbn/data-plugin/public'; import type { SavedQueryAttributes } from '@kbn/data-plugin/common'; import { DataView } from '@kbn/data-views-plugin/public'; +import { i18n } from '@kbn/i18n'; import type { IUnifiedSearchPluginServices } from '../types'; import { SavedQueryMeta, SaveQueryForm } from '../saved_query_form'; import { SavedQueryManagementList } from '../saved_query_management'; @@ -153,6 +159,7 @@ class SearchBarUI extends C private services = this.props.kibana.services; private savedQueryService = this.services.data.query.savedQueries; + private queryBarMenuRef = createRef(); public static getDerivedStateFromProps( nextProps: SearchBarProps, @@ -290,27 +297,14 @@ class SearchBarUI extends C return true; } - public onSave = async (savedQueryMeta: SavedQueryMeta, saveAsNew = false) => { - if (!this.state.query) return; - - const savedQueryAttributes: SavedQueryAttributes = { - title: savedQueryMeta.title, - description: savedQueryMeta.description, - query: this.state.query as Query, - }; - - if (savedQueryMeta.shouldIncludeFilters) { - savedQueryAttributes.filters = this.props.filters; - } - + private getTimeFilter(): SavedQueryTimeFilter | undefined { if ( - savedQueryMeta.shouldIncludeTimefilter && this.state.dateRangeTo !== undefined && this.state.dateRangeFrom !== undefined && this.props.refreshInterval !== undefined && this.props.isRefreshPaused !== undefined ) { - savedQueryAttributes.timefilter = { + return { from: this.state.dateRangeFrom, to: this.state.dateRangeTo, refreshInterval: { @@ -319,6 +313,26 @@ class SearchBarUI extends C }, }; } + } + + public onSave = async (savedQueryMeta: SavedQueryMeta, saveAsNew = false) => { + if (!this.state.query) return; + + const savedQueryAttributes: SavedQueryAttributes = { + title: savedQueryMeta.title, + description: savedQueryMeta.description, + query: this.state.query as Query, + }; + + if (savedQueryMeta.shouldIncludeFilters) { + savedQueryAttributes.filters = this.props.filters; + } + + const timeFilter = this.getTimeFilter(); + + if (savedQueryMeta.shouldIncludeTimefilter && timeFilter) { + savedQueryAttributes.timefilter = timeFilter; + } try { let response; @@ -332,7 +346,12 @@ class SearchBarUI extends C } this.services.notifications.toasts.addSuccess( - `Your query "${response.attributes.title}" was saved` + i18n.translate('unifiedSearch.search.searchBar.saveQuerySuccessMessage', { + defaultMessage: 'Your query "{queryTitle}" was saved', + values: { + queryTitle: response.attributes.title, + }, + }) ); if (this.props.onSaved) { @@ -340,7 +359,12 @@ class SearchBarUI extends C } } catch (error) { this.services.notifications.toasts.addDanger( - `An error occured while saving your query: ${error.message}` + i18n.translate('unifiedSearch.search.searchBar.saveQueryErrorMessage', { + defaultMessage: 'An error occured while saving your query: {errorMessage}', + values: { + errorMessage: error.message, + }, + }) ); throw error; } @@ -498,6 +522,7 @@ class SearchBarUI extends C onQueryBarSubmit={this.onQueryBarSubmit} dateRangeFrom={this.state.dateRangeFrom} dateRangeTo={this.state.dateRangeTo} + timeFilter={this.getTimeFilter()} savedQueryService={this.savedQueryService} saveAsNewQueryFormComponent={saveAsNewQueryFormComponent} saveFormComponent={saveQueryFormComponent} @@ -528,6 +553,7 @@ class SearchBarUI extends C } suggestionsAbstraction={this.props.suggestionsAbstraction} renderQueryInputAppend={this.props.renderQueryInputAppend} + queryBarMenuRef={this.queryBarMenuRef} /> ) : undefined; @@ -621,14 +647,6 @@ class SearchBarUI extends C ); } - private hasFiltersOrQuery() { - const hasFilters = Boolean(this.props.filters && this.props.filters.length > 0); - const hasQuery = Boolean( - this.state.query && isOfQueryType(this.state.query) && this.state.query.query - ); - return hasFilters || hasQuery; - } - private renderSavedQueryManagement = memoizeOne( ( onClearSavedQuery: SearchBarOwnProps['onClearSavedQuery'], @@ -639,11 +657,11 @@ class SearchBarUI extends C this.setState({ openQueryBarMenu: false })} - hasFiltersOrQuery={this.hasFiltersOrQuery()} /> ); diff --git a/src/plugins/unified_search/public/types.ts b/src/plugins/unified_search/public/types.ts index 73c581e8f4c27..fa74c87884bb3 100755 --- a/src/plugins/unified_search/public/types.ts +++ b/src/plugins/unified_search/public/types.ts @@ -92,6 +92,9 @@ export interface IUnifiedSearchPluginServices extends Partial { notifications: CoreStart['notifications']; application: CoreStart['application']; http: CoreStart['http']; + analytics: CoreStart['analytics']; + i18n: CoreStart['i18n']; + theme: CoreStart['theme']; storage: IStorageWrapper; docLinks: DocLinksStart; data: DataPublicPluginStart; diff --git a/src/plugins/unified_search/tsconfig.json b/src/plugins/unified_search/tsconfig.json index d5842db6d1c58..7a70c4aafe2a3 100644 --- a/src/plugins/unified_search/tsconfig.json +++ b/src/plugins/unified_search/tsconfig.json @@ -44,6 +44,7 @@ "@kbn/ml-string-hash", "@kbn/code-editor", "@kbn/calculate-width-from-char-count", + "@kbn/react-kibana-context-render", ], "exclude": [ "target/**/*", diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index 4deb2acb66d74..8a4dc8de7a52b 100644 --- a/test/accessibility/apps/discover.ts +++ b/test/accessibility/apps/discover.ts @@ -109,7 +109,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.clickSavedQueriesPopOver(); await testSubjects.click('saved-query-management-load-button'); await savedQueryManagementComponent.deleteSavedQuery('test'); - await a11y.testAppSnapshot(); + await a11y.testAppSnapshot({ + // The saved query selectable search input has invalid aria attrs after + // the query is deleted and the `emptyMessage` is displayed, and it fails + // with this error, likely because the list is replaced by `emptyMessage`: + // [aria-valid-attr-value]: Ensures all ARIA attributes have valid values + excludeTestSubj: ['saved-query-management-search-input'], + }); }); // adding a11y tests for the new data grid diff --git a/test/api_integration/apis/saved_queries/index.js b/test/api_integration/apis/saved_queries/index.ts similarity index 77% rename from test/api_integration/apis/saved_queries/index.js rename to test/api_integration/apis/saved_queries/index.ts index 6f531e8026940..fd029c8764f01 100644 --- a/test/api_integration/apis/saved_queries/index.js +++ b/test/api_integration/apis/saved_queries/index.ts @@ -6,7 +6,9 @@ * Side Public License, v 1. */ -export default function ({ loadTestFile }) { +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { describe('Saved queries', () => { loadTestFile(require.resolve('./saved_queries')); }); diff --git a/test/api_integration/apis/saved_queries/saved_queries.js b/test/api_integration/apis/saved_queries/saved_queries.js deleted file mode 100644 index eb3c1465e24de..0000000000000 --- a/test/api_integration/apis/saved_queries/saved_queries.js +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ -import expect from '@kbn/expect'; -import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; -import { SAVED_QUERY_BASE_URL } from '@kbn/data-plugin/common'; - -// node scripts/functional_tests --config test/api_integration/config.js --grep="search session" - -const mockSavedQuery = { - title: 'my title', - description: 'my description', - query: { - query: 'foo: bar', - language: 'kql', - }, - filters: [], -}; - -export default function ({ getService }) { - const esArchiver = getService('esArchiver'); - const supertest = getService('supertest'); - void SAVED_QUERY_BASE_URL; - - describe('Saved queries API', function () { - before(async () => { - await esArchiver.emptyKibanaIndex(); - await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); - }); - - after(async () => { - await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); - }); - - it('should return 200 for create saved query', () => - supertest - .post(`${SAVED_QUERY_BASE_URL}/_create`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .send(mockSavedQuery) - .expect(200) - .then(({ body }) => { - expect(body.id).to.have.length(36); - expect(body.attributes.title).to.be('my title'); - expect(body.attributes.description).to.be('my description'); - })); - - it('should return 400 for create invalid saved query', () => - supertest - .post(`${SAVED_QUERY_BASE_URL}/_create`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .send({ description: 'my description' }) - .expect(400)); - - it('should return 200 for update saved query', () => - supertest - .post(`${SAVED_QUERY_BASE_URL}/_create`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .send(mockSavedQuery) - .expect(200) - .then(({ body }) => - supertest - .put(`${SAVED_QUERY_BASE_URL}/${body.id}`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .send({ - ...mockSavedQuery, - title: 'my new title', - }) - .expect(200) - .then((res) => { - expect(res.body.id).to.be(body.id); - expect(res.body.attributes.title).to.be('my new title'); - }) - )); - - it('should return 404 for update non-existent saved query', () => - supertest - .put(`${SAVED_QUERY_BASE_URL}/invalid_id`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .send(mockSavedQuery) - .expect(404)); - - it('should return 200 for get saved query', () => - supertest - .post(`${SAVED_QUERY_BASE_URL}/_create`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .send(mockSavedQuery) - .expect(200) - .then(({ body }) => - supertest - .get(`${SAVED_QUERY_BASE_URL}/${body.id}`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .expect(200) - .then((res) => { - expect(res.body.id).to.be(body.id); - expect(res.body.attributes.title).to.be(body.attributes.title); - }) - )); - - it('should return 404 for get non-existent saved query', () => - supertest - .get(`${SAVED_QUERY_BASE_URL}/invalid_id`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .expect(404)); - - it('should return 200 for saved query count', () => - supertest - .get(`${SAVED_QUERY_BASE_URL}/_count`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .expect(200)); - - it('should return 200 for find saved queries', () => - supertest - .post(`${SAVED_QUERY_BASE_URL}/_find`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .send({}) - .expect(200)); - - it('should return 400 for bad find saved queries request', () => - supertest - .post(`${SAVED_QUERY_BASE_URL}/_find`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .send({ foo: 'bar' }) - .expect(400)); - - it('should return 200 for find all saved queries', () => - supertest - .post(`${SAVED_QUERY_BASE_URL}/_all`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .expect(200)); - - it('should return 200 for delete saved query', () => - supertest - .post(`${SAVED_QUERY_BASE_URL}/_create`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .send(mockSavedQuery) - .expect(200) - .then(({ body }) => - supertest - .delete(`${SAVED_QUERY_BASE_URL}/${body.id}`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .expect(200) - )); - - it('should return 404 for get non-existent saved query', () => - supertest - .delete(`${SAVED_QUERY_BASE_URL}/invalid_id`) - .set(ELASTIC_HTTP_VERSION_HEADER, '1') - .expect(404)); - }); -} diff --git a/test/api_integration/apis/saved_queries/saved_queries.ts b/test/api_integration/apis/saved_queries/saved_queries.ts new file mode 100644 index 0000000000000..3134ab6b80fdb --- /dev/null +++ b/test/api_integration/apis/saved_queries/saved_queries.ts @@ -0,0 +1,426 @@ +/* + * 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 expect from '@kbn/expect'; +import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; +import { SavedQueryAttributes, SAVED_QUERY_BASE_URL } from '@kbn/data-plugin/common'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +// node scripts/functional_tests --config test/api_integration/config.js --grep="search session" + +const mockSavedQuery: SavedQueryAttributes = { + title: 'my title', + description: 'my description', + query: { + query: 'foo: bar', + language: 'kql', + }, + filters: [], +}; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + const createQuery = (query: Partial = mockSavedQuery) => + supertest + .post(`${SAVED_QUERY_BASE_URL}/_create`) + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .send(query); + + const updateQuery = (id: string, query: Partial = mockSavedQuery) => + supertest + .put(`${SAVED_QUERY_BASE_URL}/${id}`) + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .send(query); + + const deleteQuery = (id: string) => + supertest.delete(`${SAVED_QUERY_BASE_URL}/${id}`).set(ELASTIC_HTTP_VERSION_HEADER, '1'); + + const getQuery = (id: string) => + supertest.get(`${SAVED_QUERY_BASE_URL}/${id}`).set(ELASTIC_HTTP_VERSION_HEADER, '1'); + + const findQueries = (options: { search?: string; perPage?: number; page?: number } = {}) => + supertest + .post(`${SAVED_QUERY_BASE_URL}/_find`) + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .send(options); + + const countQueries = () => + supertest.get(`${SAVED_QUERY_BASE_URL}/_count`).set(ELASTIC_HTTP_VERSION_HEADER, '1'); + + const isDuplicateTitle = (title: string, id?: string) => + supertest + .post(`${SAVED_QUERY_BASE_URL}/_is_duplicate_title`) + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .send({ title, id }); + + describe('Saved queries API', function () { + before(async () => { + await esArchiver.emptyKibanaIndex(); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + }); + + after(async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + }); + + afterEach(async () => { + await kibanaServer.savedObjects.clean({ types: ['query'] }); + }); + + describe('create', () => { + it('should return 200 for create saved query', () => + createQuery() + .expect(200) + .then(({ body }) => { + expect(body.id).to.have.length(36); + expect(body.attributes.title).to.be('my title'); + expect(body.attributes.description).to.be('my description'); + })); + + it('should return 400 for create invalid saved query', () => + createQuery({ description: 'my description' }) + .expect(400) + .then(({ body }) => { + expect(body.message).to.be( + '[request body.title]: expected value of type [string] but got [undefined]' + ); + })); + + it('should return 400 for create saved query with duplicate title', () => + createQuery() + .expect(200) + .then(() => + createQuery() + .expect(400) + .then(({ body }) => { + expect(body.message).to.be('Query with title "my title" already exists'); + }) + )); + + it('should leave filters and timefilter undefined if not provided', () => + createQuery({ ...mockSavedQuery, filters: undefined, timefilter: undefined }) + .expect(200) + .then(({ body }) => + getQuery(body.id) + .expect(200) + .then(({ body: body2 }) => { + expect(body.attributes.filters).to.be(undefined); + expect(body.attributes.timefilter).to.be(undefined); + expect(body2.attributes.filters).to.be(undefined); + expect(body2.attributes.timefilter).to.be(undefined); + }) + )); + }); + + describe('update', () => { + it('should return 200 for update saved query', () => + createQuery() + .expect(200) + .then(({ body }) => + updateQuery(body.id, { + ...mockSavedQuery, + title: 'my updated title', + }) + .expect(200) + .then((res) => { + expect(res.body.id).to.be(body.id); + expect(res.body.attributes.title).to.be('my updated title'); + }) + )); + + it('should return 404 for update non-existent saved query', () => + updateQuery('invalid_id').expect(404)); + + it('should return 400 for update saved query with duplicate title', () => + createQuery() + .expect(200) + .then(({ body }) => + createQuery({ ...mockSavedQuery, title: 'my duplicate title' }) + .expect(200) + .then(() => + updateQuery(body.id, { ...mockSavedQuery, title: 'my duplicate title' }) + .expect(400) + .then(({ body: body2 }) => { + expect(body2.message).to.be( + 'Query with title "my duplicate title" already exists' + ); + }) + ) + )); + + it('should remove filters and timefilter if not provided', () => + createQuery({ + ...mockSavedQuery, + filters: [{ meta: {}, query: {} }], + timefilter: { + from: 'now-7d', + to: 'now', + refreshInterval: { + pause: false, + value: 60000, + }, + }, + }) + .expect(200) + .then(({ body }) => + updateQuery(body.id, { + ...mockSavedQuery, + filters: undefined, + timefilter: undefined, + }) + .expect(200) + .then(({ body: body2 }) => + getQuery(body2.id) + .expect(200) + .then(({ body: body3 }) => { + expect(body.attributes.filters).not.to.be(undefined); + expect(body.attributes.timefilter).not.to.be(undefined); + expect(body2.attributes.filters).to.be(undefined); + expect(body2.attributes.timefilter).to.be(undefined); + expect(body3.attributes.filters).to.be(undefined); + expect(body3.attributes.timefilter).to.be(undefined); + }) + ) + )); + }); + + describe('delete', () => { + it('should return 200 for delete saved query', () => + createQuery() + .expect(200) + .then(({ body }) => deleteQuery(body.id).expect(200))); + + it('should return 404 for delete non-existent saved query', () => + deleteQuery('invalid_id').expect(404)); + }); + + describe('get', () => { + it('should return 200 for get saved query', () => + createQuery() + .expect(200) + .then(({ body }) => + getQuery(body.id) + .expect(200) + .then((res) => { + expect(res.body.id).to.be(body.id); + expect(res.body.attributes.title).to.be(body.attributes.title); + }) + )); + + it('should return 404 for get non-existent saved query', () => + getQuery('invalid_id').expect(404)); + }); + + describe('find', () => { + it('should return 200 for find saved queries', () => findQueries().expect(200)); + + it('should return 400 for bad find saved queries request', () => + findQueries({ foo: 'bar' } as any) + .expect(400) + .then(({ body }) => { + expect(body.message).to.be('[request body.foo]: definition for this key is missing'); + })); + + it('should return expected queries for find saved queries', async () => { + await createQuery().expect(200); + + const result = await createQuery({ ...mockSavedQuery, title: 'my title 2' }).expect(200); + + await findQueries() + .expect(200) + .then((res) => { + expect(res.body.total).to.be(2); + expect(res.body.savedQueries.length).to.be(2); + expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([ + 'my title', + 'my title 2', + ]); + }); + + await deleteQuery(result.body.id).expect(200); + + await findQueries() + .expect(200) + .then((res) => { + expect(res.body.total).to.be(1); + expect(res.body.savedQueries.length).to.be(1); + expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql(['my title']); + }); + }); + + it('should return expected queries for find saved queries with a search', async () => { + await createQuery().expect(200); + await createQuery({ ...mockSavedQuery, title: 'my title 2' }).expect(200); + + const result = await createQuery({ ...mockSavedQuery, title: 'my title 2 again' }).expect( + 200 + ); + + await findQueries({ search: 'itle 2' }) + .expect(200) + .then((res) => { + expect(res.body.total).to.be(2); + expect(res.body.savedQueries.length).to.be(2); + expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([ + 'my title 2', + 'my title 2 again', + ]); + }); + + await deleteQuery(result.body.id).expect(200); + + await findQueries({ search: 'itle 2' }) + .expect(200) + .then((res) => { + expect(res.body.total).to.be(1); + expect(res.body.savedQueries.length).to.be(1); + expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([ + 'my title 2', + ]); + }); + }); + + it('should support pagination for find saved queries', async () => { + await createQuery().expect(200); + await createQuery({ ...mockSavedQuery, title: 'my title 2' }).expect(200); + await createQuery({ ...mockSavedQuery, title: 'my title 3' }).expect(200); + + await findQueries({ perPage: 2 }) + .expect(200) + .then((res) => { + expect(res.body.total).to.be(3); + expect(res.body.savedQueries.length).to.be(2); + expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([ + 'my title', + 'my title 2', + ]); + }); + + await findQueries({ perPage: 2, page: 2 }) + .expect(200) + .then((res) => { + expect(res.body.total).to.be(3); + expect(res.body.savedQueries.length).to.be(1); + expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([ + 'my title 3', + ]); + }); + }); + + it('should support pagination for find saved queries with a search', async () => { + await createQuery().expect(200); + await createQuery({ ...mockSavedQuery, title: 'my title 2' }).expect(200); + await createQuery({ ...mockSavedQuery, title: 'my title 3' }).expect(200); + await createQuery({ ...mockSavedQuery, title: 'not a match' }).expect(200); + + await findQueries({ perPage: 2, search: 'itle' }) + .expect(200) + .then((res) => { + expect(res.body.total).to.be(3); + expect(res.body.savedQueries.length).to.be(2); + expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([ + 'my title', + 'my title 2', + ]); + }); + + await findQueries({ perPage: 2, page: 2, search: 'itle' }) + .expect(200) + .then((res) => { + expect(res.body.total).to.be(3); + expect(res.body.savedQueries.length).to.be(1); + expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([ + 'my title 3', + ]); + }); + }); + + it('should support searching for queries containing special characters', async () => { + await createQuery({ ...mockSavedQuery, title: 'query <> title' }).expect(200); + + await findQueries({ search: 'ry <> ti' }) + .expect(200) + .then((res) => { + expect(res.body.total).to.be(1); + expect(res.body.savedQueries.length).to.be(1); + expect(res.body.savedQueries.map((q: any) => q.attributes.title)).to.eql([ + 'query <> title', + ]); + }); + }); + }); + + describe('count', () => { + it('should return 200 for saved query count', () => countQueries().expect(200)); + + it('should return expected counts for saved query count', async () => { + await countQueries() + .expect(200) + .then((res) => { + expect(res.text).to.be('0'); + }); + + await createQuery().expect(200); + + const result = await createQuery({ ...mockSavedQuery, title: 'my title 2' }).expect(200); + + await countQueries() + .expect(200) + .then((res) => { + expect(res.text).to.be('2'); + }); + + await deleteQuery(result.body.id).expect(200); + + await countQueries() + .expect(200) + .then((res) => { + expect(res.text).to.be('1'); + }); + }); + }); + + describe('isDuplicateTitle', () => { + it('should return isDuplicate = true for _is_duplicate_title check with a duplicate title', () => + createQuery() + .expect(200) + .then(({ body }) => + isDuplicateTitle(body.attributes.title) + .expect(200) + .then(({ body: body2 }) => { + expect(body2.isDuplicate).to.be(true); + }) + )); + + it('should return isDuplicate = false for _is_duplicate_title check with a duplicate title and matching ID', () => + createQuery() + .expect(200) + .then(({ body }) => + isDuplicateTitle(body.attributes.title, body.id) + .expect(200) + .then(({ body: body2 }) => { + expect(body2.isDuplicate).to.be(false); + }) + )); + + it('should return isDuplicate = false for _is_duplicate_title check with a unique title', () => + createQuery() + .expect(200) + .then(() => + isDuplicateTitle('my unique title') + .expect(200) + .then(({ body }) => { + expect(body.isDuplicate).to.be(false); + }) + )); + }); + }); +} diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 658e235c77d33..1d81faaf8a7fd 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -641,11 +641,17 @@ export class DiscoverPageObject extends FtrService { } public async saveCurrentSavedQuery() { - await this.testSubjects.click('savedQueryFormSaveButton'); + await this.testSubjects.existOrFail('savedQueryFormSaveButton'); + await this.retry.try(async () => { + if (await this.testSubjects.exists('savedQueryFormSaveButton')) { + await this.testSubjects.click('savedQueryFormSaveButton'); + } + await this.testSubjects.missingOrFail('queryBarMenuPanel'); + }); } public async deleteSavedQuery() { - await this.testSubjects.click('delete-saved-query-TEST-button'); + await this.testSubjects.click('delete-saved-query-button'); } public async confirmDeletionOfSavedQuery() { diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts index 7822ed8f77a89..fed8a2e66f601 100644 --- a/test/functional/services/saved_query_management_component.ts +++ b/test/functional/services/saved_query_management_component.ts @@ -87,6 +87,9 @@ export class SavedQueryManagementComponentService extends FtrService { await this.testSubjects.click('saved-query-management-load-button'); await this.testSubjects.click(`~load-saved-query-${title}-button`); await this.testSubjects.click('saved-query-management-apply-changes-button'); + await this.retry.try(async () => { + await this.testSubjects.missingOrFail('queryBarMenuPanel'); + }); await this.retry.try(async () => { await this.openSavedQueryManagementComponent(); const selectedSavedQueryText = await this.testSubjects.getVisibleText('savedQueryTitle'); @@ -105,7 +108,7 @@ export class SavedQueryManagementComponentService extends FtrService { } await this.testSubjects.click(`~load-saved-query-${title}-button`); await this.retry.waitFor('delete saved query', async () => { - await this.testSubjects.click(`delete-saved-query-${title}-button`); + await this.testSubjects.click(`delete-saved-query-button`); const exists = await this.testSubjects.exists('confirmModalTitleText'); return exists === true; }); @@ -149,6 +152,9 @@ export class SavedQueryManagementComponentService extends FtrService { } await this.testSubjects.click('savedQueryFormSaveButton'); + await this.retry.try(async () => { + await this.testSubjects.missingOrFail('saveQueryForm'); + }); } async savedQueryExist(title: string) { @@ -160,8 +166,8 @@ export class SavedQueryManagementComponentService extends FtrService { } async savedQueryExistOrFail(title: string) { - await this.openSavedQueryManagementComponent(); await this.retry.waitFor('load saved query', async () => { + await this.openSavedQueryManagementComponent(); const shouldClickLoadMenu = await this.testSubjects.exists( 'saved-query-management-load-button' ); diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index 67f35ef8c99f9..0186804edc814 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { isEqual } from 'lodash'; +import { cloneDeep, isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { isOfAggregateQueryType } from '@kbn/es-query'; @@ -856,7 +856,12 @@ export const LensTopNavMenu = ({ const onSavedQueryUpdatedWrapped = useCallback( (newSavedQuery) => { - const savedQueryFilters = newSavedQuery.attributes.filters || []; + // If the user tries to load the same saved query that is already loaded, + // we will receive the same object reference which was previously frozen + // by Redux Toolkit. `filterManager.setFilters` will then try to modify + // the query's filters, which will throw an error. To avoid this, we need + // to clone the filters before passing them to `filterManager.setFilters`. + const savedQueryFilters = cloneDeep(newSavedQuery.attributes.filters || []); const globalFilters = data.query.filterManager.getGlobalFilters(); data.query.filterManager.setFilters([...globalFilters, ...savedQueryFilters]); dispatchSetState({ diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index b2cda32060382..8525f40d47d19 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -146,18 +146,17 @@ export const createStartServicesMock = ( ...data.query, savedQueries: { ...data.query.savedQueries, - getAllSavedQueries: jest.fn(() => - Promise.resolve({ - id: '123', - attributes: { - total: 123, - }, - }) - ), findSavedQueries: jest.fn(() => Promise.resolve({ total: 123, - queries: [], + queries: [ + { + id: '123', + attributes: { + total: 123, + }, + }, + ], }) ), }, diff --git a/x-pack/plugins/threat_intelligence/public/mocks/test_providers.tsx b/x-pack/plugins/threat_intelligence/public/mocks/test_providers.tsx index 37360284b6aa7..57e1dee846c0a 100644 --- a/x-pack/plugins/threat_intelligence/public/mocks/test_providers.tsx +++ b/x-pack/plugins/threat_intelligence/public/mocks/test_providers.tsx @@ -71,18 +71,17 @@ const dataServiceMock = { ...data.query, savedQueries: { ...data.query.savedQueries, - getAllSavedQueries: jest.fn(() => - Promise.resolve({ - id: '123', - attributes: { - total: 123, - }, - }) - ), findSavedQueries: jest.fn(() => Promise.resolve({ total: 123, - queries: [], + queries: [ + { + id: '123', + attributes: { + total: 123, + }, + }, + ], }) ), }, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 9f2a66925c226..de90e90f58e0a 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -6200,19 +6200,16 @@ "unifiedSearch.queryBarTopRow.submitButton.run": "Exécuter la requête", "unifiedSearch.queryBarTopRow.submitButton.update": "Nécessite une mise à jour", "unifiedSearch.search.searchBar.savedQueryDescriptionText": "Enregistrez le texte et les filtres de la requête que vous souhaitez réutiliser.", - "unifiedSearch.search.searchBar.savedQueryForm.titleConflictText": "Ce nom est en conflit avec une requête existante", "unifiedSearch.search.searchBar.savedQueryForm.titleExistsText": "Un nom est requis.", "unifiedSearch.search.searchBar.savedQueryFormSaveButtonText": "Enregistrer la requête", "unifiedSearch.search.searchBar.savedQueryIncludeFiltersLabelText": "Inclure les filtres", "unifiedSearch.search.searchBar.savedQueryIncludeTimeFilterLabelText": "Inclure le filtre temporel", "unifiedSearch.search.searchBar.savedQueryMultipleNamespacesDeleteWarning": "Cette requête enregistrée est partagée sur plusieurs espaces. Si vous la supprimez, elle disparaît de tous les espaces où elle est partagée. Vous ne pouvez pas annuler cette action.", - "unifiedSearch.search.searchBar.savedQueryNameHelpText": "Le nom ne peut pas contenir d'espace au début ni à la fin, et il doit être unique.", "unifiedSearch.search.searchBar.savedQueryNameLabelText": "Nom", "unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText": "Aucune requête enregistrée.", "unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel": "Charger la requête", "unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionCancelButtonText": "Annuler", "unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionConfirmButtonText": "Supprimer", - "unifiedSearch.search.searchBar.savedQueryPopoverManageLabel": "Gérer les objets enregistrés", "unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonAriaLabel": "Enregistrer en tant que nouvelle requête", "unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText": "Enregistrer en tant que nouvelle", "unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText": "Mettre à jour la recherche", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 248e4fddb7f3c..deb0b7bceb6d1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6215,19 +6215,16 @@ "unifiedSearch.queryBarTopRow.submitButton.run": "クエリを実行", "unifiedSearch.queryBarTopRow.submitButton.update": "更新が必要です", "unifiedSearch.search.searchBar.savedQueryDescriptionText": "再度使用するクエリテキストとフィルターを保存します。", - "unifiedSearch.search.searchBar.savedQueryForm.titleConflictText": "名前が既存のクエリと競合しています", "unifiedSearch.search.searchBar.savedQueryForm.titleExistsText": "名前が必要です。", "unifiedSearch.search.searchBar.savedQueryFormSaveButtonText": "クエリを保存", "unifiedSearch.search.searchBar.savedQueryIncludeFiltersLabelText": "フィルターを含める", "unifiedSearch.search.searchBar.savedQueryIncludeTimeFilterLabelText": "時間フィルターを含める", "unifiedSearch.search.searchBar.savedQueryMultipleNamespacesDeleteWarning": "この保存されたクエリは複数のスペースで共有されます。削除すると、それが共有されているすべてのスペースから削除されます。この操作は元に戻すことができません。", - "unifiedSearch.search.searchBar.savedQueryNameHelpText": "名前の始めと終わりにはスペースを使用できません。名前は一意でなければなりません。", "unifiedSearch.search.searchBar.savedQueryNameLabelText": "名前", "unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText": "保存されたクエリがありません。", "unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel": "クエリを読み込む", "unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionCancelButtonText": "キャンセル", "unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionConfirmButtonText": "削除", - "unifiedSearch.search.searchBar.savedQueryPopoverManageLabel": "保存されたオブジェクトを管理", "unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonAriaLabel": "新しいクエリとして保存", "unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText": "新規保存", "unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText": "クエリの更新", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6c23e8d2f26a3..2393a8e4391db 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6308,19 +6308,16 @@ "unifiedSearch.queryBarTopRow.submitButton.run": "运行查询", "unifiedSearch.queryBarTopRow.submitButton.update": "需要更新", "unifiedSearch.search.searchBar.savedQueryDescriptionText": "保存想要再次使用的查询文本和筛选。", - "unifiedSearch.search.searchBar.savedQueryForm.titleConflictText": "名称与现有查询有冲突", "unifiedSearch.search.searchBar.savedQueryForm.titleExistsText": "“名称”必填。", "unifiedSearch.search.searchBar.savedQueryFormSaveButtonText": "保存查询", "unifiedSearch.search.searchBar.savedQueryIncludeFiltersLabelText": "包括筛选", "unifiedSearch.search.searchBar.savedQueryIncludeTimeFilterLabelText": "包括时间筛选", "unifiedSearch.search.searchBar.savedQueryMultipleNamespacesDeleteWarning": "此已保存查询将在多个工作区中共享。如果将其删除,则会从进行共享的每个工作区中删除该项。此操作无法撤消。", - "unifiedSearch.search.searchBar.savedQueryNameHelpText": "名称不能包含前导或尾随空格,并且必须唯一。", "unifiedSearch.search.searchBar.savedQueryNameLabelText": "名称", "unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText": "无已保存查询。", "unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel": "加载查询", "unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionCancelButtonText": "取消", "unifiedSearch.search.searchBar.savedQueryPopoverConfirmDeletionConfirmButtonText": "删除", - "unifiedSearch.search.searchBar.savedQueryPopoverManageLabel": "管理已保存对象", "unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonAriaLabel": "另存为新查询", "unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText": "另存为新的", "unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText": "更新查询", diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/saved_queries.ts b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/saved_queries.ts index 0ec356e83727c..2105a9b57d9b9 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/saved_queries.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/api_calls/saved_queries.ts @@ -45,7 +45,7 @@ export const deleteSavedQueries = () => { const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_\*`; rootRequest({ method: 'POST', - url: `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed`, + url: `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed&refresh`, body: { query: { bool: {