diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c2450338f3e45..f3b3fe2258b18 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 1114f3a94ca6e..33139ec644df7 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 f6aaa2ee0ac7f..80e2f5fe02d0b 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 c9d88a7c0f8ed..8d3aa09edc8f0 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 fffed2d59a462..93f0b60fccc61 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 4d8de21af735a..20ad1a4ff72ef 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 c2add93693fe7..5002a59a167ec 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 ""