mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
# Backport This will backport the following commits from `main` to `8.x`: - [[ES|QL] Starred queries in the editor (#198362)](https://github.com/elastic/kibana/pull/198362) <!--- Backport version: 8.9.8 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Anton Dosov","email":"anton.dosov@elastic.co"},"sourceCommit":{"committedDate":"2024-11-18T20:53:46Z","message":"[ES|QL] Starred queries in the editor (#198362)\n\n## Summary\r\n\r\nclose https://github.com/elastic/kibana/issues/194165\r\nclose https://github.com/elastic/kibana-team/issues/1245\r\n\r\n### User-facing\r\n\r\n<img width=\"1680\" alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/6df4ee9f-1b4d-404c-a764-592998a1d430\">\r\n\r\nThis PRs adds a new tab in the editor history component. You can star\r\nyour query from the history and then you will see it in the Starred\r\nlist. The started queries are scoped to a user and a space.\r\n\r\n\r\n### Server\r\n\r\nTo allow starring ESQL query, this PR extends [favorites\r\nservice](https://github.com/elastic/kibana/pull/189285) with ability to\r\nstore metadata in addition to an id. To make metadata strict and in\r\nfuture to support proper metadata migrations if needed, metadata needs\r\nto be defined as schema:\r\n\r\n```\r\nplugins.contentManagement.favorites.registerFavoriteType('esql_query', {\r\n typeMetadataSchema: schema.object({ query: schema.string(), timeRange:...., etc... }),\r\n})\r\n```\r\n\r\nNotable changes: \r\n\r\n- Add support for registering a favorite type and a schema for favorite\r\ntype metadata. Previosly the `dashboard` type was the only supported\r\ntype and was hardcoded\r\n- Add `favoriteMetadata` property to a saved object mapping and make it\r\n`enabled:false` we don't want to index it, but just want to store\r\nmetadata in addition to an id.\r\n[code](https://github.com/elastic/kibana/pull/198362/files#diff-d1a39e36f1de11a1110520d7607e6aee7d506c76626993842cb58db012b760a2R74-R87)\r\n- Add a 100 favorite items limit (per type per space per user). Just do\r\nit for sanity to prevent too large objects due to metadata stored in\r\naddtion to ids.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>\r\nCo-authored-by: Stratoula Kalafateli <stratoula1@gmail.com>","sha":"45972374f06a6189ec9e225fd00b191838f33e52","branchLabelMapping":{"^v9.0.0$":"main","^v8.17.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["v9.0.0","release_note:feature","Team:SharedUX","Feature:ES|QL","Team:ESQL","backport:version","v8.17.0"],"number":198362,"url":"https://github.com/elastic/kibana/pull/198362","mergeCommit":{"message":"[ES|QL] Starred queries in the editor (#198362)\n\n## Summary\r\n\r\nclose https://github.com/elastic/kibana/issues/194165\r\nclose https://github.com/elastic/kibana-team/issues/1245\r\n\r\n### User-facing\r\n\r\n<img width=\"1680\" alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/6df4ee9f-1b4d-404c-a764-592998a1d430\">\r\n\r\nThis PRs adds a new tab in the editor history component. You can star\r\nyour query from the history and then you will see it in the Starred\r\nlist. The started queries are scoped to a user and a space.\r\n\r\n\r\n### Server\r\n\r\nTo allow starring ESQL query, this PR extends [favorites\r\nservice](https://github.com/elastic/kibana/pull/189285) with ability to\r\nstore metadata in addition to an id. To make metadata strict and in\r\nfuture to support proper metadata migrations if needed, metadata needs\r\nto be defined as schema:\r\n\r\n```\r\nplugins.contentManagement.favorites.registerFavoriteType('esql_query', {\r\n typeMetadataSchema: schema.object({ query: schema.string(), timeRange:...., etc... }),\r\n})\r\n```\r\n\r\nNotable changes: \r\n\r\n- Add support for registering a favorite type and a schema for favorite\r\ntype metadata. Previosly the `dashboard` type was the only supported\r\ntype and was hardcoded\r\n- Add `favoriteMetadata` property to a saved object mapping and make it\r\n`enabled:false` we don't want to index it, but just want to store\r\nmetadata in addition to an id.\r\n[code](https://github.com/elastic/kibana/pull/198362/files#diff-d1a39e36f1de11a1110520d7607e6aee7d506c76626993842cb58db012b760a2R74-R87)\r\n- Add a 100 favorite items limit (per type per space per user). Just do\r\nit for sanity to prevent too large objects due to metadata stored in\r\naddtion to ids.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>\r\nCo-authored-by: Stratoula Kalafateli <stratoula1@gmail.com>","sha":"45972374f06a6189ec9e225fd00b191838f33e52"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/198362","number":198362,"mergeCommit":{"message":"[ES|QL] Starred queries in the editor (#198362)\n\n## Summary\r\n\r\nclose https://github.com/elastic/kibana/issues/194165\r\nclose https://github.com/elastic/kibana-team/issues/1245\r\n\r\n### User-facing\r\n\r\n<img width=\"1680\" alt=\"image\"\r\nsrc=\"https://github.com/user-attachments/assets/6df4ee9f-1b4d-404c-a764-592998a1d430\">\r\n\r\nThis PRs adds a new tab in the editor history component. You can star\r\nyour query from the history and then you will see it in the Starred\r\nlist. The started queries are scoped to a user and a space.\r\n\r\n\r\n### Server\r\n\r\nTo allow starring ESQL query, this PR extends [favorites\r\nservice](https://github.com/elastic/kibana/pull/189285) with ability to\r\nstore metadata in addition to an id. To make metadata strict and in\r\nfuture to support proper metadata migrations if needed, metadata needs\r\nto be defined as schema:\r\n\r\n```\r\nplugins.contentManagement.favorites.registerFavoriteType('esql_query', {\r\n typeMetadataSchema: schema.object({ query: schema.string(), timeRange:...., etc... }),\r\n})\r\n```\r\n\r\nNotable changes: \r\n\r\n- Add support for registering a favorite type and a schema for favorite\r\ntype metadata. Previosly the `dashboard` type was the only supported\r\ntype and was hardcoded\r\n- Add `favoriteMetadata` property to a saved object mapping and make it\r\n`enabled:false` we don't want to index it, but just want to store\r\nmetadata in addition to an id.\r\n[code](https://github.com/elastic/kibana/pull/198362/files#diff-d1a39e36f1de11a1110520d7607e6aee7d506c76626993842cb58db012b760a2R74-R87)\r\n- Add a 100 favorite items limit (per type per space per user). Just do\r\nit for sanity to prevent too large objects due to metadata stored in\r\naddtion to ids.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>\r\nCo-authored-by: Stratoula Kalafateli <stratoula1@gmail.com>","sha":"45972374f06a6189ec9e225fd00b191838f33e52"}},{"branch":"8.x","label":"v8.17.0","labelRegex":"^v8.17.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: Anton Dosov <anton.dosov@elastic.co>
This commit is contained in:
parent
428eed52be
commit
f5ca0f7a6e
55 changed files with 1971 additions and 297 deletions
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/content-management-favorites-common
|
||||
|
||||
Shared client & server code for the favorites packages.
|
|
@ -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;
|
|
@ -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: ['<rootDir>/packages/content-management/favorites/favorites_common'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/content-management-favorites-common",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": []
|
||||
}
|
|
@ -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<GetFavoritesResponse>;
|
||||
addFavorite({ id }: { id: string }): Promise<GetFavoritesResponse>;
|
||||
removeFavorite({ id }: { id: string }): Promise<GetFavoritesResponse>;
|
||||
export interface GetFavoritesResponse<Metadata extends object | void = void>
|
||||
extends GetFavoritesResponseServer {
|
||||
favoriteMetadata: Metadata extends object ? Record<string, Metadata> : never;
|
||||
}
|
||||
|
||||
type AddFavoriteRequest<Metadata extends object | void> = Metadata extends object
|
||||
? { id: string; metadata: Metadata }
|
||||
: { id: string };
|
||||
|
||||
export interface FavoritesClientPublic<Metadata extends object | void = void> {
|
||||
getFavorites(): Promise<GetFavoritesResponse<Metadata>>;
|
||||
addFavorite(params: AddFavoriteRequest<Metadata>): Promise<AddFavoriteResponse>;
|
||||
removeFavorite(params: { id: string }): Promise<RemoveFavoriteResponse>;
|
||||
|
||||
getFavoriteType(): string;
|
||||
reportAddFavoriteClick(): void;
|
||||
reportRemoveFavoriteClick(): void;
|
||||
}
|
||||
|
||||
export class FavoritesClient implements FavoritesClientPublic {
|
||||
export class FavoritesClient<Metadata extends object | void = void>
|
||||
implements FavoritesClientPublic<Metadata>
|
||||
{
|
||||
constructor(
|
||||
private readonly appName: string,
|
||||
private readonly favoriteObjectType: string,
|
||||
private readonly deps: { http: HttpStart; usageCollection?: UsageCollectionStart }
|
||||
) {}
|
||||
|
||||
public async getFavorites(): Promise<GetFavoritesResponse> {
|
||||
public async getFavorites(): Promise<GetFavoritesResponse<Metadata>> {
|
||||
return this.deps.http.get(`/internal/content_management/favorites/${this.favoriteObjectType}`);
|
||||
}
|
||||
|
||||
public async addFavorite({ id }: { id: string }): Promise<GetFavoritesResponse> {
|
||||
public async addFavorite(params: AddFavoriteRequest<Metadata>): Promise<AddFavoriteResponse> {
|
||||
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<GetFavoritesResponse> {
|
||||
public async removeFavorite({ id }: { id: string }): Promise<RemoveFavoriteResponse> {
|
||||
return this.deps.http.post(
|
||||
`/internal/content_management/favorites/${this.favoriteObjectType}/${id}/unfavorite`
|
||||
);
|
||||
|
|
|
@ -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
|
||||
);
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<FavoritesRegistry, 'registerFavoriteType'>;
|
||||
|
||||
export class FavoritesRegistry {
|
||||
private favoriteTypes = new Map<string, FavoriteTypeConfig>();
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<string, object>;
|
||||
}
|
||||
|
||||
export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; logger: Logger }) {
|
||||
export interface AddFavoriteResponse {
|
||||
favoriteIds: string[];
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
@ -14,6 +14,7 @@ export interface FavoritesSavedObjectAttributes {
|
|||
userId: string;
|
||||
type: string;
|
||||
favoriteIds: string[];
|
||||
favoriteMetadata?: Record<string, object>;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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<string, object>;
|
||||
}
|
||||
|
||||
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<FavoritesState> {
|
||||
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<FavoritesState> {
|
||||
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<FavoritesState> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -19,5 +19,6 @@
|
|||
"@kbn/core-saved-objects-api-server",
|
||||
"@kbn/core-lifecycle-server",
|
||||
"@kbn/usage-collection-plugin",
|
||||
"@kbn/content-management-favorites-common",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -443,6 +443,7 @@
|
|||
],
|
||||
"favorites": [
|
||||
"favoriteIds",
|
||||
"favoriteMetadata",
|
||||
"type",
|
||||
"userId"
|
||||
],
|
||||
|
|
|
@ -1509,6 +1509,10 @@
|
|||
"favoriteIds": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"favoriteMetadata": {
|
||||
"dynamic": false,
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "keyword"
|
||||
},
|
||||
|
|
|
@ -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<void>;
|
||||
}
|
||||
// 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<HTMLInputElement>) => {
|
||||
setDismissModalChecked(e.target.checked);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EuiModal
|
||||
onClose={() => onClose()}
|
||||
style={{ width: 700 }}
|
||||
data-test-subj="discard-starred-query-modal"
|
||||
>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>
|
||||
{i18n.translate('esqlEditor.discardStarredQueryModal.title', {
|
||||
defaultMessage: 'Discard starred query',
|
||||
})}
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
|
||||
<EuiModalBody>
|
||||
<EuiText size="m">
|
||||
{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.',
|
||||
})}
|
||||
</EuiText>
|
||||
<EuiHorizontalRule margin="s" />
|
||||
</EuiModalBody>
|
||||
<EuiModalFooter css={{ paddingBlockStart: 0 }}>
|
||||
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiCheckbox
|
||||
id="dismiss-discard-starred-query-modal"
|
||||
label={i18n.translate('esqlEditor.discardStarredQueryModal.dismissButtonLabel', {
|
||||
defaultMessage: "Don't ask me again",
|
||||
})}
|
||||
checked={dismissModalChecked}
|
||||
onChange={onTransitionModalDismiss}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
onClick={async () => {
|
||||
await onClose(dismissModalChecked, false);
|
||||
}}
|
||||
color="primary"
|
||||
data-test-subj="esqlEditor-discard-starred-query-cancel-btn"
|
||||
>
|
||||
{i18n.translate('esqlEditor.discardStarredQueryModal.cancelLabel', {
|
||||
defaultMessage: 'Cancel',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
onClick={async () => {
|
||||
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',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiModalFooter>
|
||||
</EuiModal>
|
||||
);
|
||||
}
|
|
@ -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 = () => <div />;
|
||||
|
||||
const LazyDiscardStarredQueryModal = React.lazy(() => import('./discard_starred_query_modal'));
|
||||
export const DiscardStarredQueryModal = (props: DiscardStarredQueryModalProps) => (
|
||||
<React.Suspense fallback={<Fallback />}>
|
||||
<LazyDiscardStarredQueryModal {...props} />
|
||||
</React.Suspense>
|
||||
);
|
|
@ -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<string, unknown>;
|
||||
constructor(defaultStore: Record<string, unknown>) {
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -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<StarredQueryMetadata>;
|
||||
starredQueries: StarredQueryItem[];
|
||||
storage: Storage;
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
interface StarredQueryMetadata {
|
||||
queryString: string;
|
||||
createdAt: string;
|
||||
status: 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
export class EsqlStarredQueriesService {
|
||||
private client: FavoritesClient<StarredQueryMetadata>;
|
||||
private starredQueries: StarredQueryItem[] = [];
|
||||
private queryToEdit: string = '';
|
||||
private storage: Storage;
|
||||
queries$: BehaviorSubject<StarredQueryItem[]>;
|
||||
discardModalVisibility$: BehaviorSubject<boolean> = 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<StarredQueryMetadata>('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<QueryHistoryItem, 'queryString' | 'status'>) {
|
||||
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 (
|
||||
<TooltipWrapper
|
||||
tooltipContent={i18n.translate(
|
||||
'esqlEditor.query.querieshistory.starredQueriesReachedLimitTooltip',
|
||||
{
|
||||
defaultMessage:
|
||||
'Limit reached: This list can contain a maximum of {limit} items. Please remove an item before adding a new one.',
|
||||
values: { limit: ESQL_STARRED_QUERIES_LIMIT },
|
||||
}
|
||||
)}
|
||||
condition={!isStarred && this.checkIfStarredQueriesLimitReached()}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
title={
|
||||
isStarred
|
||||
? i18n.translate('esqlEditor.query.querieshistory.removeFavoriteTitle', {
|
||||
defaultMessage: 'Remove ES|QL query from Starred',
|
||||
})
|
||||
: i18n.translate('esqlEditor.query.querieshistory.addFavoriteTitle', {
|
||||
defaultMessage: 'Add ES|QL query to Starred',
|
||||
})
|
||||
}
|
||||
className={!isStarred ? 'cm-favorite-button--empty' : ''}
|
||||
aria-label={
|
||||
isStarred
|
||||
? i18n.translate('esqlEditor.query.querieshistory.removeFavoriteTitle', {
|
||||
defaultMessage: 'Remove ES|QL query from Starred',
|
||||
})
|
||||
: i18n.translate('esqlEditor.query.querieshistory.addFavoriteTitle', {
|
||||
defaultMessage: 'Add ES|QL query to Starred',
|
||||
})
|
||||
}
|
||||
iconType={isStarred ? 'starFilled' : 'starEmpty'}
|
||||
disabled={!isStarred && this.checkIfStarredQueriesLimitReached()}
|
||||
onClick={async () => {
|
||||
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"
|
||||
/>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(<QueryHistoryAction toggleHistory={jest.fn()} isHistoryOpen />);
|
||||
|
@ -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(
|
||||
<KibanaContextProvider services={services}>
|
||||
<HistoryAndStarredQueriesTabs
|
||||
containerCSS={{}}
|
||||
containerWidth={1024}
|
||||
onUpdateAndSubmit={jest.fn()}
|
||||
height={200}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
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(
|
||||
<KibanaContextProvider services={services}>
|
||||
<HistoryAndStarredQueriesTabs
|
||||
containerCSS={{}}
|
||||
containerWidth={1024}
|
||||
onUpdateAndSubmit={jest.fn()}
|
||||
height={200}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
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(
|
||||
<KibanaContextProvider services={services}>
|
||||
<HistoryAndStarredQueriesTabs
|
||||
containerCSS={{}}
|
||||
containerWidth={1024}
|
||||
onUpdateAndSubmit={jest.fn()}
|
||||
height={200}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
// 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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<CustomItemAction<QueryHistoryItem>>
|
||||
actions: Array<CustomItemAction<QueryHistoryItem>>,
|
||||
isStarredTab = false,
|
||||
starredQueriesService?: EsqlStarredQueriesService
|
||||
): Array<EuiBasicTableColumn<QueryHistoryItem>> => {
|
||||
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']) => (
|
||||
<QueryColumn
|
||||
|
@ -180,11 +208,15 @@ export const getTableColumns = (
|
|||
{
|
||||
field: 'timeRan',
|
||||
'data-test-subj': 'timeRan',
|
||||
name: i18n.translate('esqlEditor.query.timeRanColumnLabel', {
|
||||
defaultMessage: 'Time ran',
|
||||
}),
|
||||
name: isStarredTab
|
||||
? i18n.translate('esqlEditor.query.dateAddedColumnLabel', {
|
||||
defaultMessage: 'Date Added',
|
||||
})
|
||||
: i18n.translate('esqlEditor.query.timeRanColumnLabel', {
|
||||
defaultMessage: 'Time ran',
|
||||
}),
|
||||
sortable: true,
|
||||
render: (timeRan: QueryHistoryItem['timeRan']) => 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<Theme>;
|
||||
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<QueryHistoryItem>({
|
||||
tableId: 'esqlQueryHistory',
|
||||
|
@ -221,8 +264,6 @@ export function QueryHistory({
|
|||
},
|
||||
});
|
||||
|
||||
const historyItems: QueryHistoryItem[] = getHistoryItems(sorting.sort.direction);
|
||||
|
||||
const actions: Array<CustomItemAction<QueryHistoryItem>> = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
|
@ -232,16 +273,16 @@ export function QueryHistory({
|
|||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={i18n.translate('esqlEditor.query.querieshistoryRun', {
|
||||
content={i18n.translate('esqlEditor.query.esqlQueriesListRun', {
|
||||
defaultMessage: 'Run query',
|
||||
})}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
iconType="play"
|
||||
aria-label={i18n.translate('esqlEditor.query.querieshistoryRun', {
|
||||
aria-label={i18n.translate('esqlEditor.query.esqlQueriesListRun', {
|
||||
defaultMessage: 'Run query',
|
||||
})}
|
||||
data-test-subj="ESQLEditor-queryHistory-runQuery-button"
|
||||
data-test-subj="ESQLEditor-history-starred-queries-run-button"
|
||||
role="button"
|
||||
iconSize="m"
|
||||
onClick={() => onUpdateAndSubmit(item.queryString)}
|
||||
|
@ -254,7 +295,7 @@ export function QueryHistory({
|
|||
<EuiFlexItem grow={false}>
|
||||
<EuiCopy
|
||||
textToCopy={item.queryString}
|
||||
content={i18n.translate('esqlEditor.query.querieshistoryCopy', {
|
||||
content={i18n.translate('esqlEditor.query.esqlQueriesCopy', {
|
||||
defaultMessage: 'Copy query to clipboard',
|
||||
})}
|
||||
>
|
||||
|
@ -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 (
|
||||
<div data-test-subj="ESQLEditor-queryHistory" css={containerCSS}>
|
||||
<div data-test-subj={dataTestSubj ?? 'ESQLEditor-queryList'} css={containerCSS}>
|
||||
<EuiInMemoryTable
|
||||
tableCaption={i18n.translate('esqlEditor.query.querieshistoryTable', {
|
||||
defaultMessage: 'Queries history table',
|
||||
})}
|
||||
tableCaption={
|
||||
tableCaption ||
|
||||
i18n.translate('esqlEditor.query.queriesListTable', {
|
||||
defaultMessage: 'ES|QL Queries table',
|
||||
})
|
||||
}
|
||||
responsiveBreakpoint={false}
|
||||
items={historyItems}
|
||||
items={listItems}
|
||||
columns={columns}
|
||||
sorting={sorting}
|
||||
onChange={onTableChange}
|
||||
css={tableStyling}
|
||||
tableLayout={containerWidth < 560 ? 'auto' : 'fixed'}
|
||||
/>
|
||||
{isDiscardQueryModalVisible && (
|
||||
<DiscardStarredQueryModal
|
||||
onClose={async (dismissFlag, removeQuery) =>
|
||||
(await starredQueriesService?.onDiscardModalClose(dismissFlag, removeQuery)) ??
|
||||
Promise.resolve()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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<Theme>;
|
||||
containerWidth: number;
|
||||
onUpdateAndSubmit: (qs: string) => void;
|
||||
height: number;
|
||||
}) {
|
||||
const kibana = useKibana<ESQLEditorDeps>();
|
||||
const { core, usageCollection, storage } = kibana.services;
|
||||
|
||||
const [starredQueriesService, setStarredQueriesService] = useState<EsqlStarredQueriesService>();
|
||||
const [starredQueries, setStarredQueries] = useState<StarredQueryItem[]>([]);
|
||||
|
||||
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: (
|
||||
<QueryList
|
||||
containerCSS={containerCSS}
|
||||
onUpdateAndSubmit={onUpdateAndSubmit}
|
||||
containerWidth={containerWidth}
|
||||
height={height}
|
||||
listItems={getHistoryItems('desc')}
|
||||
dataTestSubj="ESQLEditor-queryHistory"
|
||||
tableCaption={i18n.translate('esqlEditor.query.querieshistoryTable', {
|
||||
defaultMessage: 'Queries history table',
|
||||
})}
|
||||
starredQueriesService={starredQueriesService}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'starred-queries-tab',
|
||||
dataTestSubj: 'starred-queries-tab',
|
||||
name: i18n.translate('esqlEditor.query.starredQueriesTabLabel', {
|
||||
defaultMessage: 'Starred',
|
||||
}),
|
||||
append: (
|
||||
<EuiNotificationBadge className="eui-alignCenter" size="m" color="subdued">
|
||||
{starredQueries?.length}
|
||||
</EuiNotificationBadge>
|
||||
),
|
||||
content: (
|
||||
<QueryList
|
||||
containerCSS={containerCSS}
|
||||
onUpdateAndSubmit={onUpdateAndSubmit}
|
||||
containerWidth={containerWidth}
|
||||
height={height}
|
||||
listItems={starredQueries}
|
||||
dataTestSubj="ESQLEditor-starredQueries"
|
||||
tableCaption={i18n.translate('esqlEditor.query.starredQueriesTable', {
|
||||
defaultMessage: 'Starred queries table',
|
||||
})}
|
||||
starredQueriesService={starredQueriesService}
|
||||
isStarredTab={true}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [
|
||||
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) => (
|
||||
<EuiTab
|
||||
key={index}
|
||||
onClick={() => onSelectedTabChanged(tab.id)}
|
||||
isSelected={tab.id === selectedTabId}
|
||||
append={tab.append}
|
||||
data-test-subj={tab.dataTestSubj}
|
||||
>
|
||||
{tab.name}
|
||||
</EuiTab>
|
||||
));
|
||||
}, [selectedTabId, tabs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup
|
||||
responsive={false}
|
||||
alignItems="center"
|
||||
justifyContent="spaceBetween"
|
||||
css={css`
|
||||
background-color: ${euiTheme.colors.lightestShade};
|
||||
padding-left: ${euiTheme.size.s};
|
||||
padding-right: ${euiTheme.size.s};
|
||||
border-block-end: ${euiTheme.border.thin};
|
||||
`}
|
||||
>
|
||||
<EuiTabs bottomBorder={false} size="s">
|
||||
{renderTabs()}
|
||||
</EuiTabs>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
|
||||
<EuiText
|
||||
size="xs"
|
||||
color="subdued"
|
||||
data-test-subj="ESQLEditor-history-starred-queries-helpText"
|
||||
>
|
||||
<p>
|
||||
{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,
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
{selectedTabContent}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 () {
|
|
@ -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;
|
||||
}
|
|
@ -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({
|
|||
</EuiFlexItem>
|
||||
{isHistoryOpen && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<QueryHistory
|
||||
<HistoryAndStarredQueriesTabs
|
||||
containerCSS={styles.historyContainer}
|
||||
onUpdateAndSubmit={onUpdateAndSubmit}
|
||||
containerWidth={measuredContainerWidth}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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 { EuiToolTip, EuiToolTipProps } from '@elastic/eui';
|
||||
|
||||
export type TooltipWrapperProps = Partial<Omit<EuiToolTipProps, 'content'>> & {
|
||||
tooltipContent: string;
|
||||
/** When the condition is truthy, the tooltip will be shown */
|
||||
condition: boolean;
|
||||
};
|
||||
|
||||
export const TooltipWrapper: React.FunctionComponent<TooltipWrapperProps> = ({
|
||||
children,
|
||||
condition,
|
||||
tooltipContent,
|
||||
...tooltipProps
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{condition ? (
|
||||
<EuiToolTip content={tooltipContent} delay="regular" {...tooltipProps}>
|
||||
<>{children}</>
|
||||
</EuiToolTip>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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<string>(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 }) => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -75,6 +75,8 @@ export class DashboardPlugin
|
|||
},
|
||||
});
|
||||
|
||||
plugins.contentManagement.favorites.registerFavoriteType('dashboard');
|
||||
|
||||
if (plugins.taskManager) {
|
||||
initializeDashboardTelemetryTask(this.logger, core, plugins.taskManager, plugins.embeddable);
|
||||
}
|
||||
|
|
|
@ -10,16 +10,19 @@
|
|||
"browser": true,
|
||||
"optionalPlugins": [
|
||||
"indexManagement",
|
||||
"fieldsMetadata"
|
||||
"fieldsMetadata",
|
||||
"usageCollection"
|
||||
],
|
||||
"requiredPlugins": [
|
||||
"data",
|
||||
"expressions",
|
||||
"dataViews",
|
||||
"uiActions",
|
||||
"contentManagement"
|
||||
],
|
||||
"requiredBundles": [
|
||||
"kibanaReact",
|
||||
"kibanaUtils",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<ServiceDeps | undefined>(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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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() {}
|
||||
|
|
|
@ -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 {};
|
||||
}
|
||||
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -402,12 +402,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 () => {
|
||||
|
@ -424,12 +419,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 () => {
|
||||
|
@ -443,12 +436,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 () => {
|
||||
|
|
|
@ -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<string[][]> {
|
||||
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<string[][]> {
|
||||
const starredQueries = await this.testSubjects.find('ESQLEditor-starredQueries');
|
||||
const tableItems = await this.getStarredHistoryTableItems(starredQueries);
|
||||
return tableItems;
|
||||
}
|
||||
|
||||
private async getStarredHistoryTableItems(element: WebElementWrapper): Promise<string[][]> {
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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<string, unknown>;
|
||||
constructor(defaultStore: Record<string, unknown>) {
|
||||
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
|
||||
);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "更新",
|
||||
|
|
|
@ -3155,8 +3155,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": "刷新",
|
||||
|
|
|
@ -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('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: '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: 'fav3', 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',
|
||||
});
|
||||
|
||||
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([]);
|
||||
|
||||
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']);
|
||||
|
||||
response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
|
||||
// check that reader user can favorite
|
||||
const interactiveUser3 = await loginAsInteractiveUser({
|
||||
getService,
|
||||
username: 'content_reader_dashboard_2',
|
||||
});
|
||||
|
||||
response = await api.list({ user: interactiveUser3 });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser3 });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
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);
|
||||
});
|
||||
|
||||
// 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('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([]);
|
||||
|
||||
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.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.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',
|
||||
});
|
||||
|
||||
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([]);
|
||||
|
||||
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']);
|
||||
|
||||
response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
|
||||
// check that reader user can favorite
|
||||
const interactiveUser3 = await loginAsInteractiveUser({
|
||||
getService,
|
||||
username: 'content_reader_dashboard_2',
|
||||
});
|
||||
|
||||
response = await api.list({ user: interactiveUser3 });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser3 });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
143
x-pack/test/functional/apps/discover/esql_starred.ts
Normal file
143
x-pack/test/functional/apps/discover/esql_starred.ts
Normal file
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -374,12 +374,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 () => {
|
||||
|
@ -396,12 +391,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 () => {
|
||||
|
@ -415,12 +408,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 () => {
|
||||
|
@ -436,7 +427,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');
|
||||
});
|
||||
|
|
|
@ -3670,6 +3670,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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue