[8.x] [ES|QL] Starred queries in the editor (#198362) (#200672)

# 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:
Stratoula Kalafateli 2024-11-19 09:24:54 +01:00 committed by GitHub
parent 428eed52be
commit f5ca0f7a6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1971 additions and 297 deletions

View file

@ -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",

View file

@ -0,0 +1,3 @@
# @kbn/content-management-favorites-common
Shared client & server code for the favorites packages.

View file

@ -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;

View file

@ -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'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/content-management-favorites-common",
"owner": "@elastic/appex-sharedux"
}

View file

@ -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"
}

View file

@ -0,0 +1,17 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": []
}

View file

@ -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`
);

View file

@ -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
);
},
}

View file

@ -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';

View file

@ -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`);
}
}
}
}

View file

@ -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,
});
}
);

View file

@ -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,
},
},
},
};

View file

@ -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
}
}

View file

@ -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;
}

View file

@ -19,5 +19,6 @@
"@kbn/core-saved-objects-api-server",
"@kbn/core-lifecycle-server",
"@kbn/usage-collection-plugin",
"@kbn/content-management-favorites-common",
]
}

View file

@ -443,6 +443,7 @@
],
"favorites": [
"favoriteIds",
"favoriteMetadata",
"type",
"userId"
],

View file

@ -1509,6 +1509,10 @@
"favoriteIds": {
"type": "keyword"
},
"favoriteMetadata": {
"dynamic": false,
"type": "object"
},
"type": {
"type": "keyword"
},

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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);
});
});

View file

@ -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>
);
}
}

View file

@ -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)'
);
});
});
});

View file

@ -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}
</>
);
}

View file

@ -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 () {

View file

@ -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;
}

View file

@ -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}

View file

@ -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
)}
</>
);
};

View file

@ -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 }) => {

View file

@ -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

View file

@ -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);

View file

@ -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;
}

View file

@ -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": [

View file

@ -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",

View file

@ -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');

View file

@ -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,
};
}

View file

@ -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 {}

View file

@ -75,6 +75,8 @@ export class DashboardPlugin
},
});
plugins.contentManagement.favorites.registerFavoriteType('dashboard');
if (plugins.taskManager) {
initializeDashboardTelemetryTask(this.logger, core, plugins.taskManager, plugins.embeddable);
}

View file

@ -10,16 +10,19 @@
"browser": true,
"optionalPlugins": [
"indexManagement",
"fieldsMetadata"
"fieldsMetadata",
"usageCollection"
],
"requiredPlugins": [
"data",
"expressions",
"dataViews",
"uiActions",
"contentManagement"
],
"requiredBundles": [
"kibanaReact",
"kibanaUtils",
]
}
}

View file

@ -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,
});
};

View file

@ -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() {}

View file

@ -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 {};
}

View file

@ -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/**/*",

View file

@ -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 () => {

View file

@ -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();
}

View file

@ -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"],

View file

@ -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
);

View file

@ -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",

View file

@ -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": "更新",

View file

@ -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": "刷新",

View file

@ -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({});
});
});
});

View 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');
});
});
}

View file

@ -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'));
});
}

View file

@ -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');
});

View file

@ -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 ""