From 45972374f06a6189ec9e225fd00b191838f33e52 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 18 Nov 2024 21:53:46 +0100 Subject: [PATCH] [ES|QL] Starred queries in the editor (#198362) ## Summary close https://github.com/elastic/kibana/issues/194165 close https://github.com/elastic/kibana-team/issues/1245 ### User-facing image This PRs adds a new tab in the editor history component. You can star your query from the history and then you will see it in the Starred list. The started queries are scoped to a user and a space. ### Server To allow starring ESQL query, this PR extends [favorites service](https://github.com/elastic/kibana/pull/189285) with ability to store metadata in addition to an id. To make metadata strict and in future to support proper metadata migrations if needed, metadata needs to be defined as schema: ``` plugins.contentManagement.favorites.registerFavoriteType('esql_query', { typeMetadataSchema: schema.object({ query: schema.string(), timeRange:...., etc... }), }) ``` Notable changes: - Add support for registering a favorite type and a schema for favorite type metadata. Previosly the `dashboard` type was the only supported type and was hardcoded - Add `favoriteMetadata` property to a saved object mapping and make it `enabled:false` we don't want to index it, but just want to store metadata in addition to an id. [code](https://github.com/elastic/kibana/pull/198362/files#diff-d1a39e36f1de11a1110520d7607e6aee7d506c76626993842cb58db012b760a2R74-R87) - Add a 100 favorite items limit (per type per space per user). Just do it for sanity to prevent too large objects due to metadata stored in addtion to ids. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Stratoula Kalafateli Co-authored-by: Stratoula Kalafateli --- .github/CODEOWNERS | 1 + package.json | 1 + .../favorites/favorites_common/README.md | 3 + .../favorites/favorites_common/index.ts | 11 + .../favorites/favorites_common/jest.config.js | 14 + .../favorites/favorites_common/kibana.jsonc | 5 + .../favorites/favorites_common/package.json | 6 + .../favorites/favorites_common/tsconfig.json | 17 + .../favorites_public/src/favorites_client.ts | 36 +- .../favorites_public/src/favorites_query.tsx | 5 +- .../favorites/favorites_server/index.ts | 8 +- .../src/favorites_registry.ts | 50 +++ .../favorites_server/src/favorites_routes.ts | 102 ++++- .../src/favorites_saved_object.ts | 20 + .../favorites_server/src/favorites_service.ts | 89 ++++- .../favorites/favorites_server/src/index.ts | 20 +- .../favorites/favorites_server/tsconfig.json | 1 + .../current_fields.json | 1 + .../current_mappings.json | 4 + .../discard_starred_query_modal.tsx | 109 +++++ .../discard_starred_query/index.tsx | 20 + .../esql_starred_queries_service.test.tsx | 203 ++++++++++ .../esql_starred_queries_service.tsx | 241 +++++++++++ ...t.tsx => history_starred_queries.test.tsx} | 135 ++++++- ...istory.tsx => history_starred_queries.tsx} | 290 ++++++++++++-- ...> history_starred_queries_helpers.test.ts} | 2 +- ....ts => history_starred_queries_helpers.ts} | 11 +- .../src/editor_footer/index.tsx | 7 +- .../src/editor_footer/tooltip_wrapper.tsx | 36 ++ packages/kbn-esql-editor/src/esql_editor.tsx | 5 +- .../src/history_local_storage.test.ts | 4 - .../src/history_local_storage.ts | 46 ++- packages/kbn-esql-editor/src/types.ts | 4 + packages/kbn-esql-editor/tsconfig.json | 4 + .../check_registered_types.test.ts | 2 +- .../content_management/server/plugin.test.ts | 2 +- .../content_management/server/plugin.ts | 7 +- .../content_management/server/types.ts | 6 +- src/plugins/dashboard/server/plugin.ts | 2 + src/plugins/esql/kibana.jsonc | 5 +- src/plugins/esql/public/kibana_services.ts | 10 +- src/plugins/esql/public/plugin.ts | 16 +- src/plugins/esql/server/plugin.ts | 12 +- src/plugins/esql/tsconfig.json | 5 +- .../apps/discover/esql/_esql_view.ts | 27 +- test/functional/services/esql.ts | 38 +- tsconfig.base.json | 2 + .../.storybook/mock_kibana_services.ts | 25 +- .../translations/translations/fr-FR.json | 4 +- .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- .../apis/content_management/favorites.ts | 374 +++++++++++++----- .../functional/apps/discover/esql_starred.ts | 143 +++++++ x-pack/test/functional/apps/discover/index.ts | 1 + .../common/discover/esql/_esql_view.ts | 29 +- yarn.lock | 4 + 56 files changed, 1954 insertions(+), 279 deletions(-) create mode 100644 packages/content-management/favorites/favorites_common/README.md create mode 100644 packages/content-management/favorites/favorites_common/index.ts create mode 100644 packages/content-management/favorites/favorites_common/jest.config.js create mode 100644 packages/content-management/favorites/favorites_common/kibana.jsonc create mode 100644 packages/content-management/favorites/favorites_common/package.json create mode 100644 packages/content-management/favorites/favorites_common/tsconfig.json create mode 100644 packages/content-management/favorites/favorites_server/src/favorites_registry.ts create mode 100644 packages/kbn-esql-editor/src/editor_footer/discard_starred_query/discard_starred_query_modal.tsx create mode 100644 packages/kbn-esql-editor/src/editor_footer/discard_starred_query/index.tsx create mode 100644 packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.test.tsx create mode 100644 packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx rename packages/kbn-esql-editor/src/editor_footer/{query_history.test.tsx => history_starred_queries.test.tsx} (52%) rename packages/kbn-esql-editor/src/editor_footer/{query_history.tsx => history_starred_queries.tsx} (54%) rename packages/kbn-esql-editor/src/editor_footer/{query_history_helpers.test.ts => history_starred_queries_helpers.test.ts} (93%) rename packages/kbn-esql-editor/src/editor_footer/{query_history_helpers.ts => history_starred_queries_helpers.ts} (86%) create mode 100644 packages/kbn-esql-editor/src/editor_footer/tooltip_wrapper.tsx create mode 100644 x-pack/test/functional/apps/discover/esql_starred.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a3c447a15b858..c5603dd514c33 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -47,6 +47,7 @@ packages/cloud @elastic/kibana-core packages/content-management/content_editor @elastic/appex-sharedux packages/content-management/content_insights/content_insights_public @elastic/appex-sharedux packages/content-management/content_insights/content_insights_server @elastic/appex-sharedux +packages/content-management/favorites/favorites_common @elastic/appex-sharedux packages/content-management/favorites/favorites_public @elastic/appex-sharedux packages/content-management/favorites/favorites_server @elastic/appex-sharedux packages/content-management/tabbed_table_list_view @elastic/appex-sharedux diff --git a/package.json b/package.json index 4c04880c56aa1..a8c60004e6040 100644 --- a/package.json +++ b/package.json @@ -232,6 +232,7 @@ "@kbn/content-management-content-insights-public": "link:packages/content-management/content_insights/content_insights_public", "@kbn/content-management-content-insights-server": "link:packages/content-management/content_insights/content_insights_server", "@kbn/content-management-examples-plugin": "link:examples/content_management_examples", + "@kbn/content-management-favorites-common": "link:packages/content-management/favorites/favorites_common", "@kbn/content-management-favorites-public": "link:packages/content-management/favorites/favorites_public", "@kbn/content-management-favorites-server": "link:packages/content-management/favorites/favorites_server", "@kbn/content-management-plugin": "link:src/plugins/content_management", diff --git a/packages/content-management/favorites/favorites_common/README.md b/packages/content-management/favorites/favorites_common/README.md new file mode 100644 index 0000000000000..61608fa380e20 --- /dev/null +++ b/packages/content-management/favorites/favorites_common/README.md @@ -0,0 +1,3 @@ +# @kbn/content-management-favorites-common + +Shared client & server code for the favorites packages. diff --git a/packages/content-management/favorites/favorites_common/index.ts b/packages/content-management/favorites/favorites_common/index.ts new file mode 100644 index 0000000000000..05ad1fa0b9cef --- /dev/null +++ b/packages/content-management/favorites/favorites_common/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +// Limit the number of favorites to prevent too large objects due to metadata +export const FAVORITES_LIMIT = 100; diff --git a/packages/content-management/favorites/favorites_common/jest.config.js b/packages/content-management/favorites/favorites_common/jest.config.js new file mode 100644 index 0000000000000..c8b618b4f4ac6 --- /dev/null +++ b/packages/content-management/favorites/favorites_common/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../../../..', + roots: ['/packages/content-management/favorites/favorites_common'], +}; diff --git a/packages/content-management/favorites/favorites_common/kibana.jsonc b/packages/content-management/favorites/favorites_common/kibana.jsonc new file mode 100644 index 0000000000000..69e13e656639b --- /dev/null +++ b/packages/content-management/favorites/favorites_common/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/content-management-favorites-common", + "owner": "@elastic/appex-sharedux" +} diff --git a/packages/content-management/favorites/favorites_common/package.json b/packages/content-management/favorites/favorites_common/package.json new file mode 100644 index 0000000000000..cb3a685ebc064 --- /dev/null +++ b/packages/content-management/favorites/favorites_common/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/content-management-favorites-common", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/packages/content-management/favorites/favorites_common/tsconfig.json b/packages/content-management/favorites/favorites_common/tsconfig.json new file mode 100644 index 0000000000000..0d78dace105e1 --- /dev/null +++ b/packages/content-management/favorites/favorites_common/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/packages/content-management/favorites/favorites_public/src/favorites_client.ts b/packages/content-management/favorites/favorites_public/src/favorites_client.ts index 3b3d439caecda..84c44db5fd64c 100644 --- a/packages/content-management/favorites/favorites_public/src/favorites_client.ts +++ b/packages/content-management/favorites/favorites_public/src/favorites_client.ts @@ -9,36 +9,52 @@ import type { HttpStart } from '@kbn/core-http-browser'; import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; -import type { GetFavoritesResponse } from '@kbn/content-management-favorites-server'; +import type { + GetFavoritesResponse as GetFavoritesResponseServer, + AddFavoriteResponse, + RemoveFavoriteResponse, +} from '@kbn/content-management-favorites-server'; -export interface FavoritesClientPublic { - getFavorites(): Promise; - addFavorite({ id }: { id: string }): Promise; - removeFavorite({ id }: { id: string }): Promise; +export interface GetFavoritesResponse + extends GetFavoritesResponseServer { + favoriteMetadata: Metadata extends object ? Record : never; +} + +type AddFavoriteRequest = Metadata extends object + ? { id: string; metadata: Metadata } + : { id: string }; + +export interface FavoritesClientPublic { + getFavorites(): Promise>; + addFavorite(params: AddFavoriteRequest): Promise; + removeFavorite(params: { id: string }): Promise; getFavoriteType(): string; reportAddFavoriteClick(): void; reportRemoveFavoriteClick(): void; } -export class FavoritesClient implements FavoritesClientPublic { +export class FavoritesClient + implements FavoritesClientPublic +{ constructor( private readonly appName: string, private readonly favoriteObjectType: string, private readonly deps: { http: HttpStart; usageCollection?: UsageCollectionStart } ) {} - public async getFavorites(): Promise { + public async getFavorites(): Promise> { return this.deps.http.get(`/internal/content_management/favorites/${this.favoriteObjectType}`); } - public async addFavorite({ id }: { id: string }): Promise { + public async addFavorite(params: AddFavoriteRequest): Promise { return this.deps.http.post( - `/internal/content_management/favorites/${this.favoriteObjectType}/${id}/favorite` + `/internal/content_management/favorites/${this.favoriteObjectType}/${params.id}/favorite`, + { body: 'metadata' in params ? JSON.stringify({ metadata: params.metadata }) : undefined } ); } - public async removeFavorite({ id }: { id: string }): Promise { + public async removeFavorite({ id }: { id: string }): Promise { return this.deps.http.post( `/internal/content_management/favorites/${this.favoriteObjectType}/${id}/unfavorite` ); diff --git a/packages/content-management/favorites/favorites_public/src/favorites_query.tsx b/packages/content-management/favorites/favorites_public/src/favorites_query.tsx index e3ca1e4ed202d..63e8ad3a7ef75 100644 --- a/packages/content-management/favorites/favorites_public/src/favorites_query.tsx +++ b/packages/content-management/favorites/favorites_public/src/favorites_query.tsx @@ -11,6 +11,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import type { IHttpFetchError } from '@kbn/core-http-browser'; import { useFavoritesClient, useFavoritesContext } from './favorites_context'; const favoritesKeys = { @@ -54,14 +55,14 @@ export const useAddFavorite = () => { onSuccess: (data) => { queryClient.setQueryData(favoritesKeys.byType(favoritesClient!.getFavoriteType()), data); }, - onError: (error: Error) => { + onError: (error: IHttpFetchError<{ message?: string }>) => { notifyError?.( <> {i18n.translate('contentManagement.favorites.addFavoriteError', { defaultMessage: 'Error adding to Starred', })} , - error?.message + error?.body?.message ?? error.message ); }, } diff --git a/packages/content-management/favorites/favorites_server/index.ts b/packages/content-management/favorites/favorites_server/index.ts index bcb8d0bffba8c..2810102d9165c 100644 --- a/packages/content-management/favorites/favorites_server/index.ts +++ b/packages/content-management/favorites/favorites_server/index.ts @@ -7,4 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { registerFavorites, type GetFavoritesResponse } from './src'; +export { + registerFavorites, + type GetFavoritesResponse, + type FavoritesSetup, + type AddFavoriteResponse, + type RemoveFavoriteResponse, +} from './src'; diff --git a/packages/content-management/favorites/favorites_server/src/favorites_registry.ts b/packages/content-management/favorites/favorites_server/src/favorites_registry.ts new file mode 100644 index 0000000000000..53fc6dc4b5260 --- /dev/null +++ b/packages/content-management/favorites/favorites_server/src/favorites_registry.ts @@ -0,0 +1,50 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import { ObjectType } from '@kbn/config-schema'; + +interface FavoriteTypeConfig { + typeMetadataSchema?: ObjectType; +} + +export type FavoritesRegistrySetup = Pick; + +export class FavoritesRegistry { + private favoriteTypes = new Map(); + + registerFavoriteType(type: string, config: FavoriteTypeConfig = {}) { + if (this.favoriteTypes.has(type)) { + throw new Error(`Favorite type ${type} is already registered`); + } + + this.favoriteTypes.set(type, config); + } + + hasType(type: string) { + return this.favoriteTypes.has(type); + } + + validateMetadata(type: string, metadata?: object) { + if (!this.hasType(type)) { + throw new Error(`Favorite type ${type} is not registered`); + } + + const typeConfig = this.favoriteTypes.get(type)!; + const typeMetadataSchema = typeConfig.typeMetadataSchema; + + if (typeMetadataSchema) { + typeMetadataSchema.validate(metadata); + } else { + if (metadata === undefined) { + return; /* ok */ + } else { + throw new Error(`Favorite type ${type} does not support metadata`); + } + } + } +} diff --git a/packages/content-management/favorites/favorites_server/src/favorites_routes.ts b/packages/content-management/favorites/favorites_server/src/favorites_routes.ts index 663d0181f3806..512b2cbe1260e 100644 --- a/packages/content-management/favorites/favorites_server/src/favorites_routes.ts +++ b/packages/content-management/favorites/favorites_server/src/favorites_routes.ts @@ -14,12 +14,9 @@ import { SECURITY_EXTENSION_ID, } from '@kbn/core/server'; import { schema } from '@kbn/config-schema'; -import { FavoritesService } from './favorites_service'; +import { FavoritesService, FavoritesLimitExceededError } from './favorites_service'; import { favoritesSavedObjectType } from './favorites_saved_object'; - -// only dashboard is supported for now -// TODO: make configurable or allow any string -const typeSchema = schema.oneOf([schema.literal('dashboard')]); +import { FavoritesRegistry } from './favorites_registry'; /** * @public @@ -27,9 +24,45 @@ const typeSchema = schema.oneOf([schema.literal('dashboard')]); */ export interface GetFavoritesResponse { favoriteIds: string[]; + favoriteMetadata?: Record; +} + +export interface AddFavoriteResponse { + favoriteIds: string[]; } -export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; logger: Logger }) { +export interface RemoveFavoriteResponse { + favoriteIds: string[]; +} + +export function registerFavoritesRoutes({ + core, + logger, + favoritesRegistry, +}: { + core: CoreSetup; + logger: Logger; + favoritesRegistry: FavoritesRegistry; +}) { + const typeSchema = schema.string({ + validate: (type) => { + if (!favoritesRegistry.hasType(type)) { + return `Unknown favorite type: ${type}`; + } + }, + }); + + const metadataSchema = schema.maybe( + schema.object( + { + // validated later by the registry depending on the type + }, + { + unknowns: 'allow', + } + ) + ); + const router = core.http.createRouter(); const getSavedObjectClient = (coreRequestHandlerContext: CoreRequestHandlerContext) => { @@ -49,6 +82,13 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log id: schema.string(), type: typeSchema, }), + body: schema.maybe( + schema.nullable( + schema.object({ + metadata: metadataSchema, + }) + ) + ), }, // we don't protect the route with any access tags as // we only give access to the current user's favorites ids @@ -67,13 +107,35 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log const favorites = new FavoritesService(type, userId, { savedObjectClient: getSavedObjectClient(coreRequestHandlerContext), logger, + favoritesRegistry, }); - const favoriteIds: GetFavoritesResponse = await favorites.addFavorite({ - id: request.params.id, - }); + const id = request.params.id; + const metadata = request.body?.metadata; - return response.ok({ body: favoriteIds }); + try { + favoritesRegistry.validateMetadata(type, metadata); + } catch (e) { + return response.badRequest({ body: { message: e.message } }); + } + + try { + const favoritesResult = await favorites.addFavorite({ + id, + metadata, + }); + const addFavoritesResponse: AddFavoriteResponse = { + favoriteIds: favoritesResult.favoriteIds, + }; + + return response.ok({ body: addFavoritesResponse }); + } catch (e) { + if (e instanceof FavoritesLimitExceededError) { + return response.forbidden({ body: { message: e.message } }); + } + + throw e; // unexpected error, let the global error handler deal with it + } } ); @@ -102,12 +164,18 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log const favorites = new FavoritesService(type, userId, { savedObjectClient: getSavedObjectClient(coreRequestHandlerContext), logger, + favoritesRegistry, }); - const favoriteIds: GetFavoritesResponse = await favorites.removeFavorite({ + const favoritesResult: GetFavoritesResponse = await favorites.removeFavorite({ id: request.params.id, }); - return response.ok({ body: favoriteIds }); + + const removeFavoriteResponse: RemoveFavoriteResponse = { + favoriteIds: favoritesResult.favoriteIds, + }; + + return response.ok({ body: removeFavoriteResponse }); } ); @@ -135,12 +203,18 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log const favorites = new FavoritesService(type, userId, { savedObjectClient: getSavedObjectClient(coreRequestHandlerContext), logger, + favoritesRegistry, }); - const getFavoritesResponse: GetFavoritesResponse = await favorites.getFavorites(); + const favoritesResult = await favorites.getFavorites(); + + const favoritesResponse: GetFavoritesResponse = { + favoriteIds: favoritesResult.favoriteIds, + favoriteMetadata: favoritesResult.favoriteMetadata, + }; return response.ok({ - body: getFavoritesResponse, + body: favoritesResponse, }); } ); diff --git a/packages/content-management/favorites/favorites_server/src/favorites_saved_object.ts b/packages/content-management/favorites/favorites_server/src/favorites_saved_object.ts index 73cd3b3ca185f..776133f408975 100644 --- a/packages/content-management/favorites/favorites_server/src/favorites_saved_object.ts +++ b/packages/content-management/favorites/favorites_server/src/favorites_saved_object.ts @@ -14,6 +14,7 @@ export interface FavoritesSavedObjectAttributes { userId: string; type: string; favoriteIds: string[]; + favoriteMetadata?: Record; } const schemaV1 = schema.object({ @@ -22,6 +23,10 @@ const schemaV1 = schema.object({ favoriteIds: schema.arrayOf(schema.string()), }); +const schemaV3 = schemaV1.extends({ + favoriteMetadata: schema.maybe(schema.object({}, { unknowns: 'allow' })), +}); + export const favoritesSavedObjectName = 'favorites'; export const favoritesSavedObjectType: SavedObjectsType = { @@ -34,6 +39,7 @@ export const favoritesSavedObjectType: SavedObjectsType = { userId: { type: 'keyword' }, type: { type: 'keyword' }, favoriteIds: { type: 'keyword' }, + favoriteMetadata: { type: 'object', dynamic: false }, }, }, modelVersions: { @@ -65,5 +71,19 @@ export const favoritesSavedObjectType: SavedObjectsType = { create: schemaV1, }, }, + 3: { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + favoriteMetadata: { type: 'object', dynamic: false }, + }, + }, + ], + schemas: { + forwardCompatibility: schemaV3.extends({}, { unknowns: 'ignore' }), + create: schemaV3, + }, + }, }, }; diff --git a/packages/content-management/favorites/favorites_server/src/favorites_service.ts b/packages/content-management/favorites/favorites_server/src/favorites_service.ts index 41c9b10f05507..6258e66897fa3 100644 --- a/packages/content-management/favorites/favorites_server/src/favorites_service.ts +++ b/packages/content-management/favorites/favorites_server/src/favorites_service.ts @@ -7,9 +7,17 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +// eslint-disable-next-line max-classes-per-file import type { SavedObject, SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import { FAVORITES_LIMIT } from '@kbn/content-management-favorites-common'; import { Logger, SavedObjectsErrorHelpers } from '@kbn/core/server'; import { favoritesSavedObjectType, FavoritesSavedObjectAttributes } from './favorites_saved_object'; +import { FavoritesRegistry } from './favorites_registry'; + +export interface FavoritesState { + favoriteIds: string[]; + favoriteMetadata?: Record; +} export class FavoritesService { constructor( @@ -18,23 +26,38 @@ export class FavoritesService { private readonly deps: { savedObjectClient: SavedObjectsClientContract; logger: Logger; + favoritesRegistry: FavoritesRegistry; } ) { if (!this.userId || !this.type) { // This should never happen, but just in case let's do a runtime check throw new Error('userId and object type are required to use a favorite service'); } + + if (!this.deps.favoritesRegistry.hasType(this.type)) { + throw new Error(`Favorite type ${this.type} is not registered`); + } } - public async getFavorites(): Promise<{ favoriteIds: string[] }> { + public async getFavorites(): Promise { const favoritesSavedObject = await this.getFavoritesSavedObject(); const favoriteIds = favoritesSavedObject?.attributes?.favoriteIds ?? []; + const favoriteMetadata = favoritesSavedObject?.attributes?.favoriteMetadata; - return { favoriteIds }; + return { favoriteIds, favoriteMetadata }; } - public async addFavorite({ id }: { id: string }): Promise<{ favoriteIds: string[] }> { + /** + * @throws {FavoritesLimitExceededError} + */ + public async addFavorite({ + id, + metadata, + }: { + id: string; + metadata?: object; + }): Promise { let favoritesSavedObject = await this.getFavoritesSavedObject(); if (!favoritesSavedObject) { @@ -44,14 +67,28 @@ export class FavoritesService { userId: this.userId, type: this.type, favoriteIds: [id], + ...(metadata + ? { + favoriteMetadata: { + [id]: metadata, + }, + } + : {}), }, { id: this.getFavoriteSavedObjectId(), } ); - return { favoriteIds: favoritesSavedObject.attributes.favoriteIds }; + return { + favoriteIds: favoritesSavedObject.attributes.favoriteIds, + favoriteMetadata: favoritesSavedObject.attributes.favoriteMetadata, + }; } else { + if ((favoritesSavedObject.attributes.favoriteIds ?? []).length >= FAVORITES_LIMIT) { + throw new FavoritesLimitExceededError(); + } + const newFavoriteIds = [ ...(favoritesSavedObject.attributes.favoriteIds ?? []).filter( (favoriteId) => favoriteId !== id @@ -59,22 +96,34 @@ export class FavoritesService { id, ]; + const newFavoriteMetadata = metadata + ? { + ...favoritesSavedObject.attributes.favoriteMetadata, + [id]: metadata, + } + : undefined; + await this.deps.savedObjectClient.update( favoritesSavedObjectType.name, favoritesSavedObject.id, { favoriteIds: newFavoriteIds, + ...(newFavoriteMetadata + ? { + favoriteMetadata: newFavoriteMetadata, + } + : {}), }, { version: favoritesSavedObject.version, } ); - return { favoriteIds: newFavoriteIds }; + return { favoriteIds: newFavoriteIds, favoriteMetadata: newFavoriteMetadata }; } } - public async removeFavorite({ id }: { id: string }): Promise<{ favoriteIds: string[] }> { + public async removeFavorite({ id }: { id: string }): Promise { const favoritesSavedObject = await this.getFavoritesSavedObject(); if (!favoritesSavedObject) { @@ -85,19 +134,36 @@ export class FavoritesService { (favoriteId) => favoriteId !== id ); + const newFavoriteMetadata = favoritesSavedObject.attributes.favoriteMetadata + ? { ...favoritesSavedObject.attributes.favoriteMetadata } + : undefined; + + if (newFavoriteMetadata) { + delete newFavoriteMetadata[id]; + } + await this.deps.savedObjectClient.update( favoritesSavedObjectType.name, favoritesSavedObject.id, { + ...favoritesSavedObject.attributes, favoriteIds: newFavoriteIds, + ...(newFavoriteMetadata + ? { + favoriteMetadata: newFavoriteMetadata, + } + : {}), }, { version: favoritesSavedObject.version, + // We don't want to merge the attributes here because we want to remove the keys from the metadata + mergeAttributes: false, } ); return { favoriteIds: newFavoriteIds, + favoriteMetadata: newFavoriteMetadata, }; } @@ -123,3 +189,14 @@ export class FavoritesService { return `${this.type}:${this.userId}`; } } + +export class FavoritesLimitExceededError extends Error { + constructor() { + super( + `Limit reached: This list can contain a maximum of ${FAVORITES_LIMIT} items. Please remove an item before adding a new one.` + ); + + this.name = 'FavoritesLimitExceededError'; + Object.setPrototypeOf(this, FavoritesLimitExceededError.prototype); // For TypeScript compatibility + } +} diff --git a/packages/content-management/favorites/favorites_server/src/index.ts b/packages/content-management/favorites/favorites_server/src/index.ts index d6cdd51285b38..44e3b9f259a33 100644 --- a/packages/content-management/favorites/favorites_server/src/index.ts +++ b/packages/content-management/favorites/favorites_server/src/index.ts @@ -12,8 +12,19 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import { registerFavoritesRoutes } from './favorites_routes'; import { favoritesSavedObjectType } from './favorites_saved_object'; import { registerFavoritesUsageCollection } from './favorites_usage_collection'; +import { FavoritesRegistry, FavoritesRegistrySetup } from './favorites_registry'; -export type { GetFavoritesResponse } from './favorites_routes'; +export type { + GetFavoritesResponse, + AddFavoriteResponse, + RemoveFavoriteResponse, +} from './favorites_routes'; + +/** + * @public + * Setup contract for the favorites feature. + */ +export type FavoritesSetup = FavoritesRegistrySetup; /** * @public @@ -31,11 +42,14 @@ export function registerFavorites({ core: CoreSetup; logger: Logger; usageCollection?: UsageCollectionSetup; -}) { +}): FavoritesSetup { + const favoritesRegistry = new FavoritesRegistry(); core.savedObjects.registerType(favoritesSavedObjectType); - registerFavoritesRoutes({ core, logger }); + registerFavoritesRoutes({ core, logger, favoritesRegistry }); if (usageCollection) { registerFavoritesUsageCollection({ core, usageCollection }); } + + return favoritesRegistry; } diff --git a/packages/content-management/favorites/favorites_server/tsconfig.json b/packages/content-management/favorites/favorites_server/tsconfig.json index 5a9ae392c875b..bbab19ade978b 100644 --- a/packages/content-management/favorites/favorites_server/tsconfig.json +++ b/packages/content-management/favorites/favorites_server/tsconfig.json @@ -19,5 +19,6 @@ "@kbn/core-saved-objects-api-server", "@kbn/core-lifecycle-server", "@kbn/usage-collection-plugin", + "@kbn/content-management-favorites-common", ] } diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 5493b8dc3bbdb..a5642cee10958 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -443,6 +443,7 @@ ], "favorites": [ "favoriteIds", + "favoriteMetadata", "type", "userId" ], diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 726b6e9e1d4c5..61f680509c133 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1509,6 +1509,10 @@ "favoriteIds": { "type": "keyword" }, + "favoriteMetadata": { + "dynamic": false, + "type": "object" + }, "type": { "type": "keyword" }, diff --git a/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/discard_starred_query_modal.tsx b/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/discard_starred_query_modal.tsx new file mode 100644 index 0000000000000..5efa3a1469354 --- /dev/null +++ b/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/discard_starred_query_modal.tsx @@ -0,0 +1,109 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiButton, + EuiButtonEmpty, + EuiText, + EuiCheckbox, + EuiFlexItem, + EuiFlexGroup, + EuiHorizontalRule, +} from '@elastic/eui'; + +export interface DiscardStarredQueryModalProps { + onClose: (dismissFlag?: boolean, removeQuery?: boolean) => Promise; +} +// Needed for React.lazy +// eslint-disable-next-line import/no-default-export +export default function DiscardStarredQueryModal({ onClose }: DiscardStarredQueryModalProps) { + const [dismissModalChecked, setDismissModalChecked] = useState(false); + const onTransitionModalDismiss = useCallback((e: React.ChangeEvent) => { + setDismissModalChecked(e.target.checked); + }, []); + + return ( + onClose()} + style={{ width: 700 }} + data-test-subj="discard-starred-query-modal" + > + + + {i18n.translate('esqlEditor.discardStarredQueryModal.title', { + defaultMessage: 'Discard starred query', + })} + + + + + + {i18n.translate('esqlEditor.discardStarredQueryModal.body', { + defaultMessage: + 'Removing a starred query will remove it from the list. This has no impact on the recent query history.', + })} + + + + + + + + + + + + { + await onClose(dismissModalChecked, false); + }} + color="primary" + data-test-subj="esqlEditor-discard-starred-query-cancel-btn" + > + {i18n.translate('esqlEditor.discardStarredQueryModal.cancelLabel', { + defaultMessage: 'Cancel', + })} + + + + { + await onClose(dismissModalChecked, true); + }} + color="danger" + iconType="trash" + data-test-subj="esqlEditor-discard-starred-query-discard-btn" + > + {i18n.translate('esqlEditor.discardStarredQueryModal.discardQueryLabel', { + defaultMessage: 'Discard query', + })} + + + + + + + + ); +} diff --git a/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/index.tsx b/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/index.tsx new file mode 100644 index 0000000000000..544b251c76754 --- /dev/null +++ b/packages/kbn-esql-editor/src/editor_footer/discard_starred_query/index.tsx @@ -0,0 +1,20 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import type { DiscardStarredQueryModalProps } from './discard_starred_query_modal'; + +const Fallback = () =>
; + +const LazyDiscardStarredQueryModal = React.lazy(() => import('./discard_starred_query_modal')); +export const DiscardStarredQueryModal = (props: DiscardStarredQueryModalProps) => ( + }> + + +); diff --git a/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.test.tsx b/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.test.tsx new file mode 100644 index 0000000000000..fca4d95c6f6cb --- /dev/null +++ b/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.test.tsx @@ -0,0 +1,203 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EsqlStarredQueriesService } from './esql_starred_queries_service'; +import { coreMock } from '@kbn/core/public/mocks'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; + +class LocalStorageMock { + public store: Record; + constructor(defaultStore: Record) { + this.store = defaultStore; + } + clear() { + this.store = {}; + } + get(key: string) { + return this.store[key] || null; + } + set(key: string, value: unknown) { + this.store[key] = String(value); + } + remove(key: string) { + delete this.store[key]; + } +} + +describe('EsqlStarredQueriesService', () => { + const core = coreMock.createStart(); + const storage = new LocalStorageMock({}) as unknown as Storage; + + it('should initialize', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + expect(service).toBeDefined(); + expect(service.queries$.value).toEqual([]); + }); + + it('should add a new starred query', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: 'SELECT * FROM test', + timeRan: '2021-09-01T00:00:00Z', + status: 'success' as const, + }; + + await service.addStarredQuery(query); + expect(service.queries$.value).toEqual([ + { + id: expect.any(String), + ...query, + // stores now() + timeRan: expect.any(String), + }, + ]); + }); + + it('should not add the same query twice', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: 'SELECT * FROM test', + timeRan: '2021-09-01T00:00:00Z', + status: 'success' as const, + }; + + const expected = { + id: expect.any(String), + ...query, + // stores now() + timeRan: expect.any(String), + // trimmed query + queryString: 'SELECT * FROM test', + }; + + await service.addStarredQuery(query); + expect(service.queries$.value).toEqual([expected]); + + // second time + await service.addStarredQuery(query); + expect(service.queries$.value).toEqual([expected]); + }); + + it('should add the query trimmed', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: `SELECT * FROM test | + WHERE field != 'value'`, + timeRan: '2021-09-01T00:00:00Z', + status: 'error' as const, + }; + + await service.addStarredQuery(query); + expect(service.queries$.value).toEqual([ + { + id: expect.any(String), + ...query, + timeRan: expect.any(String), + // trimmed query + queryString: `SELECT * FROM test | WHERE field != 'value'`, + }, + ]); + }); + + it('should remove a query', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: `SELECT * FROM test | WHERE field != 'value'`, + timeRan: '2021-09-01T00:00:00Z', + status: 'error' as const, + }; + + await service.addStarredQuery(query); + expect(service.queries$.value).toEqual([ + { + id: expect.any(String), + ...query, + timeRan: expect.any(String), + // trimmed query + queryString: `SELECT * FROM test | WHERE field != 'value'`, + }, + ]); + + await service.removeStarredQuery(query.queryString); + expect(service.queries$.value).toEqual([]); + }); + + it('should return the button correctly', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: 'SELECT * FROM test', + timeRan: '2021-09-01T00:00:00Z', + status: 'success' as const, + }; + + await service.addStarredQuery(query); + const buttonWithTooltip = service.renderStarredButton(query); + const button = buttonWithTooltip.props.children; + expect(button.props.title).toEqual('Remove ES|QL query from Starred'); + expect(button.props.iconType).toEqual('starFilled'); + }); + + it('should display the modal when the Remove button is clicked', async () => { + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: 'SELECT * FROM test', + timeRan: '2021-09-01T00:00:00Z', + status: 'success' as const, + }; + + await service.addStarredQuery(query); + const buttonWithTooltip = service.renderStarredButton(query); + const button = buttonWithTooltip.props.children; + expect(button.props.title).toEqual('Remove ES|QL query from Starred'); + button.props.onClick(); + + expect(service.discardModalVisibility$.value).toEqual(true); + }); + + it('should NOT display the modal when Remove the button is clicked but the user has dismissed the modal permanently', async () => { + storage.set('esqlEditor.starredQueriesDiscard', true); + const service = await EsqlStarredQueriesService.initialize({ + http: core.http, + storage, + }); + const query = { + queryString: 'SELECT * FROM test', + timeRan: '2021-09-01T00:00:00Z', + status: 'success' as const, + }; + + await service.addStarredQuery(query); + const buttonWithTooltip = service.renderStarredButton(query); + const button = buttonWithTooltip.props.children; + button.props.onClick(); + + expect(service.discardModalVisibility$.value).toEqual(false); + }); +}); diff --git a/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx b/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx new file mode 100644 index 0000000000000..80ef716cfd4b0 --- /dev/null +++ b/packages/kbn-esql-editor/src/editor_footer/esql_starred_queries_service.tsx @@ -0,0 +1,241 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { i18n } from '@kbn/i18n'; +import { v4 as uuidv4 } from 'uuid'; +import type { CoreStart } from '@kbn/core/public'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; +import { EuiButtonIcon } from '@elastic/eui'; +import { FavoritesClient } from '@kbn/content-management-favorites-public'; +import { FAVORITES_LIMIT as ESQL_STARRED_QUERIES_LIMIT } from '@kbn/content-management-favorites-common'; +import { type QueryHistoryItem, getTrimmedQuery } from '../history_local_storage'; +import { TooltipWrapper } from './tooltip_wrapper'; + +const STARRED_QUERIES_DISCARD_KEY = 'esqlEditor.starredQueriesDiscard'; + +/** + * EsqlStarredQueriesService is a service that manages the starred queries in the ES|QL editor. + * It provides methods to add and remove queries from the starred list. + * It also provides a method to render the starred button in the editor list table. + * + * @param client - The FavoritesClient instance. + * @param starredQueries - The list of starred queries. + * @param queries$ - The BehaviorSubject that emits the starred queries list. + * @method initialize - Initializes the service and retrieves the starred queries from the favoriteService. + * @method checkIfQueryIsStarred - Checks if a query is already starred. + * @method addStarredQuery - Adds a query to the starred list. + * @method removeStarredQuery - Removes a query from the starred list. + * @method renderStarredButton - Renders the starred button in the editor list table. + * @returns EsqlStarredQueriesService instance. + * + */ +export interface StarredQueryItem extends QueryHistoryItem { + id: string; +} + +interface EsqlStarredQueriesServices { + http: CoreStart['http']; + storage: Storage; + usageCollection?: UsageCollectionStart; +} + +interface EsqlStarredQueriesParams { + client: FavoritesClient; + starredQueries: StarredQueryItem[]; + storage: Storage; +} + +function generateId() { + return uuidv4(); +} + +interface StarredQueryMetadata { + queryString: string; + createdAt: string; + status: 'success' | 'warning' | 'error'; +} + +export class EsqlStarredQueriesService { + private client: FavoritesClient; + private starredQueries: StarredQueryItem[] = []; + private queryToEdit: string = ''; + private storage: Storage; + queries$: BehaviorSubject; + discardModalVisibility$: BehaviorSubject = new BehaviorSubject(false); + + constructor({ client, starredQueries, storage }: EsqlStarredQueriesParams) { + this.client = client; + this.starredQueries = starredQueries; + this.queries$ = new BehaviorSubject(starredQueries); + this.storage = storage; + } + + static async initialize(services: EsqlStarredQueriesServices) { + const client = new FavoritesClient('esql_editor', 'esql_query', { + http: services.http, + usageCollection: services.usageCollection, + }); + + const { favoriteMetadata } = (await client?.getFavorites()) || {}; + const retrievedQueries: StarredQueryItem[] = []; + + if (!favoriteMetadata) { + return new EsqlStarredQueriesService({ + client, + starredQueries: [], + storage: services.storage, + }); + } + Object.keys(favoriteMetadata).forEach((id) => { + const item = favoriteMetadata[id]; + const { queryString, createdAt, status } = item; + retrievedQueries.push({ id, queryString, timeRan: createdAt, status }); + }); + + return new EsqlStarredQueriesService({ + client, + starredQueries: retrievedQueries, + storage: services.storage, + }); + } + + private checkIfQueryIsStarred(queryString: string) { + return this.starredQueries.some((item) => item.queryString === queryString); + } + + private checkIfStarredQueriesLimitReached() { + return this.starredQueries.length >= ESQL_STARRED_QUERIES_LIMIT; + } + + async addStarredQuery(item: Pick) { + const favoriteItem: { id: string; metadata: StarredQueryMetadata } = { + id: generateId(), + metadata: { + queryString: getTrimmedQuery(item.queryString), + createdAt: new Date().toISOString(), + status: item.status ?? 'success', + }, + }; + + // do not add the query if it's already starred or has reached the limit + if ( + this.checkIfQueryIsStarred(favoriteItem.metadata.queryString) || + this.checkIfStarredQueriesLimitReached() + ) { + return; + } + + const starredQueries = [...this.starredQueries]; + + starredQueries.push({ + queryString: favoriteItem.metadata.queryString, + timeRan: favoriteItem.metadata.createdAt, + status: favoriteItem.metadata.status, + id: favoriteItem.id, + }); + this.queries$.next(starredQueries); + this.starredQueries = starredQueries; + await this.client.addFavorite(favoriteItem); + + // telemetry, add favorite click event + this.client.reportAddFavoriteClick(); + } + + async removeStarredQuery(queryString: string) { + const trimmedQueryString = getTrimmedQuery(queryString); + const favoriteItem = this.starredQueries.find( + (item) => item.queryString === trimmedQueryString + ); + + if (!favoriteItem) { + return; + } + + this.starredQueries = this.starredQueries.filter( + (item) => item.queryString !== trimmedQueryString + ); + this.queries$.next(this.starredQueries); + + await this.client.removeFavorite({ id: favoriteItem.id }); + + // telemetry, remove favorite click event + this.client.reportRemoveFavoriteClick(); + } + + async onDiscardModalClose(shouldDismissModal?: boolean, removeQuery?: boolean) { + if (shouldDismissModal) { + // set the local storage flag to not show the modal again + this.storage.set(STARRED_QUERIES_DISCARD_KEY, true); + } + this.discardModalVisibility$.next(false); + + if (removeQuery) { + // remove the query + await this.removeStarredQuery(this.queryToEdit); + } + } + + renderStarredButton(item: QueryHistoryItem) { + const trimmedQueryString = getTrimmedQuery(item.queryString); + const isStarred = this.checkIfQueryIsStarred(trimmedQueryString); + return ( + + { + this.queryToEdit = trimmedQueryString; + if (isStarred) { + // show the discard modal only if the user has not dismissed it + if (!this.storage.get(STARRED_QUERIES_DISCARD_KEY)) { + this.discardModalVisibility$.next(true); + } else { + await this.removeStarredQuery(item.queryString); + } + } else { + await this.addStarredQuery(item); + } + }} + data-test-subj="ESQLFavoriteButton" + /> + + ); + } +} diff --git a/packages/kbn-esql-editor/src/editor_footer/query_history.test.tsx b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries.test.tsx similarity index 52% rename from packages/kbn-esql-editor/src/editor_footer/query_history.test.tsx rename to packages/kbn-esql-editor/src/editor_footer/history_starred_queries.test.tsx index df41e2a2d3b91..9e0d586622c31 100644 --- a/packages/kbn-esql-editor/src/editor_footer/query_history.test.tsx +++ b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries.test.tsx @@ -8,8 +8,15 @@ */ import React from 'react'; -import { QueryHistoryAction, getTableColumns, QueryColumn } from './query_history'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { coreMock } from '@kbn/core/public/mocks'; import { render, screen } from '@testing-library/react'; +import { + QueryHistoryAction, + getTableColumns, + QueryColumn, + HistoryAndStarredQueriesTabs, +} from './history_starred_queries'; jest.mock('../history_local_storage', () => { const module = jest.requireActual('../history_local_storage'); @@ -18,7 +25,6 @@ jest.mock('../history_local_storage', () => { getHistoryItems: () => [ { queryString: 'from kibana_sample_data_flights | limit 10', - timeZone: 'Browser', timeRan: 'Mar. 25, 24 08:45:27', queryRunning: false, status: 'success', @@ -27,7 +33,7 @@ jest.mock('../history_local_storage', () => { }; }); -describe('QueryHistory', () => { +describe('Starred and History queries components', () => { describe('QueryHistoryAction', () => { it('should render the history action component as a button if is spaceReduced is undefined', () => { render(); @@ -47,9 +53,14 @@ describe('QueryHistory', () => { }); describe('getTableColumns', () => { - it('should get the history table columns correctly', async () => { + it('should get the table columns correctly', async () => { const columns = getTableColumns(50, false, []); expect(columns).toEqual([ + { + 'data-test-subj': 'favoriteBtn', + render: expect.anything(), + width: '40px', + }, { css: { height: '100%', @@ -64,7 +75,7 @@ describe('QueryHistory', () => { { 'data-test-subj': 'queryString', field: 'queryString', - name: 'Recent queries', + name: 'Query', render: expect.anything(), }, { @@ -83,11 +94,58 @@ describe('QueryHistory', () => { }, ]); }); + + it('should get the table columns correctly for the starred list', async () => { + const columns = getTableColumns(50, false, [], true); + expect(columns).toEqual([ + { + 'data-test-subj': 'favoriteBtn', + render: expect.anything(), + width: '40px', + }, + { + css: { + height: '100%', + }, + 'data-test-subj': 'status', + field: 'status', + name: '', + render: expect.anything(), + sortable: false, + width: '40px', + }, + { + 'data-test-subj': 'queryString', + field: 'queryString', + name: 'Query', + render: expect.anything(), + }, + { + 'data-test-subj': 'timeRan', + field: 'timeRan', + name: 'Date Added', + render: expect.anything(), + sortable: true, + width: '240px', + }, + { + actions: [], + 'data-test-subj': 'actions', + name: '', + width: '60px', + }, + ]); + }); }); it('should get the history table columns correctly for reduced space', async () => { const columns = getTableColumns(50, true, []); expect(columns).toEqual([ + { + 'data-test-subj': 'favoriteBtn', + render: expect.anything(), + width: 'auto', + }, { css: { height: '100%', @@ -110,7 +168,7 @@ describe('QueryHistory', () => { { 'data-test-subj': 'queryString', field: 'queryString', - name: 'Recent queries', + name: 'Query', render: expect.anything(), }, { @@ -132,7 +190,7 @@ describe('QueryHistory', () => { /> ); expect( - screen.queryByTestId('ESQLEditor-queryHistory-queryString-expanded') + screen.queryByTestId('ESQLEditor-queryList-queryString-expanded') ).not.toBeInTheDocument(); }); @@ -152,9 +210,66 @@ describe('QueryHistory', () => { isOnReducedSpaceLayout={true} /> ); - expect( - screen.getByTestId('ESQLEditor-queryHistory-queryString-expanded') - ).toBeInTheDocument(); + expect(screen.getByTestId('ESQLEditor-queryList-queryString-expanded')).toBeInTheDocument(); + }); + }); + + describe('HistoryAndStarredQueriesTabs', () => { + const services = { + core: coreMock.createStart(), + }; + it('should render two tabs', () => { + render( + + + + ); + expect(screen.getByTestId('history-queries-tab')).toBeInTheDocument(); + expect(screen.getByTestId('history-queries-tab')).toHaveTextContent('Recent'); + expect(screen.getByTestId('starred-queries-tab')).toBeInTheDocument(); + expect(screen.getByTestId('starred-queries-tab')).toHaveTextContent('Starred'); + }); + + it('should render the history queries tab by default', () => { + render( + + + + ); + expect(screen.getByTestId('ESQLEditor-queryHistory')).toBeInTheDocument(); + expect(screen.getByTestId('ESQLEditor-history-starred-queries-helpText')).toHaveTextContent( + 'Showing last 20 queries' + ); + }); + + it('should render the starred queries if the corresponding btn is clicked', () => { + render( + + + + ); + // click the starred queries tab + screen.getByTestId('starred-queries-tab').click(); + + expect(screen.getByTestId('ESQLEditor-starredQueries')).toBeInTheDocument(); + expect(screen.getByTestId('ESQLEditor-history-starred-queries-helpText')).toHaveTextContent( + 'Showing 0 queries (max 100)' + ); }); }); }); diff --git a/packages/kbn-esql-editor/src/editor_footer/query_history.tsx b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries.tsx similarity index 54% rename from packages/kbn-esql-editor/src/editor_footer/query_history.tsx rename to packages/kbn-esql-editor/src/editor_footer/history_starred_queries.tsx index 7316a5b49ddea..c24d0a0b1817b 100644 --- a/packages/kbn-esql-editor/src/editor_footer/query_history.tsx +++ b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries.tsx @@ -6,8 +6,8 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ - -import React, { useState, useRef, useEffect, useMemo } from 'react'; +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import moment from 'moment'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, @@ -22,11 +22,26 @@ import { EuiCopy, EuiToolTip, euiScrollBarStyles, + EuiTab, + EuiTabs, + EuiNotificationBadge, + EuiText, } from '@elastic/eui'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { cssFavoriteHoverWithinEuiTableRow } from '@kbn/content-management-favorites-public'; +import { FAVORITES_LIMIT as ESQL_STARRED_QUERIES_LIMIT } from '@kbn/content-management-favorites-common'; import { css, Interpolation, Theme } from '@emotion/react'; import { useEuiTablePersist } from '@kbn/shared-ux-table-persist'; -import { type QueryHistoryItem, getHistoryItems } from '../history_local_storage'; -import { getReducedSpaceStyling, swapArrayElements } from './query_history_helpers'; +import { + type QueryHistoryItem, + getHistoryItems, + MAX_HISTORY_QUERIES_NUMBER, + dateFormat, +} from '../history_local_storage'; +import type { ESQLEditorDeps } from '../types'; +import { getReducedSpaceStyling, swapArrayElements } from './history_starred_queries_helpers'; +import { EsqlStarredQueriesService, StarredQueryItem } from './esql_starred_queries_service'; +import { DiscardStarredQueryModal } from './discard_starred_query'; export function QueryHistoryAction({ toggleHistory, @@ -99,9 +114,22 @@ export function QueryHistoryAction({ export const getTableColumns = ( width: number, isOnReducedSpaceLayout: boolean, - actions: Array> + actions: Array>, + isStarredTab = false, + starredQueriesService?: EsqlStarredQueriesService ): Array> => { const columnsArray = [ + { + 'data-test-subj': 'favoriteBtn', + render: (item: QueryHistoryItem) => { + const StarredQueryButton = starredQueriesService?.renderStarredButton(item); + if (!StarredQueryButton) { + return null; + } + return StarredQueryButton; + }, + width: isOnReducedSpaceLayout ? 'auto' : '40px', + }, { field: 'status', name: '', @@ -167,7 +195,7 @@ export const getTableColumns = ( field: 'queryString', 'data-test-subj': 'queryString', name: i18n.translate('esqlEditor.query.recentQueriesColumnLabel', { - defaultMessage: 'Recent queries', + defaultMessage: 'Query', }), render: (queryString: QueryHistoryItem['queryString']) => ( timeRan, + render: (timeRan: QueryHistoryItem['timeRan']) => moment(timeRan).format(dateFormat), width: isOnReducedSpaceLayout ? 'auto' : '240px', }, { @@ -196,22 +228,33 @@ export const getTableColumns = ( ]; // I need to swap the elements here to get the desired design - return isOnReducedSpaceLayout ? swapArrayElements(columnsArray, 1, 2) : columnsArray; + return isOnReducedSpaceLayout ? swapArrayElements(columnsArray, 2, 3) : columnsArray; }; -export function QueryHistory({ +export function QueryList({ containerCSS, containerWidth, onUpdateAndSubmit, height, + listItems, + starredQueriesService, + tableCaption, + dataTestSubj, + isStarredTab = false, }: { + listItems: QueryHistoryItem[]; containerCSS: Interpolation; containerWidth: number; onUpdateAndSubmit: (qs: string) => void; height: number; + starredQueriesService?: EsqlStarredQueriesService; + tableCaption?: string; + dataTestSubj?: string; + isStarredTab?: boolean; }) { const theme = useEuiTheme(); const scrollBarStyles = euiScrollBarStyles(theme); + const [isDiscardQueryModalVisible, setIsDiscardQueryModalVisible] = useState(false); const { sorting, onTableChange } = useEuiTablePersist({ tableId: 'esqlQueryHistory', @@ -221,8 +264,6 @@ export function QueryHistory({ }, }); - const historyItems: QueryHistoryItem[] = getHistoryItems(sorting.sort.direction); - const actions: Array> = useMemo(() => { return [ { @@ -232,16 +273,16 @@ export function QueryHistory({ onUpdateAndSubmit(item.queryString)} @@ -254,7 +295,7 @@ export function QueryHistory({ @@ -266,7 +307,7 @@ export function QueryHistory({ css={css` cursor: pointer; `} - aria-label={i18n.translate('esqlEditor.query.querieshistoryCopy', { + aria-label={i18n.translate('esqlEditor.query.esqlQueriesCopy', { defaultMessage: 'Copy query to clipboard', })} /> @@ -279,14 +320,23 @@ export function QueryHistory({ }, ]; }, [onUpdateAndSubmit]); + const isOnReducedSpaceLayout = containerWidth < 560; const columns = useMemo(() => { - return getTableColumns(containerWidth, isOnReducedSpaceLayout, actions); - }, [actions, containerWidth, isOnReducedSpaceLayout]); + return getTableColumns( + containerWidth, + isOnReducedSpaceLayout, + actions, + isStarredTab, + starredQueriesService + ); + }, [containerWidth, isOnReducedSpaceLayout, actions, isStarredTab, starredQueriesService]); const { euiTheme } = theme; const extraStyling = isOnReducedSpaceLayout ? getReducedSpaceStyling() : ''; + const starredQueriesCellStyling = cssFavoriteHoverWithinEuiTableRow(theme.euiTheme); + const tableStyling = css` .euiTable { background-color: ${euiTheme.colors.lightestShade}; @@ -305,22 +355,40 @@ export function QueryHistory({ overflow-y: auto; ${scrollBarStyles} ${extraStyling} + ${starredQueriesCellStyling} `; + starredQueriesService?.discardModalVisibility$.subscribe((nextVisibility) => { + if (isDiscardQueryModalVisible !== nextVisibility) { + setIsDiscardQueryModalVisible(nextVisibility); + } + }); + return ( -
+
+ {isDiscardQueryModalVisible && ( + + (await starredQueriesService?.onDiscardModalClose(dismissFlag, removeQuery)) ?? + Promise.resolve() + } + /> + )}
); } @@ -354,7 +422,7 @@ export function QueryColumn({ onClick={() => { setIsRowExpanded(!isRowExpanded); }} - data-test-subj="ESQLEditor-queryHistory-queryString-expanded" + data-test-subj="ESQLEditor-queryList-queryString-expanded" aria-label={ isRowExpanded ? i18n.translate('esqlEditor.query.collapseLabel', { @@ -387,3 +455,171 @@ export function QueryColumn({ ); } + +export function HistoryAndStarredQueriesTabs({ + containerCSS, + containerWidth, + onUpdateAndSubmit, + height, +}: { + containerCSS: Interpolation; + containerWidth: number; + onUpdateAndSubmit: (qs: string) => void; + height: number; +}) { + const kibana = useKibana(); + const { core, usageCollection, storage } = kibana.services; + + const [starredQueriesService, setStarredQueriesService] = useState(); + const [starredQueries, setStarredQueries] = useState([]); + + useEffect(() => { + const initializeService = async () => { + const starredService = await EsqlStarredQueriesService.initialize({ + http: core.http, + usageCollection, + storage, + }); + setStarredQueriesService(starredService); + }; + if (!starredQueriesService) { + initializeService(); + } + }, [core.http, starredQueriesService, storage, usageCollection]); + + starredQueriesService?.queries$.subscribe((nextQueries) => { + if (nextQueries.length !== starredQueries.length) { + setStarredQueries(nextQueries); + } + }); + + const { euiTheme } = useEuiTheme(); + const tabs = useMemo(() => { + return [ + { + id: 'history-queries-tab', + name: i18n.translate('esqlEditor.query.historyQueriesTabLabel', { + defaultMessage: 'Recent', + }), + dataTestSubj: 'history-queries-tab', + content: ( + + ), + }, + { + id: 'starred-queries-tab', + dataTestSubj: 'starred-queries-tab', + name: i18n.translate('esqlEditor.query.starredQueriesTabLabel', { + defaultMessage: 'Starred', + }), + append: ( + + {starredQueries?.length} + + ), + content: ( + + ), + }, + ]; + }, [ + containerCSS, + containerWidth, + height, + onUpdateAndSubmit, + starredQueries, + starredQueriesService, + ]); + + const [selectedTabId, setSelectedTabId] = useState('history-queries-tab'); + const selectedTabContent = useMemo(() => { + return tabs.find((obj) => obj.id === selectedTabId)?.content; + }, [selectedTabId, tabs]); + + const onSelectedTabChanged = (id: string) => { + setSelectedTabId(id); + }; + + const renderTabs = useCallback(() => { + return tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + append={tab.append} + data-test-subj={tab.dataTestSubj} + > + {tab.name} + + )); + }, [selectedTabId, tabs]); + + return ( + <> + + + {renderTabs()} + + + + +

+ {selectedTabId === 'history-queries-tab' + ? i18n.translate('esqlEditor.history.historyItemslimit', { + defaultMessage: 'Showing last {historyItemsLimit} queries', + values: { historyItemsLimit: MAX_HISTORY_QUERIES_NUMBER }, + }) + : i18n.translate('esqlEditor.history.starredItemslimit', { + defaultMessage: + 'Showing {starredItemsCount} queries (max {starredItemsLimit})', + values: { + starredItemsLimit: ESQL_STARRED_QUERIES_LIMIT, + starredItemsCount: starredQueries.length ?? 0, + }, + })} +

+
+
+
+
+ {selectedTabContent} + + ); +} diff --git a/packages/kbn-esql-editor/src/editor_footer/query_history_helpers.test.ts b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.test.ts similarity index 93% rename from packages/kbn-esql-editor/src/editor_footer/query_history_helpers.test.ts rename to packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.test.ts index 43a676aba56b5..ad33cd3687fae 100644 --- a/packages/kbn-esql-editor/src/editor_footer/query_history_helpers.test.ts +++ b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.test.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { swapArrayElements } from './query_history_helpers'; +import { swapArrayElements } from './history_starred_queries_helpers'; describe('query history helpers', function () { it('should swap 2 elements in an array', function () { diff --git a/packages/kbn-esql-editor/src/editor_footer/query_history_helpers.ts b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.ts similarity index 86% rename from packages/kbn-esql-editor/src/editor_footer/query_history_helpers.ts rename to packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.ts index c55bc0801ec66..2f7d4419d13c9 100644 --- a/packages/kbn-esql-editor/src/editor_footer/query_history_helpers.ts +++ b/packages/kbn-esql-editor/src/editor_footer/history_starred_queries_helpers.ts @@ -19,16 +19,19 @@ export const getReducedSpaceStyling = () => { } .euiTable thead tr { display: grid; - grid-template-columns: 40px 1fr 0 auto; + grid-template-columns: 40px 40px 1fr 0 auto; } .euiTable tbody tr { display: grid; - grid-template-columns: 40px 1fr auto; + grid-template-columns: 40px 40px 1fr auto; grid-template-areas: - 'status timeRan lastDuration actions' - '. queryString queryString queryString'; + 'favoriteBtn status timeRan lastDuration actions' + '. . queryString queryString queryString'; } /* Set grid template areas */ + .euiTable td[data-test-subj='favoriteBtn'] { + grid-area: favoriteBtn; + } .euiTable td[data-test-subj='status'] { grid-area: status; } diff --git a/packages/kbn-esql-editor/src/editor_footer/index.tsx b/packages/kbn-esql-editor/src/editor_footer/index.tsx index d898d2c52c9c7..4e60e65f19ca4 100644 --- a/packages/kbn-esql-editor/src/editor_footer/index.tsx +++ b/packages/kbn-esql-editor/src/editor_footer/index.tsx @@ -8,7 +8,6 @@ */ import React, { memo, useState, useCallback, useMemo } from 'react'; - import { i18n } from '@kbn/i18n'; import { EuiText, @@ -27,7 +26,7 @@ import { import { getLimitFromESQLQuery } from '@kbn/esql-utils'; import { type MonacoMessage } from '../helpers'; import { ErrorsWarningsFooterPopover } from './errors_warnings_popover'; -import { QueryHistoryAction, QueryHistory } from './query_history'; +import { QueryHistoryAction, HistoryAndStarredQueriesTabs } from './history_starred_queries'; import { SubmitFeedbackComponent } from './feedback_component'; import { QueryWrapComponent } from './query_wrap_component'; import type { ESQLEditorDeps } from '../types'; @@ -60,7 +59,6 @@ interface EditorFooterProps { isSpaceReduced?: boolean; hideTimeFilterInfo?: boolean; hideQueryHistory?: boolean; - isInCompactMode?: boolean; displayDocumentationAsFlyout?: boolean; } @@ -84,7 +82,6 @@ export const EditorFooter = memo(function EditorFooter({ isLanguageComponentOpen, setIsLanguageComponentOpen, hideQueryHistory, - isInCompactMode, displayDocumentationAsFlyout, measuredContainerWidth, code, @@ -310,7 +307,7 @@ export const EditorFooter = memo(function EditorFooter({ {isHistoryOpen && ( - > & { + tooltipContent: string; + /** When the condition is truthy, the tooltip will be shown */ + condition: boolean; +}; + +export const TooltipWrapper: React.FunctionComponent = ({ + children, + condition, + tooltipContent, + ...tooltipProps +}) => { + return ( + <> + {condition ? ( + + <>{children} + + ) : ( + children + )} + + ); +}; diff --git a/packages/kbn-esql-editor/src/esql_editor.tsx b/packages/kbn-esql-editor/src/esql_editor.tsx index 636bb0b13ff17..767bc9026348c 100644 --- a/packages/kbn-esql-editor/src/esql_editor.tsx +++ b/packages/kbn-esql-editor/src/esql_editor.tsx @@ -99,7 +99,7 @@ export const ESQLEditor = memo(function ESQLEditor({ uiSettings, } = kibana.services; const darkMode = core.theme?.getTheme().darkMode; - const timeZone = uiSettings?.get('dateFormat:tz'); + const histogramBarTarget = uiSettings?.get('histogram:barTarget') ?? 50; const [code, setCode] = useState(query.esql ?? ''); // To make server side errors less "sticky", register the state of the code when submitting @@ -464,11 +464,10 @@ export const ESQLEditor = memo(function ESQLEditor({ validateQuery(); addQueriesToCache({ queryString: code, - timeZone, status: clientParserStatus, }); } - }, [clientParserStatus, isLoading, isQueryLoading, parseMessages, code, timeZone]); + }, [clientParserStatus, isLoading, isQueryLoading, parseMessages, code]); const queryValidation = useCallback( async ({ active }: { active: boolean }) => { diff --git a/packages/kbn-esql-editor/src/history_local_storage.test.ts b/packages/kbn-esql-editor/src/history_local_storage.test.ts index c149dada84894..4632bd124f80d 100644 --- a/packages/kbn-esql-editor/src/history_local_storage.test.ts +++ b/packages/kbn-esql-editor/src/history_local_storage.test.ts @@ -20,7 +20,6 @@ describe('history local storage', function () { it('should add queries to cache correctly ', function () { addQueriesToCache({ queryString: 'from kibana_sample_data_flights | limit 10', - timeZone: 'Browser', }); const historyItems = getCachedQueries(); expect(historyItems.length).toBe(1); @@ -31,7 +30,6 @@ describe('history local storage', function () { it('should update queries to cache correctly ', function () { addQueriesToCache({ queryString: 'from kibana_sample_data_flights \n | limit 10 \n | stats meow = avg(woof)', - timeZone: 'Browser', status: 'success', }); @@ -49,7 +47,6 @@ describe('history local storage', function () { it('should update queries to cache correctly if they are the same with different format', function () { addQueriesToCache({ queryString: 'from kibana_sample_data_flights | limit 10 | stats meow = avg(woof) ', - timeZone: 'Browser', status: 'success', }); @@ -68,7 +65,6 @@ describe('history local storage', function () { addQueriesToCache( { queryString: 'row 1', - timeZone: 'Browser', status: 'success', }, 2 diff --git a/packages/kbn-esql-editor/src/history_local_storage.ts b/packages/kbn-esql-editor/src/history_local_storage.ts index c79561d5d3875..46dd770d8d897 100644 --- a/packages/kbn-esql-editor/src/history_local_storage.ts +++ b/packages/kbn-esql-editor/src/history_local_storage.ts @@ -7,10 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import moment from 'moment'; import 'moment-timezone'; const QUERY_HISTORY_ITEM_KEY = 'QUERY_HISTORY_ITEM_KEY'; -const dateFormat = 'MMM. D, YY HH:mm:ss.SSS'; +export const dateFormat = 'MMM. D, YY HH:mm:ss'; /** * We show maximum 20 ES|QL queries in the Query history component @@ -19,32 +18,35 @@ const dateFormat = 'MMM. D, YY HH:mm:ss.SSS'; export interface QueryHistoryItem { status?: 'success' | 'error' | 'warning'; queryString: string; - startDateMilliseconds?: number; timeRan?: string; - timeZone?: string; } -const MAX_QUERIES_NUMBER = 20; +export const MAX_HISTORY_QUERIES_NUMBER = 20; -const getKey = (queryString: string) => { +export const getTrimmedQuery = (queryString: string) => { return queryString.replaceAll('\n', '').trim().replace(/\s\s+/g, ' '); }; -const getMomentTimeZone = (timeZone?: string) => { - return !timeZone || timeZone === 'Browser' ? moment.tz.guess() : timeZone; -}; - -const sortDates = (date1?: number, date2?: number) => { - return moment(date1)?.valueOf() - moment(date2)?.valueOf(); +const sortDates = (date1?: string, date2?: string) => { + if (!date1 || !date2) return 0; + return date1 < date2 ? 1 : date1 > date2 ? -1 : 0; }; export const getHistoryItems = (sortDirection: 'desc' | 'asc'): QueryHistoryItem[] => { const localStorageString = localStorage.getItem(QUERY_HISTORY_ITEM_KEY) ?? '[]'; - const historyItems: QueryHistoryItem[] = JSON.parse(localStorageString); + const localStorageItems: QueryHistoryItem[] = JSON.parse(localStorageString); + const historyItems: QueryHistoryItem[] = localStorageItems.map((item) => { + return { + status: item.status, + queryString: item.queryString, + timeRan: item.timeRan ? new Date(item.timeRan).toISOString() : undefined, + }; + }); + const sortedByDate = historyItems.sort((a, b) => { return sortDirection === 'desc' - ? sortDates(b.startDateMilliseconds, a.startDateMilliseconds) - : sortDates(a.startDateMilliseconds, b.startDateMilliseconds); + ? sortDates(b.timeRan, a.timeRan) + : sortDates(a.timeRan, b.timeRan); }); return sortedByDate; }; @@ -58,24 +60,22 @@ export const getCachedQueries = (): QueryHistoryItem[] => { // Adding the maxQueriesAllowed here for testing purposes export const addQueriesToCache = ( item: QueryHistoryItem, - maxQueriesAllowed = MAX_QUERIES_NUMBER + maxQueriesAllowed = MAX_HISTORY_QUERIES_NUMBER ) => { // if the user is working on multiple tabs // the cachedQueries Map might not contain all // the localStorage queries const queries = getHistoryItems('desc'); queries.forEach((queryItem) => { - const trimmedQueryString = getKey(queryItem.queryString); + const trimmedQueryString = getTrimmedQuery(queryItem.queryString); cachedQueries.set(trimmedQueryString, queryItem); }); - const trimmedQueryString = getKey(item.queryString); + const trimmedQueryString = getTrimmedQuery(item.queryString); if (item.queryString) { - const tz = getMomentTimeZone(item.timeZone); cachedQueries.set(trimmedQueryString, { ...item, - timeRan: moment().tz(tz).format(dateFormat), - startDateMilliseconds: moment().valueOf(), + timeRan: new Date().toISOString(), status: item.status, }); } @@ -83,9 +83,7 @@ export const addQueriesToCache = ( let allQueries = [...getCachedQueries()]; if (allQueries.length >= maxQueriesAllowed + 1) { - const sortedByDate = allQueries.sort((a, b) => - sortDates(b?.startDateMilliseconds, a?.startDateMilliseconds) - ); + const sortedByDate = allQueries.sort((a, b) => sortDates(b.timeRan, a.timeRan)); // queries to store in the localstorage allQueries = sortedByDate.slice(0, maxQueriesAllowed); diff --git a/packages/kbn-esql-editor/src/types.ts b/packages/kbn-esql-editor/src/types.ts index 339ac7a506430..a5fcaba885b0a 100644 --- a/packages/kbn-esql-editor/src/types.ts +++ b/packages/kbn-esql-editor/src/types.ts @@ -13,6 +13,8 @@ import type { AggregateQuery } from '@kbn/es-query'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; export interface ESQLEditorProps { /** The aggregate type query */ @@ -70,6 +72,8 @@ export interface ESQLEditorDeps { core: CoreStart; dataViews: DataViewsPublicPluginStart; expressions: ExpressionsStart; + storage: Storage; indexManagementApiService?: IndexManagementPluginSetup['apiService']; fieldsMetadata?: FieldsMetadataPublicStart; + usageCollection?: UsageCollectionStart; } diff --git a/packages/kbn-esql-editor/tsconfig.json b/packages/kbn-esql-editor/tsconfig.json index 075c5ff9ab457..5131dd90fb0a5 100644 --- a/packages/kbn-esql-editor/tsconfig.json +++ b/packages/kbn-esql-editor/tsconfig.json @@ -28,6 +28,10 @@ "@kbn/fields-metadata-plugin", "@kbn/esql-validation-autocomplete", "@kbn/esql-utils", + "@kbn/content-management-favorites-public", + "@kbn/usage-collection-plugin", + "@kbn/content-management-favorites-common", + "@kbn/kibana-utils-plugin", "@kbn/shared-ux-table-persist", ], "exclude": [ 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 02f7007b51202..28a1e8e1eb538 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 @@ -100,7 +100,7 @@ describe('checking migration metadata changes on all registered SO types', () => "event_loop_delays_daily": "01b967e8e043801357503de09199dfa3853bab88", "exception-list": "4aebc4e61fb5d608cae48eaeb0977e8db21c61a4", "exception-list-agnostic": "6d3262d58eee28ac381ec9654f93126a58be6f5d", - "favorites": "a68c7c8ae22eaddcca324d8b3bfc80a94e3eec3a", + "favorites": "e9773d802932ea85547b120e0efdd9a4f11ff4c6", "file": "6b65ae5899b60ebe08656fd163ea532e557d3c98", "file-upload-usage-collection-telemetry": "06e0a8c04f991e744e09d03ab2bd7f86b2088200", "fileShare": "5be52de1747d249a221b5241af2838264e19aaa1", diff --git a/src/plugins/content_management/server/plugin.test.ts b/src/plugins/content_management/server/plugin.test.ts index 30bbc57ee0159..de077a3f6d4de 100644 --- a/src/plugins/content_management/server/plugin.test.ts +++ b/src/plugins/content_management/server/plugin.test.ts @@ -91,7 +91,7 @@ describe('ContentManagementPlugin', () => { const { plugin, coreSetup, pluginsSetup } = setup(); const api = plugin.setup(coreSetup, pluginsSetup); - expect(Object.keys(api).sort()).toEqual(['crud', 'eventBus', 'register']); + expect(Object.keys(api).sort()).toEqual(['crud', 'eventBus', 'favorites', 'register']); expect(api.crud('')).toBe('mockedCrud'); expect(api.register({} as any)).toBe('mockedRegister'); expect(api.eventBus.emit({} as any)).toBe('mockedEventBusEmit'); diff --git a/src/plugins/content_management/server/plugin.ts b/src/plugins/content_management/server/plugin.ts index c82ed1c66fee2..0215f3d36771b 100755 --- a/src/plugins/content_management/server/plugin.ts +++ b/src/plugins/content_management/server/plugin.ts @@ -76,10 +76,15 @@ export class ContentManagementPlugin contentRegistry, }); - registerFavorites({ core, logger: this.logger, usageCollection: plugins.usageCollection }); + const favoritesSetup = registerFavorites({ + core, + logger: this.logger, + usageCollection: plugins.usageCollection, + }); return { ...coreApi, + favorites: favoritesSetup, }; } diff --git a/src/plugins/content_management/server/types.ts b/src/plugins/content_management/server/types.ts index 22d1d57e38ba8..020f135a7d080 100644 --- a/src/plugins/content_management/server/types.ts +++ b/src/plugins/content_management/server/types.ts @@ -9,6 +9,7 @@ import type { Version } from '@kbn/object-versioning'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import type { FavoritesSetup } from '@kbn/content-management-favorites-server'; import type { CoreApi, StorageContextGetTransformFn } from './core'; export interface ContentManagementServerSetupDependencies { @@ -18,8 +19,9 @@ export interface ContentManagementServerSetupDependencies { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ContentManagementServerStartDependencies {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ContentManagementServerSetup extends CoreApi {} +export interface ContentManagementServerSetup extends CoreApi { + favorites: FavoritesSetup; +} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ContentManagementServerStart {} diff --git a/src/plugins/dashboard/server/plugin.ts b/src/plugins/dashboard/server/plugin.ts index e3d67ca10716b..7762e7da0da96 100644 --- a/src/plugins/dashboard/server/plugin.ts +++ b/src/plugins/dashboard/server/plugin.ts @@ -75,6 +75,8 @@ export class DashboardPlugin }, }); + plugins.contentManagement.favorites.registerFavoriteType('dashboard'); + if (plugins.taskManager) { initializeDashboardTelemetryTask(this.logger, core, plugins.taskManager, plugins.embeddable); } diff --git a/src/plugins/esql/kibana.jsonc b/src/plugins/esql/kibana.jsonc index 6ee732ef79f5a..2f2e765f0b774 100644 --- a/src/plugins/esql/kibana.jsonc +++ b/src/plugins/esql/kibana.jsonc @@ -10,16 +10,19 @@ "browser": true, "optionalPlugins": [ "indexManagement", - "fieldsMetadata" + "fieldsMetadata", + "usageCollection" ], "requiredPlugins": [ "data", "expressions", "dataViews", "uiActions", + "contentManagement" ], "requiredBundles": [ "kibanaReact", + "kibanaUtils", ] } } diff --git a/src/plugins/esql/public/kibana_services.ts b/src/plugins/esql/public/kibana_services.ts index ae6eca13715f5..3ada58d7c2aec 100644 --- a/src/plugins/esql/public/kibana_services.ts +++ b/src/plugins/esql/public/kibana_services.ts @@ -11,8 +11,10 @@ import { BehaviorSubject } from 'rxjs'; import type { CoreStart } from '@kbn/core/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; export let core: CoreStart; @@ -20,8 +22,10 @@ interface ServiceDeps { core: CoreStart; dataViews: DataViewsPublicPluginStart; expressions: ExpressionsStart; + storage: Storage; indexManagementApiService?: IndexManagementPluginSetup['apiService']; fieldsMetadata?: FieldsMetadataPublicStart; + usageCollection?: UsageCollectionStart; } const servicesReady$ = new BehaviorSubject(undefined); @@ -41,15 +45,19 @@ export const setKibanaServices = ( kibanaCore: CoreStart, dataViews: DataViewsPublicPluginStart, expressions: ExpressionsStart, + storage: Storage, indexManagement?: IndexManagementPluginSetup, - fieldsMetadata?: FieldsMetadataPublicStart + fieldsMetadata?: FieldsMetadataPublicStart, + usageCollection?: UsageCollectionStart ) => { core = kibanaCore; servicesReady$.next({ core, dataViews, expressions, + storage, indexManagementApiService: indexManagement?.apiService, fieldsMetadata, + usageCollection, }); }; diff --git a/src/plugins/esql/public/plugin.ts b/src/plugins/esql/public/plugin.ts index ca75c27eccdca..99199d21c1ef8 100755 --- a/src/plugins/esql/public/plugin.ts +++ b/src/plugins/esql/public/plugin.ts @@ -14,6 +14,8 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types'; import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; import { updateESQLQueryTrigger, UpdateESQLQueryAction, @@ -27,6 +29,7 @@ interface EsqlPluginStart { uiActions: UiActionsStart; data: DataPublicPluginStart; fieldsMetadata: FieldsMetadataPublicStart; + usageCollection?: UsageCollectionStart; } interface EsqlPluginSetup { @@ -47,11 +50,20 @@ export class EsqlPlugin implements Plugin<{}, void> { public start( core: CoreStart, - { dataViews, expressions, data, uiActions, fieldsMetadata }: EsqlPluginStart + { dataViews, expressions, data, uiActions, fieldsMetadata, usageCollection }: EsqlPluginStart ): void { + const storage = new Storage(localStorage); const appendESQLAction = new UpdateESQLQueryAction(data); uiActions.addTriggerAction(UPDATE_ESQL_QUERY_TRIGGER, appendESQLAction); - setKibanaServices(core, dataViews, expressions, this.indexManagement, fieldsMetadata); + setKibanaServices( + core, + dataViews, + expressions, + storage, + this.indexManagement, + fieldsMetadata, + usageCollection + ); } public stop() {} diff --git a/src/plugins/esql/server/plugin.ts b/src/plugins/esql/server/plugin.ts index acddcb35b6ca1..a227c8e95b4af 100644 --- a/src/plugins/esql/server/plugin.ts +++ b/src/plugins/esql/server/plugin.ts @@ -8,11 +8,21 @@ */ import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; +import { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; import { getUiSettings } from './ui_settings'; export class EsqlServerPlugin implements Plugin { - public setup(core: CoreSetup) { + public setup(core: CoreSetup, plugins: { contentManagement: ContentManagementServerSetup }) { core.uiSettings.register(getUiSettings()); + + plugins.contentManagement.favorites.registerFavoriteType('esql_query', { + typeMetadataSchema: schema.object({ + queryString: schema.string(), + createdAt: schema.string(), + status: schema.string(), + }), + }); return {}; } diff --git a/src/plugins/esql/tsconfig.json b/src/plugins/esql/tsconfig.json index 85503fd846b4c..2f9bd7f0883b3 100644 --- a/src/plugins/esql/tsconfig.json +++ b/src/plugins/esql/tsconfig.json @@ -22,7 +22,10 @@ "@kbn/ui-actions-plugin", "@kbn/data-plugin", "@kbn/es-query", - "@kbn/fields-metadata-plugin" + "@kbn/fields-metadata-plugin", + "@kbn/usage-collection-plugin", + "@kbn/content-management-plugin", + "@kbn/kibana-utils-plugin", ], "exclude": [ "target/**/*", diff --git a/test/functional/apps/discover/esql/_esql_view.ts b/test/functional/apps/discover/esql/_esql_view.ts index 6c91899c9ebc5..b1fd957f97a6d 100644 --- a/test/functional/apps/discover/esql/_esql_view.ts +++ b/test/functional/apps/discover/esql/_esql_view.ts @@ -401,12 +401,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('ESQLEditor-toggle-query-history-button'); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'FROM logstash-* | LIMIT 10'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', historyItems); }); it('updating the query should add this to the history', async () => { @@ -423,12 +418,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('ESQLEditor-toggle-query-history-button'); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'from logstash-* | limit 100 | drop @timestamp'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable( + 'from logstash-* | limit 100 | drop @timestamp', + historyItems + ); }); it('should select a query from the history and submit it', async () => { @@ -442,12 +435,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esql.clickHistoryItem(1); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'from logstash-* | limit 100 | drop @timestamp'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable( + 'from logstash-* | limit 100 | drop @timestamp', + historyItems + ); }); it('should add a failed query to the history', async () => { diff --git a/test/functional/services/esql.ts b/test/functional/services/esql.ts index c144c6e8993be..9a2bd8149563e 100644 --- a/test/functional/services/esql.ts +++ b/test/functional/services/esql.ts @@ -8,6 +8,7 @@ */ import expect from '@kbn/expect'; +import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; import { FtrService } from '../ftr_provider_context'; export class ESQLService extends FtrService { @@ -20,9 +21,28 @@ export class ESQLService extends FtrService { expect(await codeEditor.getAttribute('innerText')).to.contain(statement); } + public async isQueryPresentInTable(query: string, items: string[][]) { + const queryAdded = items.some((item) => { + return item[2] === query; + }); + + expect(queryAdded).to.be(true); + } + public async getHistoryItems(): Promise { const queryHistory = await this.testSubjects.find('ESQLEditor-queryHistory'); - const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody')); + const tableItems = await this.getStarredHistoryTableItems(queryHistory); + return tableItems; + } + + public async getStarredItems(): Promise { + const starredQueries = await this.testSubjects.find('ESQLEditor-starredQueries'); + const tableItems = await this.getStarredHistoryTableItems(starredQueries); + return tableItems; + } + + private async getStarredHistoryTableItems(element: WebElementWrapper): Promise { + const tableBody = await this.retry.try(async () => element.findByTagName('tbody')); const $ = await tableBody.parseDomContent(); return $('tr') .toArray() @@ -44,6 +64,20 @@ export class ESQLService extends FtrService { }); } + public async getStarredItem(rowIndex = 0) { + const queryHistory = await this.testSubjects.find('ESQLEditor-starredQueries'); + const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody')); + const rows = await this.retry.try(async () => tableBody.findAllByTagName('tr')); + + return rows[rowIndex]; + } + + public async clickStarredItem(rowIndex = 0) { + const row = await this.getStarredItem(rowIndex); + const toggle = await row.findByTestSubject('ESQLEditor-history-starred-queries-run-button'); + await toggle.click(); + } + public async getHistoryItem(rowIndex = 0) { const queryHistory = await this.testSubjects.find('ESQLEditor-queryHistory'); const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody')); @@ -54,7 +88,7 @@ export class ESQLService extends FtrService { public async clickHistoryItem(rowIndex = 0) { const row = await this.getHistoryItem(rowIndex); - const toggle = await row.findByTestSubject('ESQLEditor-queryHistory-runQuery-button'); + const toggle = await row.findByTestSubject('ESQLEditor-history-starred-queries-run-button'); await toggle.click(); } diff --git a/tsconfig.base.json b/tsconfig.base.json index b9270f4f035b8..26fe060916a92 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -206,6 +206,8 @@ "@kbn/content-management-content-insights-server/*": ["packages/content-management/content_insights/content_insights_server/*"], "@kbn/content-management-examples-plugin": ["examples/content_management_examples"], "@kbn/content-management-examples-plugin/*": ["examples/content_management_examples/*"], + "@kbn/content-management-favorites-common": ["packages/content-management/favorites/favorites_common"], + "@kbn/content-management-favorites-common/*": ["packages/content-management/favorites/favorites_common/*"], "@kbn/content-management-favorites-public": ["packages/content-management/favorites/favorites_public"], "@kbn/content-management-favorites-public/*": ["packages/content-management/favorites/favorites_public/*"], "@kbn/content-management-favorites-server": ["packages/content-management/favorites/favorites_server"], diff --git a/x-pack/plugins/observability_solution/investigate_app/.storybook/mock_kibana_services.ts b/x-pack/plugins/observability_solution/investigate_app/.storybook/mock_kibana_services.ts index e9f1b2b40ef40..1a8a07bf7a360 100644 --- a/x-pack/plugins/observability_solution/investigate_app/.storybook/mock_kibana_services.ts +++ b/x-pack/plugins/observability_solution/investigate_app/.storybook/mock_kibana_services.ts @@ -8,9 +8,32 @@ import { setKibanaServices } from '@kbn/esql/public/kibana_services'; import { coreMock } from '@kbn/core/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks'; +import type { Storage } from '@kbn/kibana-utils-plugin/public'; + +class LocalStorageMock { + public store: Record; + constructor(defaultStore: Record) { + this.store = defaultStore; + } + clear() { + this.store = {}; + } + get(key: string) { + return this.store[key] || null; + } + set(key: string, value: unknown) { + this.store[key] = String(value); + } + remove(key: string) { + delete this.store[key]; + } +} + +const storage = new LocalStorageMock({}) as unknown as Storage; setKibanaServices( coreMock.createStart(), dataViewPluginMocks.createStartContract(), - expressionsPluginMock.createStartContract() + expressionsPluginMock.createStartContract(), + storage ); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index cd6a13e30e014..e977ae228fdfc 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -3154,8 +3154,8 @@ "esqlEditor.query.lineNumber": "Ligne {lineNumber}", "esqlEditor.query.querieshistory.error": "La requête a échouée", "esqlEditor.query.querieshistory.success": "La requête a été exécuté avec succès", - "esqlEditor.query.querieshistoryCopy": "Copier la requête dans le presse-papier", - "esqlEditor.query.querieshistoryRun": "Exécuter la requête", + "esqlEditor.query.esqlQueriesCopy": "Copier la requête dans le presse-papier", + "esqlEditor.query.esqlQueriesListRun": "Exécuter la requête", "esqlEditor.query.querieshistoryTable": "Tableau d'historique des recherches", "esqlEditor.query.recentQueriesColumnLabel": "Recherches récentes", "esqlEditor.query.refreshLabel": "Actualiser", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0ed85fcd105e3..05416471642cb 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3148,8 +3148,8 @@ "esqlEditor.query.lineNumber": "行{lineNumber}", "esqlEditor.query.querieshistory.error": "クエリ失敗", "esqlEditor.query.querieshistory.success": "クエリは正常に実行されました", - "esqlEditor.query.querieshistoryCopy": "クエリをクリップボードにコピー", - "esqlEditor.query.querieshistoryRun": "クエリーを実行", + "esqlEditor.query.esqlQueriesCopy": "クエリをクリップボードにコピー", + "esqlEditor.query.esqlQueriesListRun": "クエリーを実行", "esqlEditor.query.querieshistoryTable": "クエリ履歴テーブル", "esqlEditor.query.recentQueriesColumnLabel": "最近のクエリー", "esqlEditor.query.refreshLabel": "更新", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b971c0ffca035..0e0b68b1a3ef9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3104,8 +3104,8 @@ "esqlEditor.query.lineNumber": "第 {lineNumber} 行", "esqlEditor.query.querieshistory.error": "查询失败", "esqlEditor.query.querieshistory.success": "已成功运行查询", - "esqlEditor.query.querieshistoryCopy": "复制查询到剪贴板", - "esqlEditor.query.querieshistoryRun": "运行查询", + "esqlEditor.query.esqlQueriesCopy": "复制查询到剪贴板", + "esqlEditor.query.esqlQueriesListRun": "运行查询", "esqlEditor.query.querieshistoryTable": "查询历史记录表", "esqlEditor.query.recentQueriesColumnLabel": "最近查询", "esqlEditor.query.refreshLabel": "刷新", diff --git a/x-pack/test/api_integration/apis/content_management/favorites.ts b/x-pack/test/api_integration/apis/content_management/favorites.ts index 42641f96f63e3..6fef2f627e6a3 100644 --- a/x-pack/test/api_integration/apis/content_management/favorites.ts +++ b/x-pack/test/api_integration/apis/content_management/favorites.ts @@ -59,135 +59,293 @@ export default function ({ getService }: FtrProviderContext) { await cleanupInteractiveUser({ getService }); }); - const api = { - favorite: ({ - dashboardId, - user, - space, - }: { - dashboardId: string; - user: LoginAsInteractiveUserResponse; - space?: string; - }) => { - return supertest - .post( - `${ - space ? `/s/${space}` : '' - }/internal/content_management/favorites/dashboard/${dashboardId}/favorite` - ) - .set(user.headers) - .set('kbn-xsrf', 'true') - .expect(200); - }, - unfavorite: ({ - dashboardId, - user, - space, - }: { - dashboardId: string; - user: LoginAsInteractiveUserResponse; - space?: string; - }) => { - return supertest - .post( - `${ - space ? `/s/${space}` : '' - }/internal/content_management/favorites/dashboard/${dashboardId}/unfavorite` - ) - .set(user.headers) - .set('kbn-xsrf', 'true') - .expect(200); - }, - list: ({ user, space }: { user: LoginAsInteractiveUserResponse; space?: string }) => { - return supertest - .get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/dashboard`) - .set(user.headers) - .set('kbn-xsrf', 'true') - .expect(200); - }, - }; + it('fails to favorite type is invalid', async () => { + await supertest + .post(`/internal/content_management/favorites/invalid/fav1/favorite`) + .set(interactiveUser.headers) + .set('kbn-xsrf', 'true') + .expect(400); + }); + + describe('dashboard', () => { + const api = { + favorite: ({ + dashboardId, + user, + space, + }: { + dashboardId: string; + user: LoginAsInteractiveUserResponse; + space?: string; + }) => { + return supertest + .post( + `${ + space ? `/s/${space}` : '' + }/internal/content_management/favorites/dashboard/${dashboardId}/favorite` + ) + .set(user.headers) + .set('kbn-xsrf', 'true') + .expect(200); + }, + unfavorite: ({ + dashboardId, + user, + space, + }: { + dashboardId: string; + user: LoginAsInteractiveUserResponse; + space?: string; + }) => { + return supertest + .post( + `${ + space ? `/s/${space}` : '' + }/internal/content_management/favorites/dashboard/${dashboardId}/unfavorite` + ) + .set(user.headers) + .set('kbn-xsrf', 'true') + .expect(200); + }, + list: ({ + user, + space, + }: { + user: LoginAsInteractiveUserResponse; + space?: string; + favoriteType?: string; + }) => { + return supertest + .get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/dashboard`) + .set(user.headers) + .set('kbn-xsrf', 'true') + .expect(200); + }, + }; - it('can favorite a dashboard', async () => { - let response = await api.list({ user: interactiveUser }); - expect(response.body.favoriteIds).to.eql([]); + it('can favorite a dashboard', async () => { + let response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql([]); - response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav1']); + response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav1']); - response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav1']); + response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav1']); - response = await api.favorite({ dashboardId: 'fav2', user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']); + response = await api.favorite({ dashboardId: 'fav2', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']); - response = await api.unfavorite({ dashboardId: 'fav1', user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav2']); + response = await api.unfavorite({ dashboardId: 'fav1', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); - response = await api.unfavorite({ dashboardId: 'fav3', user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav2']); + response = await api.unfavorite({ dashboardId: 'fav3', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); - response = await api.list({ user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav2']); + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); - // check that the favorites aren't shared between users - const interactiveUser2 = await loginAsInteractiveUser({ - getService, - username: 'content_manager_dashboard_2', - }); + // check that the favorites aren't shared between users + const interactiveUser2 = await loginAsInteractiveUser({ + getService, + username: 'content_manager_dashboard_2', + }); - response = await api.list({ user: interactiveUser2 }); - expect(response.body.favoriteIds).to.eql([]); + response = await api.list({ user: interactiveUser2 }); + expect(response.body.favoriteIds).to.eql([]); - // check that the favorites aren't shared between spaces - response = await api.list({ user: interactiveUser, space: 'custom' }); - expect(response.body.favoriteIds).to.eql([]); + // check that the favorites aren't shared between spaces + response = await api.list({ user: interactiveUser, space: 'custom' }); + expect(response.body.favoriteIds).to.eql([]); - response = await api.favorite({ - dashboardId: 'fav1', - user: interactiveUser, - space: 'custom', - }); + response = await api.favorite({ + dashboardId: 'fav1', + user: interactiveUser, + space: 'custom', + }); + + expect(response.body.favoriteIds).to.eql(['fav1']); + + response = await api.list({ user: interactiveUser, space: 'custom' }); + expect(response.body.favoriteIds).to.eql(['fav1']); - expect(response.body.favoriteIds).to.eql(['fav1']); + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); - response = await api.list({ user: interactiveUser, space: 'custom' }); - expect(response.body.favoriteIds).to.eql(['fav1']); + // check that reader user can favorite + const interactiveUser3 = await loginAsInteractiveUser({ + getService, + username: 'content_reader_dashboard_2', + }); - response = await api.list({ user: interactiveUser }); - expect(response.body.favoriteIds).to.eql(['fav2']); + response = await api.list({ user: interactiveUser3 }); + expect(response.body.favoriteIds).to.eql([]); - // check that reader user can favorite - const interactiveUser3 = await loginAsInteractiveUser({ - getService, - username: 'content_reader_dashboard_2', + response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser3 }); + expect(response.body.favoriteIds).to.eql(['fav1']); }); - response = await api.list({ user: interactiveUser3 }); - expect(response.body.favoriteIds).to.eql([]); + it("fails to favorite if metadata is provided for type that doesn't support it", async () => { + await supertest + .post(`/internal/content_management/favorites/dashboard/fav1/favorite`) + .set(interactiveUser.headers) + .set('kbn-xsrf', 'true') + .send({ metadata: { foo: 'bar' } }) + .expect(400); - response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser3 }); - expect(response.body.favoriteIds).to.eql(['fav1']); + await supertest + .post(`/internal/content_management/favorites/dashboard/fav1/favorite`) + .set(interactiveUser.headers) + .set('kbn-xsrf', 'true') + .send({ metadata: {} }) + .expect(400); + }); + + // depends on the state from previous test + it('reports favorites stats', async () => { + const { body }: { body: UnencryptedTelemetryPayload } = await getService('supertest') + .post('/internal/telemetry/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .set(ELASTIC_HTTP_VERSION_HEADER, '2') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .send({ unencrypted: true, refreshCache: true }) + .expect(200); + + // @ts-ignore + const favoritesStats = body[0].stats.stack_stats.kibana.plugins.favorites; + expect(favoritesStats).to.eql({ + dashboard: { + total: 3, + total_users_spaces: 3, + avg_per_user_per_space: 1, + max_per_user_per_space: 1, + }, + }); + }); }); - // depends on the state from previous test - it('reports favorites stats', async () => { - const { body }: { body: UnencryptedTelemetryPayload } = await getService('supertest') - .post('/internal/telemetry/clusters/_stats') - .set('kbn-xsrf', 'xxx') - .set(ELASTIC_HTTP_VERSION_HEADER, '2') - .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') - .send({ unencrypted: true, refreshCache: true }) - .expect(200); - - // @ts-ignore - const favoritesStats = body[0].stats.stack_stats.kibana.plugins.favorites; - expect(favoritesStats).to.eql({ - dashboard: { - total: 3, - total_users_spaces: 3, - avg_per_user_per_space: 1, - max_per_user_per_space: 1, + describe('esql_query', () => { + const type = 'esql_query'; + const metadata1 = { + queryString: 'SELECT * FROM test1', + createdAt: '2021-09-01T00:00:00Z', + status: 'success', + }; + + const metadata2 = { + queryString: 'SELECT * FROM test2', + createdAt: '2023-09-01T00:00:00Z', + status: 'success', + }; + + const api = { + favorite: ({ + queryId, + metadata, + user, + }: { + queryId: string; + metadata: object; + user: LoginAsInteractiveUserResponse; + }) => { + return supertest + .post(`/internal/content_management/favorites/${type}/${queryId}/favorite`) + .set(user.headers) + .set('kbn-xsrf', 'true') + .send({ metadata }); + }, + unfavorite: ({ + queryId, + user, + }: { + queryId: string; + user: LoginAsInteractiveUserResponse; + }) => { + return supertest + .post(`/internal/content_management/favorites/${type}/${queryId}/unfavorite`) + .set(user.headers) + .set('kbn-xsrf', 'true') + .expect(200); }, + list: ({ + user, + space, + }: { + user: LoginAsInteractiveUserResponse; + space?: string; + favoriteType?: string; + }) => { + return supertest + .get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/${type}`) + .set(user.headers) + .set('kbn-xsrf', 'true') + .expect(200); + }, + }; + + it('fails to favorite if metadata is not valid', async () => { + await api + .favorite({ + queryId: 'fav1', + metadata: { foo: 'bar' }, + user: interactiveUser, + }) + .expect(400); + + await api + .favorite({ + queryId: 'fav1', + metadata: {}, + user: interactiveUser, + }) + .expect(400); + }); + + it('can favorite a query', async () => { + let response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql([]); + + response = await api.favorite({ + queryId: 'fav1', + user: interactiveUser, + metadata: metadata1, + }); + + expect(response.body.favoriteIds).to.eql(['fav1']); + + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav1']); + expect(response.body.favoriteMetadata).to.eql({ fav1: metadata1 }); + + response = await api.favorite({ + queryId: 'fav2', + user: interactiveUser, + metadata: metadata2, + }); + expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']); + + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']); + expect(response.body.favoriteMetadata).to.eql({ + fav1: metadata1, + fav2: metadata2, + }); + + response = await api.unfavorite({ queryId: 'fav1', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); + + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql(['fav2']); + expect(response.body.favoriteMetadata).to.eql({ + fav2: metadata2, + }); + + response = await api.unfavorite({ queryId: 'fav2', user: interactiveUser }); + expect(response.body.favoriteIds).to.eql([]); + + response = await api.list({ user: interactiveUser }); + expect(response.body.favoriteIds).to.eql([]); + expect(response.body.favoriteMetadata).to.eql({}); }); }); }); diff --git a/x-pack/test/functional/apps/discover/esql_starred.ts b/x-pack/test/functional/apps/discover/esql_starred.ts new file mode 100644 index 0000000000000..9444baabb270b --- /dev/null +++ b/x-pack/test/functional/apps/discover/esql_starred.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const monacoEditor = getService('monacoEditor'); + const { common, discover, header, unifiedFieldList, security } = getPageObjects([ + 'common', + 'discover', + 'header', + 'unifiedFieldList', + 'security', + ]); + const testSubjects = getService('testSubjects'); + const esql = getService('esql'); + const securityService = getService('security'); + const browser = getService('browser'); + + const user = 'discover_read_user'; + const role = 'discover_read_role'; + + describe('Discover ES|QL starred queries', () => { + before('initialize tests', async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); + await kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json' + ); + + await security.forceLogout(); + + await securityService.role.create(role, { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + discover: ['read'], + }, + spaces: ['*'], + }, + ], + }); + + await securityService.user.create(user, { + password: 'changeme', + roles: [role], + full_name: user, + }); + + await security.login(user, 'changeme', { + expectSpaceSelector: false, + }); + }); + + after('clean up archives', async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional'); + await kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json' + ); + await security.forceLogout(); + await securityService.user.delete(user); + await securityService.role.delete(role); + }); + + it('should star a query from the editor query history', async () => { + await common.navigateToApp('discover'); + await discover.selectTextBaseLang(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + await testSubjects.click('ESQLEditor-toggle-query-history-button'); + const historyItem = await esql.getHistoryItem(0); + await testSubjects.moveMouseTo('~ESQLFavoriteButton'); + const button = await historyItem.findByTestSubject('ESQLFavoriteButton'); + await button.click(); + + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('starred-queries-tab'); + + const starredItems = await esql.getStarredItems(); + await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', starredItems); + }); + + it('should persist the starred query after a browser refresh', async () => { + await browser.refresh(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + await testSubjects.click('ESQLEditor-toggle-query-history-button'); + await testSubjects.click('starred-queries-tab'); + const starredItems = await esql.getStarredItems(); + await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', starredItems); + }); + + it('should select a query from the starred and submit it', async () => { + await common.navigateToApp('discover'); + await discover.selectTextBaseLang(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + await testSubjects.click('ESQLEditor-toggle-query-history-button'); + await testSubjects.click('starred-queries-tab'); + + await esql.clickStarredItem(0); + await header.waitUntilLoadingHasFinished(); + + const editorValue = await monacoEditor.getCodeEditorValue(); + expect(editorValue).to.eql(`FROM logstash-* | LIMIT 10`); + }); + + it('should delete a query from the starred queries tab', async () => { + await common.navigateToApp('discover'); + await discover.selectTextBaseLang(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await unifiedFieldList.waitUntilSidebarHasLoaded(); + + await testSubjects.click('ESQLEditor-toggle-query-history-button'); + await testSubjects.click('starred-queries-tab'); + + const starredItem = await esql.getStarredItem(0); + const button = await starredItem.findByTestSubject('ESQLFavoriteButton'); + await button.click(); + await testSubjects.click('esqlEditor-discard-starred-query-discard-btn'); + + await header.waitUntilLoadingHasFinished(); + + const starredItems = await esql.getStarredItems(); + expect(starredItems[0][0]).to.be('No items found'); + }); + }); +} diff --git a/x-pack/test/functional/apps/discover/index.ts b/x-pack/test/functional/apps/discover/index.ts index a07eb9c663239..98b3ad34080fa 100644 --- a/x-pack/test/functional/apps/discover/index.ts +++ b/x-pack/test/functional/apps/discover/index.ts @@ -20,5 +20,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./value_suggestions')); loadTestFile(require.resolve('./value_suggestions_non_timebased')); loadTestFile(require.resolve('./saved_search_embeddable')); + loadTestFile(require.resolve('./esql_starred')); }); } diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts b/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts index 4a3f0b4a9d834..b4c73c56a484a 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/esql/_esql_view.ts @@ -375,12 +375,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('ESQLEditor-toggle-query-history-button'); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'FROM logstash-* | LIMIT 10'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', historyItems); }); it('updating the query should add this to the history', async () => { @@ -397,12 +392,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('ESQLEditor-toggle-query-history-button'); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'from logstash-* | limit 100 | drop @timestamp'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable( + 'from logstash-* | limit 100 | drop @timestamp', + historyItems + ); }); it('should select a query from the history and submit it', async () => { @@ -416,12 +409,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esql.clickHistoryItem(1); const historyItems = await esql.getHistoryItems(); - log.debug(historyItems); - const queryAdded = historyItems.some((item) => { - return item[1] === 'from logstash-* | limit 100 | drop @timestamp'; - }); - - expect(queryAdded).to.be(true); + await esql.isQueryPresentInTable( + 'from logstash-* | limit 100 | drop @timestamp', + historyItems + ); }); it('should add a failed query to the history', async () => { @@ -437,7 +428,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.discover.waitUntilSearchingHasFinished(); await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); await testSubjects.click('ESQLEditor-toggle-query-history-button'); - await testSubjects.click('ESQLEditor-queryHistory-runQuery-button'); + await testSubjects.click('ESQLEditor-history-starred-queries-run-button'); const historyItem = await esql.getHistoryItem(0); await historyItem.findByTestSubject('ESQLEditor-queryHistory-error'); }); diff --git a/yarn.lock b/yarn.lock index 087aa1df349ee..719e45bb7cab7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3666,6 +3666,10 @@ version "0.0.0" uid "" +"@kbn/content-management-favorites-common@link:packages/content-management/favorites/favorites_common": + version "0.0.0" + uid "" + "@kbn/content-management-favorites-public@link:packages/content-management/favorites/favorites_public": version "0.0.0" uid ""