mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[ES|QL] Starred queries in the editor (#198362)
## Summary close https://github.com/elastic/kibana/issues/194165 close https://github.com/elastic/kibana-team/issues/1245 ### User-facing <img width="1680" alt="image" src="https://github.com/user-attachments/assets/6df4ee9f-1b4d-404c-a764-592998a1d430"> This PRs adds a new tab in the editor history component. You can star your query from the history and then you will see it in the Starred list. The started queries are scoped to a user and a space. ### Server To allow starring ESQL query, this PR extends [favorites service](https://github.com/elastic/kibana/pull/189285) with ability to store metadata in addition to an id. To make metadata strict and in future to support proper metadata migrations if needed, metadata needs to be defined as schema: ``` plugins.contentManagement.favorites.registerFavoriteType('esql_query', { typeMetadataSchema: schema.object({ query: schema.string(), timeRange:...., etc... }), }) ``` Notable changes: - Add support for registering a favorite type and a schema for favorite type metadata. Previosly the `dashboard` type was the only supported type and was hardcoded - Add `favoriteMetadata` property to a saved object mapping and make it `enabled:false` we don't want to index it, but just want to store metadata in addition to an id. [code](https://github.com/elastic/kibana/pull/198362/files#diff-d1a39e36f1de11a1110520d7607e6aee7d506c76626993842cb58db012b760a2R74-R87) - Add a 100 favorite items limit (per type per space per user). Just do it for sanity to prevent too large objects due to metadata stored in addtion to ids. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co> Co-authored-by: Stratoula Kalafateli <stratoula1@gmail.com>
This commit is contained in:
parent
974293fa01
commit
45972374f0
56 changed files with 1972 additions and 297 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -47,6 +47,7 @@ packages/cloud @elastic/kibana-core
|
|||
packages/content-management/content_editor @elastic/appex-sharedux
|
||||
packages/content-management/content_insights/content_insights_public @elastic/appex-sharedux
|
||||
packages/content-management/content_insights/content_insights_server @elastic/appex-sharedux
|
||||
packages/content-management/favorites/favorites_common @elastic/appex-sharedux
|
||||
packages/content-management/favorites/favorites_public @elastic/appex-sharedux
|
||||
packages/content-management/favorites/favorites_server @elastic/appex-sharedux
|
||||
packages/content-management/tabbed_table_list_view @elastic/appex-sharedux
|
||||
|
|
|
@ -232,6 +232,7 @@
|
|||
"@kbn/content-management-content-insights-public": "link:packages/content-management/content_insights/content_insights_public",
|
||||
"@kbn/content-management-content-insights-server": "link:packages/content-management/content_insights/content_insights_server",
|
||||
"@kbn/content-management-examples-plugin": "link:examples/content_management_examples",
|
||||
"@kbn/content-management-favorites-common": "link:packages/content-management/favorites/favorites_common",
|
||||
"@kbn/content-management-favorites-public": "link:packages/content-management/favorites/favorites_public",
|
||||
"@kbn/content-management-favorites-server": "link:packages/content-management/favorites/favorites_server",
|
||||
"@kbn/content-management-plugin": "link:src/plugins/content_management",
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/content-management-favorites-common
|
||||
|
||||
Shared client & server code for the favorites packages.
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
// Limit the number of favorites to prevent too large objects due to metadata
|
||||
export const FAVORITES_LIMIT = 100;
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test/jest_node',
|
||||
rootDir: '../../../..',
|
||||
roots: ['<rootDir>/packages/content-management/favorites/favorites_common'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/content-management-favorites-common",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/content-management-favorites-common",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": []
|
||||
}
|
|
@ -9,36 +9,52 @@
|
|||
|
||||
import type { HttpStart } from '@kbn/core-http-browser';
|
||||
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
|
||||
import type { GetFavoritesResponse } from '@kbn/content-management-favorites-server';
|
||||
import type {
|
||||
GetFavoritesResponse as GetFavoritesResponseServer,
|
||||
AddFavoriteResponse,
|
||||
RemoveFavoriteResponse,
|
||||
} from '@kbn/content-management-favorites-server';
|
||||
|
||||
export interface FavoritesClientPublic {
|
||||
getFavorites(): Promise<GetFavoritesResponse>;
|
||||
addFavorite({ id }: { id: string }): Promise<GetFavoritesResponse>;
|
||||
removeFavorite({ id }: { id: string }): Promise<GetFavoritesResponse>;
|
||||
export interface GetFavoritesResponse<Metadata extends object | void = void>
|
||||
extends GetFavoritesResponseServer {
|
||||
favoriteMetadata: Metadata extends object ? Record<string, Metadata> : never;
|
||||
}
|
||||
|
||||
type AddFavoriteRequest<Metadata extends object | void> = Metadata extends object
|
||||
? { id: string; metadata: Metadata }
|
||||
: { id: string };
|
||||
|
||||
export interface FavoritesClientPublic<Metadata extends object | void = void> {
|
||||
getFavorites(): Promise<GetFavoritesResponse<Metadata>>;
|
||||
addFavorite(params: AddFavoriteRequest<Metadata>): Promise<AddFavoriteResponse>;
|
||||
removeFavorite(params: { id: string }): Promise<RemoveFavoriteResponse>;
|
||||
|
||||
getFavoriteType(): string;
|
||||
reportAddFavoriteClick(): void;
|
||||
reportRemoveFavoriteClick(): void;
|
||||
}
|
||||
|
||||
export class FavoritesClient implements FavoritesClientPublic {
|
||||
export class FavoritesClient<Metadata extends object | void = void>
|
||||
implements FavoritesClientPublic<Metadata>
|
||||
{
|
||||
constructor(
|
||||
private readonly appName: string,
|
||||
private readonly favoriteObjectType: string,
|
||||
private readonly deps: { http: HttpStart; usageCollection?: UsageCollectionStart }
|
||||
) {}
|
||||
|
||||
public async getFavorites(): Promise<GetFavoritesResponse> {
|
||||
public async getFavorites(): Promise<GetFavoritesResponse<Metadata>> {
|
||||
return this.deps.http.get(`/internal/content_management/favorites/${this.favoriteObjectType}`);
|
||||
}
|
||||
|
||||
public async addFavorite({ id }: { id: string }): Promise<GetFavoritesResponse> {
|
||||
public async addFavorite(params: AddFavoriteRequest<Metadata>): Promise<AddFavoriteResponse> {
|
||||
return this.deps.http.post(
|
||||
`/internal/content_management/favorites/${this.favoriteObjectType}/${id}/favorite`
|
||||
`/internal/content_management/favorites/${this.favoriteObjectType}/${params.id}/favorite`,
|
||||
{ body: 'metadata' in params ? JSON.stringify({ metadata: params.metadata }) : undefined }
|
||||
);
|
||||
}
|
||||
|
||||
public async removeFavorite({ id }: { id: string }): Promise<GetFavoritesResponse> {
|
||||
public async removeFavorite({ id }: { id: string }): Promise<RemoveFavoriteResponse> {
|
||||
return this.deps.http.post(
|
||||
`/internal/content_management/favorites/${this.favoriteObjectType}/${id}/unfavorite`
|
||||
);
|
||||
|
|
|
@ -11,6 +11,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
|
||||
import type { IHttpFetchError } from '@kbn/core-http-browser';
|
||||
import { useFavoritesClient, useFavoritesContext } from './favorites_context';
|
||||
|
||||
const favoritesKeys = {
|
||||
|
@ -54,14 +55,14 @@ export const useAddFavorite = () => {
|
|||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(favoritesKeys.byType(favoritesClient!.getFavoriteType()), data);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
onError: (error: IHttpFetchError<{ message?: string }>) => {
|
||||
notifyError?.(
|
||||
<>
|
||||
{i18n.translate('contentManagement.favorites.addFavoriteError', {
|
||||
defaultMessage: 'Error adding to Starred',
|
||||
})}
|
||||
</>,
|
||||
error?.message
|
||||
error?.body?.message ?? error.message
|
||||
);
|
||||
},
|
||||
}
|
||||
|
|
|
@ -7,4 +7,10 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { registerFavorites, type GetFavoritesResponse } from './src';
|
||||
export {
|
||||
registerFavorites,
|
||||
type GetFavoritesResponse,
|
||||
type FavoritesSetup,
|
||||
type AddFavoriteResponse,
|
||||
type RemoveFavoriteResponse,
|
||||
} from './src';
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
import { ObjectType } from '@kbn/config-schema';
|
||||
|
||||
interface FavoriteTypeConfig {
|
||||
typeMetadataSchema?: ObjectType;
|
||||
}
|
||||
|
||||
export type FavoritesRegistrySetup = Pick<FavoritesRegistry, 'registerFavoriteType'>;
|
||||
|
||||
export class FavoritesRegistry {
|
||||
private favoriteTypes = new Map<string, FavoriteTypeConfig>();
|
||||
|
||||
registerFavoriteType(type: string, config: FavoriteTypeConfig = {}) {
|
||||
if (this.favoriteTypes.has(type)) {
|
||||
throw new Error(`Favorite type ${type} is already registered`);
|
||||
}
|
||||
|
||||
this.favoriteTypes.set(type, config);
|
||||
}
|
||||
|
||||
hasType(type: string) {
|
||||
return this.favoriteTypes.has(type);
|
||||
}
|
||||
|
||||
validateMetadata(type: string, metadata?: object) {
|
||||
if (!this.hasType(type)) {
|
||||
throw new Error(`Favorite type ${type} is not registered`);
|
||||
}
|
||||
|
||||
const typeConfig = this.favoriteTypes.get(type)!;
|
||||
const typeMetadataSchema = typeConfig.typeMetadataSchema;
|
||||
|
||||
if (typeMetadataSchema) {
|
||||
typeMetadataSchema.validate(metadata);
|
||||
} else {
|
||||
if (metadata === undefined) {
|
||||
return; /* ok */
|
||||
} else {
|
||||
throw new Error(`Favorite type ${type} does not support metadata`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,12 +14,9 @@ import {
|
|||
SECURITY_EXTENSION_ID,
|
||||
} from '@kbn/core/server';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { FavoritesService } from './favorites_service';
|
||||
import { FavoritesService, FavoritesLimitExceededError } from './favorites_service';
|
||||
import { favoritesSavedObjectType } from './favorites_saved_object';
|
||||
|
||||
// only dashboard is supported for now
|
||||
// TODO: make configurable or allow any string
|
||||
const typeSchema = schema.oneOf([schema.literal('dashboard')]);
|
||||
import { FavoritesRegistry } from './favorites_registry';
|
||||
|
||||
/**
|
||||
* @public
|
||||
|
@ -27,9 +24,45 @@ const typeSchema = schema.oneOf([schema.literal('dashboard')]);
|
|||
*/
|
||||
export interface GetFavoritesResponse {
|
||||
favoriteIds: string[];
|
||||
favoriteMetadata?: Record<string, object>;
|
||||
}
|
||||
|
||||
export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; logger: Logger }) {
|
||||
export interface AddFavoriteResponse {
|
||||
favoriteIds: string[];
|
||||
}
|
||||
|
||||
export interface RemoveFavoriteResponse {
|
||||
favoriteIds: string[];
|
||||
}
|
||||
|
||||
export function registerFavoritesRoutes({
|
||||
core,
|
||||
logger,
|
||||
favoritesRegistry,
|
||||
}: {
|
||||
core: CoreSetup;
|
||||
logger: Logger;
|
||||
favoritesRegistry: FavoritesRegistry;
|
||||
}) {
|
||||
const typeSchema = schema.string({
|
||||
validate: (type) => {
|
||||
if (!favoritesRegistry.hasType(type)) {
|
||||
return `Unknown favorite type: ${type}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const metadataSchema = schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
// validated later by the registry depending on the type
|
||||
},
|
||||
{
|
||||
unknowns: 'allow',
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const router = core.http.createRouter();
|
||||
|
||||
const getSavedObjectClient = (coreRequestHandlerContext: CoreRequestHandlerContext) => {
|
||||
|
@ -49,6 +82,13 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log
|
|||
id: schema.string(),
|
||||
type: typeSchema,
|
||||
}),
|
||||
body: schema.maybe(
|
||||
schema.nullable(
|
||||
schema.object({
|
||||
metadata: metadataSchema,
|
||||
})
|
||||
)
|
||||
),
|
||||
},
|
||||
// we don't protect the route with any access tags as
|
||||
// we only give access to the current user's favorites ids
|
||||
|
@ -67,13 +107,35 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log
|
|||
const favorites = new FavoritesService(type, userId, {
|
||||
savedObjectClient: getSavedObjectClient(coreRequestHandlerContext),
|
||||
logger,
|
||||
favoritesRegistry,
|
||||
});
|
||||
|
||||
const favoriteIds: GetFavoritesResponse = await favorites.addFavorite({
|
||||
id: request.params.id,
|
||||
});
|
||||
const id = request.params.id;
|
||||
const metadata = request.body?.metadata;
|
||||
|
||||
return response.ok({ body: favoriteIds });
|
||||
try {
|
||||
favoritesRegistry.validateMetadata(type, metadata);
|
||||
} catch (e) {
|
||||
return response.badRequest({ body: { message: e.message } });
|
||||
}
|
||||
|
||||
try {
|
||||
const favoritesResult = await favorites.addFavorite({
|
||||
id,
|
||||
metadata,
|
||||
});
|
||||
const addFavoritesResponse: AddFavoriteResponse = {
|
||||
favoriteIds: favoritesResult.favoriteIds,
|
||||
};
|
||||
|
||||
return response.ok({ body: addFavoritesResponse });
|
||||
} catch (e) {
|
||||
if (e instanceof FavoritesLimitExceededError) {
|
||||
return response.forbidden({ body: { message: e.message } });
|
||||
}
|
||||
|
||||
throw e; // unexpected error, let the global error handler deal with it
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -102,12 +164,18 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log
|
|||
const favorites = new FavoritesService(type, userId, {
|
||||
savedObjectClient: getSavedObjectClient(coreRequestHandlerContext),
|
||||
logger,
|
||||
favoritesRegistry,
|
||||
});
|
||||
|
||||
const favoriteIds: GetFavoritesResponse = await favorites.removeFavorite({
|
||||
const favoritesResult: GetFavoritesResponse = await favorites.removeFavorite({
|
||||
id: request.params.id,
|
||||
});
|
||||
return response.ok({ body: favoriteIds });
|
||||
|
||||
const removeFavoriteResponse: RemoveFavoriteResponse = {
|
||||
favoriteIds: favoritesResult.favoriteIds,
|
||||
};
|
||||
|
||||
return response.ok({ body: removeFavoriteResponse });
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -135,12 +203,18 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log
|
|||
const favorites = new FavoritesService(type, userId, {
|
||||
savedObjectClient: getSavedObjectClient(coreRequestHandlerContext),
|
||||
logger,
|
||||
favoritesRegistry,
|
||||
});
|
||||
|
||||
const getFavoritesResponse: GetFavoritesResponse = await favorites.getFavorites();
|
||||
const favoritesResult = await favorites.getFavorites();
|
||||
|
||||
const favoritesResponse: GetFavoritesResponse = {
|
||||
favoriteIds: favoritesResult.favoriteIds,
|
||||
favoriteMetadata: favoritesResult.favoriteMetadata,
|
||||
};
|
||||
|
||||
return response.ok({
|
||||
body: getFavoritesResponse,
|
||||
body: favoritesResponse,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
@ -14,6 +14,7 @@ export interface FavoritesSavedObjectAttributes {
|
|||
userId: string;
|
||||
type: string;
|
||||
favoriteIds: string[];
|
||||
favoriteMetadata?: Record<string, object>;
|
||||
}
|
||||
|
||||
const schemaV1 = schema.object({
|
||||
|
@ -22,6 +23,10 @@ const schemaV1 = schema.object({
|
|||
favoriteIds: schema.arrayOf(schema.string()),
|
||||
});
|
||||
|
||||
const schemaV3 = schemaV1.extends({
|
||||
favoriteMetadata: schema.maybe(schema.object({}, { unknowns: 'allow' })),
|
||||
});
|
||||
|
||||
export const favoritesSavedObjectName = 'favorites';
|
||||
|
||||
export const favoritesSavedObjectType: SavedObjectsType = {
|
||||
|
@ -34,6 +39,7 @@ export const favoritesSavedObjectType: SavedObjectsType = {
|
|||
userId: { type: 'keyword' },
|
||||
type: { type: 'keyword' },
|
||||
favoriteIds: { type: 'keyword' },
|
||||
favoriteMetadata: { type: 'object', dynamic: false },
|
||||
},
|
||||
},
|
||||
modelVersions: {
|
||||
|
@ -65,5 +71,19 @@ export const favoritesSavedObjectType: SavedObjectsType = {
|
|||
create: schemaV1,
|
||||
},
|
||||
},
|
||||
3: {
|
||||
changes: [
|
||||
{
|
||||
type: 'mappings_addition',
|
||||
addedMappings: {
|
||||
favoriteMetadata: { type: 'object', dynamic: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
schemas: {
|
||||
forwardCompatibility: schemaV3.extends({}, { unknowns: 'ignore' }),
|
||||
create: schemaV3,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -7,9 +7,17 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
import type { SavedObject, SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
|
||||
import { FAVORITES_LIMIT } from '@kbn/content-management-favorites-common';
|
||||
import { Logger, SavedObjectsErrorHelpers } from '@kbn/core/server';
|
||||
import { favoritesSavedObjectType, FavoritesSavedObjectAttributes } from './favorites_saved_object';
|
||||
import { FavoritesRegistry } from './favorites_registry';
|
||||
|
||||
export interface FavoritesState {
|
||||
favoriteIds: string[];
|
||||
favoriteMetadata?: Record<string, object>;
|
||||
}
|
||||
|
||||
export class FavoritesService {
|
||||
constructor(
|
||||
|
@ -18,23 +26,38 @@ export class FavoritesService {
|
|||
private readonly deps: {
|
||||
savedObjectClient: SavedObjectsClientContract;
|
||||
logger: Logger;
|
||||
favoritesRegistry: FavoritesRegistry;
|
||||
}
|
||||
) {
|
||||
if (!this.userId || !this.type) {
|
||||
// This should never happen, but just in case let's do a runtime check
|
||||
throw new Error('userId and object type are required to use a favorite service');
|
||||
}
|
||||
|
||||
if (!this.deps.favoritesRegistry.hasType(this.type)) {
|
||||
throw new Error(`Favorite type ${this.type} is not registered`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getFavorites(): Promise<{ favoriteIds: string[] }> {
|
||||
public async getFavorites(): Promise<FavoritesState> {
|
||||
const favoritesSavedObject = await this.getFavoritesSavedObject();
|
||||
|
||||
const favoriteIds = favoritesSavedObject?.attributes?.favoriteIds ?? [];
|
||||
const favoriteMetadata = favoritesSavedObject?.attributes?.favoriteMetadata;
|
||||
|
||||
return { favoriteIds };
|
||||
return { favoriteIds, favoriteMetadata };
|
||||
}
|
||||
|
||||
public async addFavorite({ id }: { id: string }): Promise<{ favoriteIds: string[] }> {
|
||||
/**
|
||||
* @throws {FavoritesLimitExceededError}
|
||||
*/
|
||||
public async addFavorite({
|
||||
id,
|
||||
metadata,
|
||||
}: {
|
||||
id: string;
|
||||
metadata?: object;
|
||||
}): Promise<FavoritesState> {
|
||||
let favoritesSavedObject = await this.getFavoritesSavedObject();
|
||||
|
||||
if (!favoritesSavedObject) {
|
||||
|
@ -44,14 +67,28 @@ export class FavoritesService {
|
|||
userId: this.userId,
|
||||
type: this.type,
|
||||
favoriteIds: [id],
|
||||
...(metadata
|
||||
? {
|
||||
favoriteMetadata: {
|
||||
[id]: metadata,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
id: this.getFavoriteSavedObjectId(),
|
||||
}
|
||||
);
|
||||
|
||||
return { favoriteIds: favoritesSavedObject.attributes.favoriteIds };
|
||||
return {
|
||||
favoriteIds: favoritesSavedObject.attributes.favoriteIds,
|
||||
favoriteMetadata: favoritesSavedObject.attributes.favoriteMetadata,
|
||||
};
|
||||
} else {
|
||||
if ((favoritesSavedObject.attributes.favoriteIds ?? []).length >= FAVORITES_LIMIT) {
|
||||
throw new FavoritesLimitExceededError();
|
||||
}
|
||||
|
||||
const newFavoriteIds = [
|
||||
...(favoritesSavedObject.attributes.favoriteIds ?? []).filter(
|
||||
(favoriteId) => favoriteId !== id
|
||||
|
@ -59,22 +96,34 @@ export class FavoritesService {
|
|||
id,
|
||||
];
|
||||
|
||||
const newFavoriteMetadata = metadata
|
||||
? {
|
||||
...favoritesSavedObject.attributes.favoriteMetadata,
|
||||
[id]: metadata,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await this.deps.savedObjectClient.update(
|
||||
favoritesSavedObjectType.name,
|
||||
favoritesSavedObject.id,
|
||||
{
|
||||
favoriteIds: newFavoriteIds,
|
||||
...(newFavoriteMetadata
|
||||
? {
|
||||
favoriteMetadata: newFavoriteMetadata,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
version: favoritesSavedObject.version,
|
||||
}
|
||||
);
|
||||
|
||||
return { favoriteIds: newFavoriteIds };
|
||||
return { favoriteIds: newFavoriteIds, favoriteMetadata: newFavoriteMetadata };
|
||||
}
|
||||
}
|
||||
|
||||
public async removeFavorite({ id }: { id: string }): Promise<{ favoriteIds: string[] }> {
|
||||
public async removeFavorite({ id }: { id: string }): Promise<FavoritesState> {
|
||||
const favoritesSavedObject = await this.getFavoritesSavedObject();
|
||||
|
||||
if (!favoritesSavedObject) {
|
||||
|
@ -85,19 +134,36 @@ export class FavoritesService {
|
|||
(favoriteId) => favoriteId !== id
|
||||
);
|
||||
|
||||
const newFavoriteMetadata = favoritesSavedObject.attributes.favoriteMetadata
|
||||
? { ...favoritesSavedObject.attributes.favoriteMetadata }
|
||||
: undefined;
|
||||
|
||||
if (newFavoriteMetadata) {
|
||||
delete newFavoriteMetadata[id];
|
||||
}
|
||||
|
||||
await this.deps.savedObjectClient.update(
|
||||
favoritesSavedObjectType.name,
|
||||
favoritesSavedObject.id,
|
||||
{
|
||||
...favoritesSavedObject.attributes,
|
||||
favoriteIds: newFavoriteIds,
|
||||
...(newFavoriteMetadata
|
||||
? {
|
||||
favoriteMetadata: newFavoriteMetadata,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
version: favoritesSavedObject.version,
|
||||
// We don't want to merge the attributes here because we want to remove the keys from the metadata
|
||||
mergeAttributes: false,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
favoriteIds: newFavoriteIds,
|
||||
favoriteMetadata: newFavoriteMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -123,3 +189,14 @@ export class FavoritesService {
|
|||
return `${this.type}:${this.userId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class FavoritesLimitExceededError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
`Limit reached: This list can contain a maximum of ${FAVORITES_LIMIT} items. Please remove an item before adding a new one.`
|
||||
);
|
||||
|
||||
this.name = 'FavoritesLimitExceededError';
|
||||
Object.setPrototypeOf(this, FavoritesLimitExceededError.prototype); // For TypeScript compatibility
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,8 +12,19 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
|
|||
import { registerFavoritesRoutes } from './favorites_routes';
|
||||
import { favoritesSavedObjectType } from './favorites_saved_object';
|
||||
import { registerFavoritesUsageCollection } from './favorites_usage_collection';
|
||||
import { FavoritesRegistry, FavoritesRegistrySetup } from './favorites_registry';
|
||||
|
||||
export type { GetFavoritesResponse } from './favorites_routes';
|
||||
export type {
|
||||
GetFavoritesResponse,
|
||||
AddFavoriteResponse,
|
||||
RemoveFavoriteResponse,
|
||||
} from './favorites_routes';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Setup contract for the favorites feature.
|
||||
*/
|
||||
export type FavoritesSetup = FavoritesRegistrySetup;
|
||||
|
||||
/**
|
||||
* @public
|
||||
|
@ -31,11 +42,14 @@ export function registerFavorites({
|
|||
core: CoreSetup;
|
||||
logger: Logger;
|
||||
usageCollection?: UsageCollectionSetup;
|
||||
}) {
|
||||
}): FavoritesSetup {
|
||||
const favoritesRegistry = new FavoritesRegistry();
|
||||
core.savedObjects.registerType(favoritesSavedObjectType);
|
||||
registerFavoritesRoutes({ core, logger });
|
||||
registerFavoritesRoutes({ core, logger, favoritesRegistry });
|
||||
|
||||
if (usageCollection) {
|
||||
registerFavoritesUsageCollection({ core, usageCollection });
|
||||
}
|
||||
|
||||
return favoritesRegistry;
|
||||
}
|
||||
|
|
|
@ -19,5 +19,6 @@
|
|||
"@kbn/core-saved-objects-api-server",
|
||||
"@kbn/core-lifecycle-server",
|
||||
"@kbn/usage-collection-plugin",
|
||||
"@kbn/content-management-favorites-common",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -443,6 +443,7 @@
|
|||
],
|
||||
"favorites": [
|
||||
"favoriteIds",
|
||||
"favoriteMetadata",
|
||||
"type",
|
||||
"userId"
|
||||
],
|
||||
|
|
|
@ -1509,6 +1509,10 @@
|
|||
"favoriteIds": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"favoriteMetadata": {
|
||||
"dynamic": false,
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "keyword"
|
||||
},
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiModal,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiText,
|
||||
EuiCheckbox,
|
||||
EuiFlexItem,
|
||||
EuiFlexGroup,
|
||||
EuiHorizontalRule,
|
||||
} from '@elastic/eui';
|
||||
|
||||
export interface DiscardStarredQueryModalProps {
|
||||
onClose: (dismissFlag?: boolean, removeQuery?: boolean) => Promise<void>;
|
||||
}
|
||||
// Needed for React.lazy
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function DiscardStarredQueryModal({ onClose }: DiscardStarredQueryModalProps) {
|
||||
const [dismissModalChecked, setDismissModalChecked] = useState(false);
|
||||
const onTransitionModalDismiss = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDismissModalChecked(e.target.checked);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EuiModal
|
||||
onClose={() => onClose()}
|
||||
style={{ width: 700 }}
|
||||
data-test-subj="discard-starred-query-modal"
|
||||
>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>
|
||||
{i18n.translate('esqlEditor.discardStarredQueryModal.title', {
|
||||
defaultMessage: 'Discard starred query',
|
||||
})}
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
|
||||
<EuiModalBody>
|
||||
<EuiText size="m">
|
||||
{i18n.translate('esqlEditor.discardStarredQueryModal.body', {
|
||||
defaultMessage:
|
||||
'Removing a starred query will remove it from the list. This has no impact on the recent query history.',
|
||||
})}
|
||||
</EuiText>
|
||||
<EuiHorizontalRule margin="s" />
|
||||
</EuiModalBody>
|
||||
<EuiModalFooter css={{ paddingBlockStart: 0 }}>
|
||||
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiCheckbox
|
||||
id="dismiss-discard-starred-query-modal"
|
||||
label={i18n.translate('esqlEditor.discardStarredQueryModal.dismissButtonLabel', {
|
||||
defaultMessage: "Don't ask me again",
|
||||
})}
|
||||
checked={dismissModalChecked}
|
||||
onChange={onTransitionModalDismiss}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
onClick={async () => {
|
||||
await onClose(dismissModalChecked, false);
|
||||
}}
|
||||
color="primary"
|
||||
data-test-subj="esqlEditor-discard-starred-query-cancel-btn"
|
||||
>
|
||||
{i18n.translate('esqlEditor.discardStarredQueryModal.cancelLabel', {
|
||||
defaultMessage: 'Cancel',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
onClick={async () => {
|
||||
await onClose(dismissModalChecked, true);
|
||||
}}
|
||||
color="danger"
|
||||
iconType="trash"
|
||||
data-test-subj="esqlEditor-discard-starred-query-discard-btn"
|
||||
>
|
||||
{i18n.translate('esqlEditor.discardStarredQueryModal.discardQueryLabel', {
|
||||
defaultMessage: 'Discard query',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiModalFooter>
|
||||
</EuiModal>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { DiscardStarredQueryModalProps } from './discard_starred_query_modal';
|
||||
|
||||
const Fallback = () => <div />;
|
||||
|
||||
const LazyDiscardStarredQueryModal = React.lazy(() => import('./discard_starred_query_modal'));
|
||||
export const DiscardStarredQueryModal = (props: DiscardStarredQueryModalProps) => (
|
||||
<React.Suspense fallback={<Fallback />}>
|
||||
<LazyDiscardStarredQueryModal {...props} />
|
||||
</React.Suspense>
|
||||
);
|
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { EsqlStarredQueriesService } from './esql_starred_queries_service';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
|
||||
class LocalStorageMock {
|
||||
public store: Record<string, unknown>;
|
||||
constructor(defaultStore: Record<string, unknown>) {
|
||||
this.store = defaultStore;
|
||||
}
|
||||
clear() {
|
||||
this.store = {};
|
||||
}
|
||||
get(key: string) {
|
||||
return this.store[key] || null;
|
||||
}
|
||||
set(key: string, value: unknown) {
|
||||
this.store[key] = String(value);
|
||||
}
|
||||
remove(key: string) {
|
||||
delete this.store[key];
|
||||
}
|
||||
}
|
||||
|
||||
describe('EsqlStarredQueriesService', () => {
|
||||
const core = coreMock.createStart();
|
||||
const storage = new LocalStorageMock({}) as unknown as Storage;
|
||||
|
||||
it('should initialize', async () => {
|
||||
const service = await EsqlStarredQueriesService.initialize({
|
||||
http: core.http,
|
||||
storage,
|
||||
});
|
||||
expect(service).toBeDefined();
|
||||
expect(service.queries$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('should add a new starred query', async () => {
|
||||
const service = await EsqlStarredQueriesService.initialize({
|
||||
http: core.http,
|
||||
storage,
|
||||
});
|
||||
const query = {
|
||||
queryString: 'SELECT * FROM test',
|
||||
timeRan: '2021-09-01T00:00:00Z',
|
||||
status: 'success' as const,
|
||||
};
|
||||
|
||||
await service.addStarredQuery(query);
|
||||
expect(service.queries$.value).toEqual([
|
||||
{
|
||||
id: expect.any(String),
|
||||
...query,
|
||||
// stores now()
|
||||
timeRan: expect.any(String),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not add the same query twice', async () => {
|
||||
const service = await EsqlStarredQueriesService.initialize({
|
||||
http: core.http,
|
||||
storage,
|
||||
});
|
||||
const query = {
|
||||
queryString: 'SELECT * FROM test',
|
||||
timeRan: '2021-09-01T00:00:00Z',
|
||||
status: 'success' as const,
|
||||
};
|
||||
|
||||
const expected = {
|
||||
id: expect.any(String),
|
||||
...query,
|
||||
// stores now()
|
||||
timeRan: expect.any(String),
|
||||
// trimmed query
|
||||
queryString: 'SELECT * FROM test',
|
||||
};
|
||||
|
||||
await service.addStarredQuery(query);
|
||||
expect(service.queries$.value).toEqual([expected]);
|
||||
|
||||
// second time
|
||||
await service.addStarredQuery(query);
|
||||
expect(service.queries$.value).toEqual([expected]);
|
||||
});
|
||||
|
||||
it('should add the query trimmed', async () => {
|
||||
const service = await EsqlStarredQueriesService.initialize({
|
||||
http: core.http,
|
||||
storage,
|
||||
});
|
||||
const query = {
|
||||
queryString: `SELECT * FROM test |
|
||||
WHERE field != 'value'`,
|
||||
timeRan: '2021-09-01T00:00:00Z',
|
||||
status: 'error' as const,
|
||||
};
|
||||
|
||||
await service.addStarredQuery(query);
|
||||
expect(service.queries$.value).toEqual([
|
||||
{
|
||||
id: expect.any(String),
|
||||
...query,
|
||||
timeRan: expect.any(String),
|
||||
// trimmed query
|
||||
queryString: `SELECT * FROM test | WHERE field != 'value'`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove a query', async () => {
|
||||
const service = await EsqlStarredQueriesService.initialize({
|
||||
http: core.http,
|
||||
storage,
|
||||
});
|
||||
const query = {
|
||||
queryString: `SELECT * FROM test | WHERE field != 'value'`,
|
||||
timeRan: '2021-09-01T00:00:00Z',
|
||||
status: 'error' as const,
|
||||
};
|
||||
|
||||
await service.addStarredQuery(query);
|
||||
expect(service.queries$.value).toEqual([
|
||||
{
|
||||
id: expect.any(String),
|
||||
...query,
|
||||
timeRan: expect.any(String),
|
||||
// trimmed query
|
||||
queryString: `SELECT * FROM test | WHERE field != 'value'`,
|
||||
},
|
||||
]);
|
||||
|
||||
await service.removeStarredQuery(query.queryString);
|
||||
expect(service.queries$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return the button correctly', async () => {
|
||||
const service = await EsqlStarredQueriesService.initialize({
|
||||
http: core.http,
|
||||
storage,
|
||||
});
|
||||
const query = {
|
||||
queryString: 'SELECT * FROM test',
|
||||
timeRan: '2021-09-01T00:00:00Z',
|
||||
status: 'success' as const,
|
||||
};
|
||||
|
||||
await service.addStarredQuery(query);
|
||||
const buttonWithTooltip = service.renderStarredButton(query);
|
||||
const button = buttonWithTooltip.props.children;
|
||||
expect(button.props.title).toEqual('Remove ES|QL query from Starred');
|
||||
expect(button.props.iconType).toEqual('starFilled');
|
||||
});
|
||||
|
||||
it('should display the modal when the Remove button is clicked', async () => {
|
||||
const service = await EsqlStarredQueriesService.initialize({
|
||||
http: core.http,
|
||||
storage,
|
||||
});
|
||||
const query = {
|
||||
queryString: 'SELECT * FROM test',
|
||||
timeRan: '2021-09-01T00:00:00Z',
|
||||
status: 'success' as const,
|
||||
};
|
||||
|
||||
await service.addStarredQuery(query);
|
||||
const buttonWithTooltip = service.renderStarredButton(query);
|
||||
const button = buttonWithTooltip.props.children;
|
||||
expect(button.props.title).toEqual('Remove ES|QL query from Starred');
|
||||
button.props.onClick();
|
||||
|
||||
expect(service.discardModalVisibility$.value).toEqual(true);
|
||||
});
|
||||
|
||||
it('should NOT display the modal when Remove the button is clicked but the user has dismissed the modal permanently', async () => {
|
||||
storage.set('esqlEditor.starredQueriesDiscard', true);
|
||||
const service = await EsqlStarredQueriesService.initialize({
|
||||
http: core.http,
|
||||
storage,
|
||||
});
|
||||
const query = {
|
||||
queryString: 'SELECT * FROM test',
|
||||
timeRan: '2021-09-01T00:00:00Z',
|
||||
status: 'success' as const,
|
||||
};
|
||||
|
||||
await service.addStarredQuery(query);
|
||||
const buttonWithTooltip = service.renderStarredButton(query);
|
||||
const button = buttonWithTooltip.props.children;
|
||||
button.props.onClick();
|
||||
|
||||
expect(service.discardModalVisibility$.value).toEqual(false);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
import React from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import { EuiButtonIcon } from '@elastic/eui';
|
||||
import { FavoritesClient } from '@kbn/content-management-favorites-public';
|
||||
import { FAVORITES_LIMIT as ESQL_STARRED_QUERIES_LIMIT } from '@kbn/content-management-favorites-common';
|
||||
import { type QueryHistoryItem, getTrimmedQuery } from '../history_local_storage';
|
||||
import { TooltipWrapper } from './tooltip_wrapper';
|
||||
|
||||
const STARRED_QUERIES_DISCARD_KEY = 'esqlEditor.starredQueriesDiscard';
|
||||
|
||||
/**
|
||||
* EsqlStarredQueriesService is a service that manages the starred queries in the ES|QL editor.
|
||||
* It provides methods to add and remove queries from the starred list.
|
||||
* It also provides a method to render the starred button in the editor list table.
|
||||
*
|
||||
* @param client - The FavoritesClient instance.
|
||||
* @param starredQueries - The list of starred queries.
|
||||
* @param queries$ - The BehaviorSubject that emits the starred queries list.
|
||||
* @method initialize - Initializes the service and retrieves the starred queries from the favoriteService.
|
||||
* @method checkIfQueryIsStarred - Checks if a query is already starred.
|
||||
* @method addStarredQuery - Adds a query to the starred list.
|
||||
* @method removeStarredQuery - Removes a query from the starred list.
|
||||
* @method renderStarredButton - Renders the starred button in the editor list table.
|
||||
* @returns EsqlStarredQueriesService instance.
|
||||
*
|
||||
*/
|
||||
export interface StarredQueryItem extends QueryHistoryItem {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface EsqlStarredQueriesServices {
|
||||
http: CoreStart['http'];
|
||||
storage: Storage;
|
||||
usageCollection?: UsageCollectionStart;
|
||||
}
|
||||
|
||||
interface EsqlStarredQueriesParams {
|
||||
client: FavoritesClient<StarredQueryMetadata>;
|
||||
starredQueries: StarredQueryItem[];
|
||||
storage: Storage;
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
interface StarredQueryMetadata {
|
||||
queryString: string;
|
||||
createdAt: string;
|
||||
status: 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
export class EsqlStarredQueriesService {
|
||||
private client: FavoritesClient<StarredQueryMetadata>;
|
||||
private starredQueries: StarredQueryItem[] = [];
|
||||
private queryToEdit: string = '';
|
||||
private storage: Storage;
|
||||
queries$: BehaviorSubject<StarredQueryItem[]>;
|
||||
discardModalVisibility$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||
|
||||
constructor({ client, starredQueries, storage }: EsqlStarredQueriesParams) {
|
||||
this.client = client;
|
||||
this.starredQueries = starredQueries;
|
||||
this.queries$ = new BehaviorSubject(starredQueries);
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
static async initialize(services: EsqlStarredQueriesServices) {
|
||||
const client = new FavoritesClient<StarredQueryMetadata>('esql_editor', 'esql_query', {
|
||||
http: services.http,
|
||||
usageCollection: services.usageCollection,
|
||||
});
|
||||
|
||||
const { favoriteMetadata } = (await client?.getFavorites()) || {};
|
||||
const retrievedQueries: StarredQueryItem[] = [];
|
||||
|
||||
if (!favoriteMetadata) {
|
||||
return new EsqlStarredQueriesService({
|
||||
client,
|
||||
starredQueries: [],
|
||||
storage: services.storage,
|
||||
});
|
||||
}
|
||||
Object.keys(favoriteMetadata).forEach((id) => {
|
||||
const item = favoriteMetadata[id];
|
||||
const { queryString, createdAt, status } = item;
|
||||
retrievedQueries.push({ id, queryString, timeRan: createdAt, status });
|
||||
});
|
||||
|
||||
return new EsqlStarredQueriesService({
|
||||
client,
|
||||
starredQueries: retrievedQueries,
|
||||
storage: services.storage,
|
||||
});
|
||||
}
|
||||
|
||||
private checkIfQueryIsStarred(queryString: string) {
|
||||
return this.starredQueries.some((item) => item.queryString === queryString);
|
||||
}
|
||||
|
||||
private checkIfStarredQueriesLimitReached() {
|
||||
return this.starredQueries.length >= ESQL_STARRED_QUERIES_LIMIT;
|
||||
}
|
||||
|
||||
async addStarredQuery(item: Pick<QueryHistoryItem, 'queryString' | 'status'>) {
|
||||
const favoriteItem: { id: string; metadata: StarredQueryMetadata } = {
|
||||
id: generateId(),
|
||||
metadata: {
|
||||
queryString: getTrimmedQuery(item.queryString),
|
||||
createdAt: new Date().toISOString(),
|
||||
status: item.status ?? 'success',
|
||||
},
|
||||
};
|
||||
|
||||
// do not add the query if it's already starred or has reached the limit
|
||||
if (
|
||||
this.checkIfQueryIsStarred(favoriteItem.metadata.queryString) ||
|
||||
this.checkIfStarredQueriesLimitReached()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const starredQueries = [...this.starredQueries];
|
||||
|
||||
starredQueries.push({
|
||||
queryString: favoriteItem.metadata.queryString,
|
||||
timeRan: favoriteItem.metadata.createdAt,
|
||||
status: favoriteItem.metadata.status,
|
||||
id: favoriteItem.id,
|
||||
});
|
||||
this.queries$.next(starredQueries);
|
||||
this.starredQueries = starredQueries;
|
||||
await this.client.addFavorite(favoriteItem);
|
||||
|
||||
// telemetry, add favorite click event
|
||||
this.client.reportAddFavoriteClick();
|
||||
}
|
||||
|
||||
async removeStarredQuery(queryString: string) {
|
||||
const trimmedQueryString = getTrimmedQuery(queryString);
|
||||
const favoriteItem = this.starredQueries.find(
|
||||
(item) => item.queryString === trimmedQueryString
|
||||
);
|
||||
|
||||
if (!favoriteItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.starredQueries = this.starredQueries.filter(
|
||||
(item) => item.queryString !== trimmedQueryString
|
||||
);
|
||||
this.queries$.next(this.starredQueries);
|
||||
|
||||
await this.client.removeFavorite({ id: favoriteItem.id });
|
||||
|
||||
// telemetry, remove favorite click event
|
||||
this.client.reportRemoveFavoriteClick();
|
||||
}
|
||||
|
||||
async onDiscardModalClose(shouldDismissModal?: boolean, removeQuery?: boolean) {
|
||||
if (shouldDismissModal) {
|
||||
// set the local storage flag to not show the modal again
|
||||
this.storage.set(STARRED_QUERIES_DISCARD_KEY, true);
|
||||
}
|
||||
this.discardModalVisibility$.next(false);
|
||||
|
||||
if (removeQuery) {
|
||||
// remove the query
|
||||
await this.removeStarredQuery(this.queryToEdit);
|
||||
}
|
||||
}
|
||||
|
||||
renderStarredButton(item: QueryHistoryItem) {
|
||||
const trimmedQueryString = getTrimmedQuery(item.queryString);
|
||||
const isStarred = this.checkIfQueryIsStarred(trimmedQueryString);
|
||||
return (
|
||||
<TooltipWrapper
|
||||
tooltipContent={i18n.translate(
|
||||
'esqlEditor.query.querieshistory.starredQueriesReachedLimitTooltip',
|
||||
{
|
||||
defaultMessage:
|
||||
'Limit reached: This list can contain a maximum of {limit} items. Please remove an item before adding a new one.',
|
||||
values: { limit: ESQL_STARRED_QUERIES_LIMIT },
|
||||
}
|
||||
)}
|
||||
condition={!isStarred && this.checkIfStarredQueriesLimitReached()}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
title={
|
||||
isStarred
|
||||
? i18n.translate('esqlEditor.query.querieshistory.removeFavoriteTitle', {
|
||||
defaultMessage: 'Remove ES|QL query from Starred',
|
||||
})
|
||||
: i18n.translate('esqlEditor.query.querieshistory.addFavoriteTitle', {
|
||||
defaultMessage: 'Add ES|QL query to Starred',
|
||||
})
|
||||
}
|
||||
className={!isStarred ? 'cm-favorite-button--empty' : ''}
|
||||
aria-label={
|
||||
isStarred
|
||||
? i18n.translate('esqlEditor.query.querieshistory.removeFavoriteTitle', {
|
||||
defaultMessage: 'Remove ES|QL query from Starred',
|
||||
})
|
||||
: i18n.translate('esqlEditor.query.querieshistory.addFavoriteTitle', {
|
||||
defaultMessage: 'Add ES|QL query to Starred',
|
||||
})
|
||||
}
|
||||
iconType={isStarred ? 'starFilled' : 'starEmpty'}
|
||||
disabled={!isStarred && this.checkIfStarredQueriesLimitReached()}
|
||||
onClick={async () => {
|
||||
this.queryToEdit = trimmedQueryString;
|
||||
if (isStarred) {
|
||||
// show the discard modal only if the user has not dismissed it
|
||||
if (!this.storage.get(STARRED_QUERIES_DISCARD_KEY)) {
|
||||
this.discardModalVisibility$.next(true);
|
||||
} else {
|
||||
await this.removeStarredQuery(item.queryString);
|
||||
}
|
||||
} else {
|
||||
await this.addStarredQuery(item);
|
||||
}
|
||||
}}
|
||||
data-test-subj="ESQLFavoriteButton"
|
||||
/>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -8,8 +8,15 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { QueryHistoryAction, getTableColumns, QueryColumn } from './query_history';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
QueryHistoryAction,
|
||||
getTableColumns,
|
||||
QueryColumn,
|
||||
HistoryAndStarredQueriesTabs,
|
||||
} from './history_starred_queries';
|
||||
|
||||
jest.mock('../history_local_storage', () => {
|
||||
const module = jest.requireActual('../history_local_storage');
|
||||
|
@ -18,7 +25,6 @@ jest.mock('../history_local_storage', () => {
|
|||
getHistoryItems: () => [
|
||||
{
|
||||
queryString: 'from kibana_sample_data_flights | limit 10',
|
||||
timeZone: 'Browser',
|
||||
timeRan: 'Mar. 25, 24 08:45:27',
|
||||
queryRunning: false,
|
||||
status: 'success',
|
||||
|
@ -27,7 +33,7 @@ jest.mock('../history_local_storage', () => {
|
|||
};
|
||||
});
|
||||
|
||||
describe('QueryHistory', () => {
|
||||
describe('Starred and History queries components', () => {
|
||||
describe('QueryHistoryAction', () => {
|
||||
it('should render the history action component as a button if is spaceReduced is undefined', () => {
|
||||
render(<QueryHistoryAction toggleHistory={jest.fn()} isHistoryOpen />);
|
||||
|
@ -47,9 +53,14 @@ describe('QueryHistory', () => {
|
|||
});
|
||||
|
||||
describe('getTableColumns', () => {
|
||||
it('should get the history table columns correctly', async () => {
|
||||
it('should get the table columns correctly', async () => {
|
||||
const columns = getTableColumns(50, false, []);
|
||||
expect(columns).toEqual([
|
||||
{
|
||||
'data-test-subj': 'favoriteBtn',
|
||||
render: expect.anything(),
|
||||
width: '40px',
|
||||
},
|
||||
{
|
||||
css: {
|
||||
height: '100%',
|
||||
|
@ -64,7 +75,7 @@ describe('QueryHistory', () => {
|
|||
{
|
||||
'data-test-subj': 'queryString',
|
||||
field: 'queryString',
|
||||
name: 'Recent queries',
|
||||
name: 'Query',
|
||||
render: expect.anything(),
|
||||
},
|
||||
{
|
||||
|
@ -83,11 +94,58 @@ describe('QueryHistory', () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get the table columns correctly for the starred list', async () => {
|
||||
const columns = getTableColumns(50, false, [], true);
|
||||
expect(columns).toEqual([
|
||||
{
|
||||
'data-test-subj': 'favoriteBtn',
|
||||
render: expect.anything(),
|
||||
width: '40px',
|
||||
},
|
||||
{
|
||||
css: {
|
||||
height: '100%',
|
||||
},
|
||||
'data-test-subj': 'status',
|
||||
field: 'status',
|
||||
name: '',
|
||||
render: expect.anything(),
|
||||
sortable: false,
|
||||
width: '40px',
|
||||
},
|
||||
{
|
||||
'data-test-subj': 'queryString',
|
||||
field: 'queryString',
|
||||
name: 'Query',
|
||||
render: expect.anything(),
|
||||
},
|
||||
{
|
||||
'data-test-subj': 'timeRan',
|
||||
field: 'timeRan',
|
||||
name: 'Date Added',
|
||||
render: expect.anything(),
|
||||
sortable: true,
|
||||
width: '240px',
|
||||
},
|
||||
{
|
||||
actions: [],
|
||||
'data-test-subj': 'actions',
|
||||
name: '',
|
||||
width: '60px',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should get the history table columns correctly for reduced space', async () => {
|
||||
const columns = getTableColumns(50, true, []);
|
||||
expect(columns).toEqual([
|
||||
{
|
||||
'data-test-subj': 'favoriteBtn',
|
||||
render: expect.anything(),
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
css: {
|
||||
height: '100%',
|
||||
|
@ -110,7 +168,7 @@ describe('QueryHistory', () => {
|
|||
{
|
||||
'data-test-subj': 'queryString',
|
||||
field: 'queryString',
|
||||
name: 'Recent queries',
|
||||
name: 'Query',
|
||||
render: expect.anything(),
|
||||
},
|
||||
{
|
||||
|
@ -132,7 +190,7 @@ describe('QueryHistory', () => {
|
|||
/>
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('ESQLEditor-queryHistory-queryString-expanded')
|
||||
screen.queryByTestId('ESQLEditor-queryList-queryString-expanded')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
@ -152,9 +210,66 @@ describe('QueryHistory', () => {
|
|||
isOnReducedSpaceLayout={true}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('ESQLEditor-queryHistory-queryString-expanded')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ESQLEditor-queryList-queryString-expanded')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HistoryAndStarredQueriesTabs', () => {
|
||||
const services = {
|
||||
core: coreMock.createStart(),
|
||||
};
|
||||
it('should render two tabs', () => {
|
||||
render(
|
||||
<KibanaContextProvider services={services}>
|
||||
<HistoryAndStarredQueriesTabs
|
||||
containerCSS={{}}
|
||||
containerWidth={1024}
|
||||
onUpdateAndSubmit={jest.fn()}
|
||||
height={200}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
expect(screen.getByTestId('history-queries-tab')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('history-queries-tab')).toHaveTextContent('Recent');
|
||||
expect(screen.getByTestId('starred-queries-tab')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('starred-queries-tab')).toHaveTextContent('Starred');
|
||||
});
|
||||
|
||||
it('should render the history queries tab by default', () => {
|
||||
render(
|
||||
<KibanaContextProvider services={services}>
|
||||
<HistoryAndStarredQueriesTabs
|
||||
containerCSS={{}}
|
||||
containerWidth={1024}
|
||||
onUpdateAndSubmit={jest.fn()}
|
||||
height={200}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
expect(screen.getByTestId('ESQLEditor-queryHistory')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ESQLEditor-history-starred-queries-helpText')).toHaveTextContent(
|
||||
'Showing last 20 queries'
|
||||
);
|
||||
});
|
||||
|
||||
it('should render the starred queries if the corresponding btn is clicked', () => {
|
||||
render(
|
||||
<KibanaContextProvider services={services}>
|
||||
<HistoryAndStarredQueriesTabs
|
||||
containerCSS={{}}
|
||||
containerWidth={1024}
|
||||
onUpdateAndSubmit={jest.fn()}
|
||||
height={200}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
// click the starred queries tab
|
||||
screen.getByTestId('starred-queries-tab').click();
|
||||
|
||||
expect(screen.getByTestId('ESQLEditor-starredQueries')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('ESQLEditor-history-starred-queries-helpText')).toHaveTextContent(
|
||||
'Showing 0 queries (max 100)'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -6,8 +6,8 @@
|
|||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import moment from 'moment';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
|
@ -22,11 +22,26 @@ import {
|
|||
EuiCopy,
|
||||
EuiToolTip,
|
||||
euiScrollBarStyles,
|
||||
EuiTab,
|
||||
EuiTabs,
|
||||
EuiNotificationBadge,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { cssFavoriteHoverWithinEuiTableRow } from '@kbn/content-management-favorites-public';
|
||||
import { FAVORITES_LIMIT as ESQL_STARRED_QUERIES_LIMIT } from '@kbn/content-management-favorites-common';
|
||||
import { css, Interpolation, Theme } from '@emotion/react';
|
||||
import { useEuiTablePersist } from '@kbn/shared-ux-table-persist';
|
||||
import { type QueryHistoryItem, getHistoryItems } from '../history_local_storage';
|
||||
import { getReducedSpaceStyling, swapArrayElements } from './query_history_helpers';
|
||||
import {
|
||||
type QueryHistoryItem,
|
||||
getHistoryItems,
|
||||
MAX_HISTORY_QUERIES_NUMBER,
|
||||
dateFormat,
|
||||
} from '../history_local_storage';
|
||||
import type { ESQLEditorDeps } from '../types';
|
||||
import { getReducedSpaceStyling, swapArrayElements } from './history_starred_queries_helpers';
|
||||
import { EsqlStarredQueriesService, StarredQueryItem } from './esql_starred_queries_service';
|
||||
import { DiscardStarredQueryModal } from './discard_starred_query';
|
||||
|
||||
export function QueryHistoryAction({
|
||||
toggleHistory,
|
||||
|
@ -99,9 +114,22 @@ export function QueryHistoryAction({
|
|||
export const getTableColumns = (
|
||||
width: number,
|
||||
isOnReducedSpaceLayout: boolean,
|
||||
actions: Array<CustomItemAction<QueryHistoryItem>>
|
||||
actions: Array<CustomItemAction<QueryHistoryItem>>,
|
||||
isStarredTab = false,
|
||||
starredQueriesService?: EsqlStarredQueriesService
|
||||
): Array<EuiBasicTableColumn<QueryHistoryItem>> => {
|
||||
const columnsArray = [
|
||||
{
|
||||
'data-test-subj': 'favoriteBtn',
|
||||
render: (item: QueryHistoryItem) => {
|
||||
const StarredQueryButton = starredQueriesService?.renderStarredButton(item);
|
||||
if (!StarredQueryButton) {
|
||||
return null;
|
||||
}
|
||||
return StarredQueryButton;
|
||||
},
|
||||
width: isOnReducedSpaceLayout ? 'auto' : '40px',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
name: '',
|
||||
|
@ -167,7 +195,7 @@ export const getTableColumns = (
|
|||
field: 'queryString',
|
||||
'data-test-subj': 'queryString',
|
||||
name: i18n.translate('esqlEditor.query.recentQueriesColumnLabel', {
|
||||
defaultMessage: 'Recent queries',
|
||||
defaultMessage: 'Query',
|
||||
}),
|
||||
render: (queryString: QueryHistoryItem['queryString']) => (
|
||||
<QueryColumn
|
||||
|
@ -180,11 +208,15 @@ export const getTableColumns = (
|
|||
{
|
||||
field: 'timeRan',
|
||||
'data-test-subj': 'timeRan',
|
||||
name: i18n.translate('esqlEditor.query.timeRanColumnLabel', {
|
||||
defaultMessage: 'Time ran',
|
||||
}),
|
||||
name: isStarredTab
|
||||
? i18n.translate('esqlEditor.query.dateAddedColumnLabel', {
|
||||
defaultMessage: 'Date Added',
|
||||
})
|
||||
: i18n.translate('esqlEditor.query.timeRanColumnLabel', {
|
||||
defaultMessage: 'Time ran',
|
||||
}),
|
||||
sortable: true,
|
||||
render: (timeRan: QueryHistoryItem['timeRan']) => timeRan,
|
||||
render: (timeRan: QueryHistoryItem['timeRan']) => moment(timeRan).format(dateFormat),
|
||||
width: isOnReducedSpaceLayout ? 'auto' : '240px',
|
||||
},
|
||||
{
|
||||
|
@ -196,22 +228,33 @@ export const getTableColumns = (
|
|||
];
|
||||
|
||||
// I need to swap the elements here to get the desired design
|
||||
return isOnReducedSpaceLayout ? swapArrayElements(columnsArray, 1, 2) : columnsArray;
|
||||
return isOnReducedSpaceLayout ? swapArrayElements(columnsArray, 2, 3) : columnsArray;
|
||||
};
|
||||
|
||||
export function QueryHistory({
|
||||
export function QueryList({
|
||||
containerCSS,
|
||||
containerWidth,
|
||||
onUpdateAndSubmit,
|
||||
height,
|
||||
listItems,
|
||||
starredQueriesService,
|
||||
tableCaption,
|
||||
dataTestSubj,
|
||||
isStarredTab = false,
|
||||
}: {
|
||||
listItems: QueryHistoryItem[];
|
||||
containerCSS: Interpolation<Theme>;
|
||||
containerWidth: number;
|
||||
onUpdateAndSubmit: (qs: string) => void;
|
||||
height: number;
|
||||
starredQueriesService?: EsqlStarredQueriesService;
|
||||
tableCaption?: string;
|
||||
dataTestSubj?: string;
|
||||
isStarredTab?: boolean;
|
||||
}) {
|
||||
const theme = useEuiTheme();
|
||||
const scrollBarStyles = euiScrollBarStyles(theme);
|
||||
const [isDiscardQueryModalVisible, setIsDiscardQueryModalVisible] = useState(false);
|
||||
|
||||
const { sorting, onTableChange } = useEuiTablePersist<QueryHistoryItem>({
|
||||
tableId: 'esqlQueryHistory',
|
||||
|
@ -221,8 +264,6 @@ export function QueryHistory({
|
|||
},
|
||||
});
|
||||
|
||||
const historyItems: QueryHistoryItem[] = getHistoryItems(sorting.sort.direction);
|
||||
|
||||
const actions: Array<CustomItemAction<QueryHistoryItem>> = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
|
@ -232,16 +273,16 @@ export function QueryHistory({
|
|||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={i18n.translate('esqlEditor.query.querieshistoryRun', {
|
||||
content={i18n.translate('esqlEditor.query.esqlQueriesListRun', {
|
||||
defaultMessage: 'Run query',
|
||||
})}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
iconType="play"
|
||||
aria-label={i18n.translate('esqlEditor.query.querieshistoryRun', {
|
||||
aria-label={i18n.translate('esqlEditor.query.esqlQueriesListRun', {
|
||||
defaultMessage: 'Run query',
|
||||
})}
|
||||
data-test-subj="ESQLEditor-queryHistory-runQuery-button"
|
||||
data-test-subj="ESQLEditor-history-starred-queries-run-button"
|
||||
role="button"
|
||||
iconSize="m"
|
||||
onClick={() => onUpdateAndSubmit(item.queryString)}
|
||||
|
@ -254,7 +295,7 @@ export function QueryHistory({
|
|||
<EuiFlexItem grow={false}>
|
||||
<EuiCopy
|
||||
textToCopy={item.queryString}
|
||||
content={i18n.translate('esqlEditor.query.querieshistoryCopy', {
|
||||
content={i18n.translate('esqlEditor.query.esqlQueriesCopy', {
|
||||
defaultMessage: 'Copy query to clipboard',
|
||||
})}
|
||||
>
|
||||
|
@ -266,7 +307,7 @@ export function QueryHistory({
|
|||
css={css`
|
||||
cursor: pointer;
|
||||
`}
|
||||
aria-label={i18n.translate('esqlEditor.query.querieshistoryCopy', {
|
||||
aria-label={i18n.translate('esqlEditor.query.esqlQueriesCopy', {
|
||||
defaultMessage: 'Copy query to clipboard',
|
||||
})}
|
||||
/>
|
||||
|
@ -279,14 +320,23 @@ export function QueryHistory({
|
|||
},
|
||||
];
|
||||
}, [onUpdateAndSubmit]);
|
||||
|
||||
const isOnReducedSpaceLayout = containerWidth < 560;
|
||||
const columns = useMemo(() => {
|
||||
return getTableColumns(containerWidth, isOnReducedSpaceLayout, actions);
|
||||
}, [actions, containerWidth, isOnReducedSpaceLayout]);
|
||||
return getTableColumns(
|
||||
containerWidth,
|
||||
isOnReducedSpaceLayout,
|
||||
actions,
|
||||
isStarredTab,
|
||||
starredQueriesService
|
||||
);
|
||||
}, [containerWidth, isOnReducedSpaceLayout, actions, isStarredTab, starredQueriesService]);
|
||||
|
||||
const { euiTheme } = theme;
|
||||
const extraStyling = isOnReducedSpaceLayout ? getReducedSpaceStyling() : '';
|
||||
|
||||
const starredQueriesCellStyling = cssFavoriteHoverWithinEuiTableRow(theme.euiTheme);
|
||||
|
||||
const tableStyling = css`
|
||||
.euiTable {
|
||||
background-color: ${euiTheme.colors.lightestShade};
|
||||
|
@ -305,22 +355,40 @@ export function QueryHistory({
|
|||
overflow-y: auto;
|
||||
${scrollBarStyles}
|
||||
${extraStyling}
|
||||
${starredQueriesCellStyling}
|
||||
`;
|
||||
|
||||
starredQueriesService?.discardModalVisibility$.subscribe((nextVisibility) => {
|
||||
if (isDiscardQueryModalVisible !== nextVisibility) {
|
||||
setIsDiscardQueryModalVisible(nextVisibility);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-test-subj="ESQLEditor-queryHistory" css={containerCSS}>
|
||||
<div data-test-subj={dataTestSubj ?? 'ESQLEditor-queryList'} css={containerCSS}>
|
||||
<EuiInMemoryTable
|
||||
tableCaption={i18n.translate('esqlEditor.query.querieshistoryTable', {
|
||||
defaultMessage: 'Queries history table',
|
||||
})}
|
||||
tableCaption={
|
||||
tableCaption ||
|
||||
i18n.translate('esqlEditor.query.queriesListTable', {
|
||||
defaultMessage: 'ES|QL Queries table',
|
||||
})
|
||||
}
|
||||
responsiveBreakpoint={false}
|
||||
items={historyItems}
|
||||
items={listItems}
|
||||
columns={columns}
|
||||
sorting={sorting}
|
||||
onChange={onTableChange}
|
||||
css={tableStyling}
|
||||
tableLayout={containerWidth < 560 ? 'auto' : 'fixed'}
|
||||
/>
|
||||
{isDiscardQueryModalVisible && (
|
||||
<DiscardStarredQueryModal
|
||||
onClose={async (dismissFlag, removeQuery) =>
|
||||
(await starredQueriesService?.onDiscardModalClose(dismissFlag, removeQuery)) ??
|
||||
Promise.resolve()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -354,7 +422,7 @@ export function QueryColumn({
|
|||
onClick={() => {
|
||||
setIsRowExpanded(!isRowExpanded);
|
||||
}}
|
||||
data-test-subj="ESQLEditor-queryHistory-queryString-expanded"
|
||||
data-test-subj="ESQLEditor-queryList-queryString-expanded"
|
||||
aria-label={
|
||||
isRowExpanded
|
||||
? i18n.translate('esqlEditor.query.collapseLabel', {
|
||||
|
@ -387,3 +455,171 @@ export function QueryColumn({
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryAndStarredQueriesTabs({
|
||||
containerCSS,
|
||||
containerWidth,
|
||||
onUpdateAndSubmit,
|
||||
height,
|
||||
}: {
|
||||
containerCSS: Interpolation<Theme>;
|
||||
containerWidth: number;
|
||||
onUpdateAndSubmit: (qs: string) => void;
|
||||
height: number;
|
||||
}) {
|
||||
const kibana = useKibana<ESQLEditorDeps>();
|
||||
const { core, usageCollection, storage } = kibana.services;
|
||||
|
||||
const [starredQueriesService, setStarredQueriesService] = useState<EsqlStarredQueriesService>();
|
||||
const [starredQueries, setStarredQueries] = useState<StarredQueryItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeService = async () => {
|
||||
const starredService = await EsqlStarredQueriesService.initialize({
|
||||
http: core.http,
|
||||
usageCollection,
|
||||
storage,
|
||||
});
|
||||
setStarredQueriesService(starredService);
|
||||
};
|
||||
if (!starredQueriesService) {
|
||||
initializeService();
|
||||
}
|
||||
}, [core.http, starredQueriesService, storage, usageCollection]);
|
||||
|
||||
starredQueriesService?.queries$.subscribe((nextQueries) => {
|
||||
if (nextQueries.length !== starredQueries.length) {
|
||||
setStarredQueries(nextQueries);
|
||||
}
|
||||
});
|
||||
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const tabs = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'history-queries-tab',
|
||||
name: i18n.translate('esqlEditor.query.historyQueriesTabLabel', {
|
||||
defaultMessage: 'Recent',
|
||||
}),
|
||||
dataTestSubj: 'history-queries-tab',
|
||||
content: (
|
||||
<QueryList
|
||||
containerCSS={containerCSS}
|
||||
onUpdateAndSubmit={onUpdateAndSubmit}
|
||||
containerWidth={containerWidth}
|
||||
height={height}
|
||||
listItems={getHistoryItems('desc')}
|
||||
dataTestSubj="ESQLEditor-queryHistory"
|
||||
tableCaption={i18n.translate('esqlEditor.query.querieshistoryTable', {
|
||||
defaultMessage: 'Queries history table',
|
||||
})}
|
||||
starredQueriesService={starredQueriesService}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'starred-queries-tab',
|
||||
dataTestSubj: 'starred-queries-tab',
|
||||
name: i18n.translate('esqlEditor.query.starredQueriesTabLabel', {
|
||||
defaultMessage: 'Starred',
|
||||
}),
|
||||
append: (
|
||||
<EuiNotificationBadge className="eui-alignCenter" size="m" color="subdued">
|
||||
{starredQueries?.length}
|
||||
</EuiNotificationBadge>
|
||||
),
|
||||
content: (
|
||||
<QueryList
|
||||
containerCSS={containerCSS}
|
||||
onUpdateAndSubmit={onUpdateAndSubmit}
|
||||
containerWidth={containerWidth}
|
||||
height={height}
|
||||
listItems={starredQueries}
|
||||
dataTestSubj="ESQLEditor-starredQueries"
|
||||
tableCaption={i18n.translate('esqlEditor.query.starredQueriesTable', {
|
||||
defaultMessage: 'Starred queries table',
|
||||
})}
|
||||
starredQueriesService={starredQueriesService}
|
||||
isStarredTab={true}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [
|
||||
containerCSS,
|
||||
containerWidth,
|
||||
height,
|
||||
onUpdateAndSubmit,
|
||||
starredQueries,
|
||||
starredQueriesService,
|
||||
]);
|
||||
|
||||
const [selectedTabId, setSelectedTabId] = useState('history-queries-tab');
|
||||
const selectedTabContent = useMemo(() => {
|
||||
return tabs.find((obj) => obj.id === selectedTabId)?.content;
|
||||
}, [selectedTabId, tabs]);
|
||||
|
||||
const onSelectedTabChanged = (id: string) => {
|
||||
setSelectedTabId(id);
|
||||
};
|
||||
|
||||
const renderTabs = useCallback(() => {
|
||||
return tabs.map((tab, index) => (
|
||||
<EuiTab
|
||||
key={index}
|
||||
onClick={() => onSelectedTabChanged(tab.id)}
|
||||
isSelected={tab.id === selectedTabId}
|
||||
append={tab.append}
|
||||
data-test-subj={tab.dataTestSubj}
|
||||
>
|
||||
{tab.name}
|
||||
</EuiTab>
|
||||
));
|
||||
}, [selectedTabId, tabs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup
|
||||
responsive={false}
|
||||
alignItems="center"
|
||||
justifyContent="spaceBetween"
|
||||
css={css`
|
||||
background-color: ${euiTheme.colors.lightestShade};
|
||||
padding-left: ${euiTheme.size.s};
|
||||
padding-right: ${euiTheme.size.s};
|
||||
border-block-end: ${euiTheme.border.thin};
|
||||
`}
|
||||
>
|
||||
<EuiTabs bottomBorder={false} size="s">
|
||||
{renderTabs()}
|
||||
</EuiTabs>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
|
||||
<EuiText
|
||||
size="xs"
|
||||
color="subdued"
|
||||
data-test-subj="ESQLEditor-history-starred-queries-helpText"
|
||||
>
|
||||
<p>
|
||||
{selectedTabId === 'history-queries-tab'
|
||||
? i18n.translate('esqlEditor.history.historyItemslimit', {
|
||||
defaultMessage: 'Showing last {historyItemsLimit} queries',
|
||||
values: { historyItemsLimit: MAX_HISTORY_QUERIES_NUMBER },
|
||||
})
|
||||
: i18n.translate('esqlEditor.history.starredItemslimit', {
|
||||
defaultMessage:
|
||||
'Showing {starredItemsCount} queries (max {starredItemsLimit})',
|
||||
values: {
|
||||
starredItemsLimit: ESQL_STARRED_QUERIES_LIMIT,
|
||||
starredItemsCount: starredQueries.length ?? 0,
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
{selectedTabContent}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { swapArrayElements } from './query_history_helpers';
|
||||
import { swapArrayElements } from './history_starred_queries_helpers';
|
||||
|
||||
describe('query history helpers', function () {
|
||||
it('should swap 2 elements in an array', function () {
|
|
@ -19,16 +19,19 @@ export const getReducedSpaceStyling = () => {
|
|||
}
|
||||
.euiTable thead tr {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 0 auto;
|
||||
grid-template-columns: 40px 40px 1fr 0 auto;
|
||||
}
|
||||
.euiTable tbody tr {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
grid-template-columns: 40px 40px 1fr auto;
|
||||
grid-template-areas:
|
||||
'status timeRan lastDuration actions'
|
||||
'. queryString queryString queryString';
|
||||
'favoriteBtn status timeRan lastDuration actions'
|
||||
'. . queryString queryString queryString';
|
||||
}
|
||||
/* Set grid template areas */
|
||||
.euiTable td[data-test-subj='favoriteBtn'] {
|
||||
grid-area: favoriteBtn;
|
||||
}
|
||||
.euiTable td[data-test-subj='status'] {
|
||||
grid-area: status;
|
||||
}
|
|
@ -8,7 +8,6 @@
|
|||
*/
|
||||
|
||||
import React, { memo, useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiText,
|
||||
|
@ -27,7 +26,7 @@ import {
|
|||
import { getLimitFromESQLQuery } from '@kbn/esql-utils';
|
||||
import { type MonacoMessage } from '../helpers';
|
||||
import { ErrorsWarningsFooterPopover } from './errors_warnings_popover';
|
||||
import { QueryHistoryAction, QueryHistory } from './query_history';
|
||||
import { QueryHistoryAction, HistoryAndStarredQueriesTabs } from './history_starred_queries';
|
||||
import { SubmitFeedbackComponent } from './feedback_component';
|
||||
import { QueryWrapComponent } from './query_wrap_component';
|
||||
import type { ESQLEditorDeps } from '../types';
|
||||
|
@ -60,7 +59,6 @@ interface EditorFooterProps {
|
|||
isSpaceReduced?: boolean;
|
||||
hideTimeFilterInfo?: boolean;
|
||||
hideQueryHistory?: boolean;
|
||||
isInCompactMode?: boolean;
|
||||
displayDocumentationAsFlyout?: boolean;
|
||||
}
|
||||
|
||||
|
@ -84,7 +82,6 @@ export const EditorFooter = memo(function EditorFooter({
|
|||
isLanguageComponentOpen,
|
||||
setIsLanguageComponentOpen,
|
||||
hideQueryHistory,
|
||||
isInCompactMode,
|
||||
displayDocumentationAsFlyout,
|
||||
measuredContainerWidth,
|
||||
code,
|
||||
|
@ -310,7 +307,7 @@ export const EditorFooter = memo(function EditorFooter({
|
|||
</EuiFlexItem>
|
||||
{isHistoryOpen && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<QueryHistory
|
||||
<HistoryAndStarredQueriesTabs
|
||||
containerCSS={styles.historyContainer}
|
||||
onUpdateAndSubmit={onUpdateAndSubmit}
|
||||
containerWidth={measuredContainerWidth}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiToolTip, EuiToolTipProps } from '@elastic/eui';
|
||||
|
||||
export type TooltipWrapperProps = Partial<Omit<EuiToolTipProps, 'content'>> & {
|
||||
tooltipContent: string;
|
||||
/** When the condition is truthy, the tooltip will be shown */
|
||||
condition: boolean;
|
||||
};
|
||||
|
||||
export const TooltipWrapper: React.FunctionComponent<TooltipWrapperProps> = ({
|
||||
children,
|
||||
condition,
|
||||
tooltipContent,
|
||||
...tooltipProps
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{condition ? (
|
||||
<EuiToolTip content={tooltipContent} delay="regular" {...tooltipProps}>
|
||||
<>{children}</>
|
||||
</EuiToolTip>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -99,7 +99,7 @@ export const ESQLEditor = memo(function ESQLEditor({
|
|||
uiSettings,
|
||||
} = kibana.services;
|
||||
const darkMode = core.theme?.getTheme().darkMode;
|
||||
const timeZone = uiSettings?.get('dateFormat:tz');
|
||||
|
||||
const histogramBarTarget = uiSettings?.get('histogram:barTarget') ?? 50;
|
||||
const [code, setCode] = useState<string>(query.esql ?? '');
|
||||
// To make server side errors less "sticky", register the state of the code when submitting
|
||||
|
@ -464,11 +464,10 @@ export const ESQLEditor = memo(function ESQLEditor({
|
|||
validateQuery();
|
||||
addQueriesToCache({
|
||||
queryString: code,
|
||||
timeZone,
|
||||
status: clientParserStatus,
|
||||
});
|
||||
}
|
||||
}, [clientParserStatus, isLoading, isQueryLoading, parseMessages, code, timeZone]);
|
||||
}, [clientParserStatus, isLoading, isQueryLoading, parseMessages, code]);
|
||||
|
||||
const queryValidation = useCallback(
|
||||
async ({ active }: { active: boolean }) => {
|
||||
|
|
|
@ -20,7 +20,6 @@ describe('history local storage', function () {
|
|||
it('should add queries to cache correctly ', function () {
|
||||
addQueriesToCache({
|
||||
queryString: 'from kibana_sample_data_flights | limit 10',
|
||||
timeZone: 'Browser',
|
||||
});
|
||||
const historyItems = getCachedQueries();
|
||||
expect(historyItems.length).toBe(1);
|
||||
|
@ -31,7 +30,6 @@ describe('history local storage', function () {
|
|||
it('should update queries to cache correctly ', function () {
|
||||
addQueriesToCache({
|
||||
queryString: 'from kibana_sample_data_flights \n | limit 10 \n | stats meow = avg(woof)',
|
||||
timeZone: 'Browser',
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
|
@ -49,7 +47,6 @@ describe('history local storage', function () {
|
|||
it('should update queries to cache correctly if they are the same with different format', function () {
|
||||
addQueriesToCache({
|
||||
queryString: 'from kibana_sample_data_flights | limit 10 | stats meow = avg(woof) ',
|
||||
timeZone: 'Browser',
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
|
@ -68,7 +65,6 @@ describe('history local storage', function () {
|
|||
addQueriesToCache(
|
||||
{
|
||||
queryString: 'row 1',
|
||||
timeZone: 'Browser',
|
||||
status: 'success',
|
||||
},
|
||||
2
|
||||
|
|
|
@ -7,10 +7,9 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
import 'moment-timezone';
|
||||
const QUERY_HISTORY_ITEM_KEY = 'QUERY_HISTORY_ITEM_KEY';
|
||||
const dateFormat = 'MMM. D, YY HH:mm:ss.SSS';
|
||||
export const dateFormat = 'MMM. D, YY HH:mm:ss';
|
||||
|
||||
/**
|
||||
* We show maximum 20 ES|QL queries in the Query history component
|
||||
|
@ -19,32 +18,35 @@ const dateFormat = 'MMM. D, YY HH:mm:ss.SSS';
|
|||
export interface QueryHistoryItem {
|
||||
status?: 'success' | 'error' | 'warning';
|
||||
queryString: string;
|
||||
startDateMilliseconds?: number;
|
||||
timeRan?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
const MAX_QUERIES_NUMBER = 20;
|
||||
export const MAX_HISTORY_QUERIES_NUMBER = 20;
|
||||
|
||||
const getKey = (queryString: string) => {
|
||||
export const getTrimmedQuery = (queryString: string) => {
|
||||
return queryString.replaceAll('\n', '').trim().replace(/\s\s+/g, ' ');
|
||||
};
|
||||
|
||||
const getMomentTimeZone = (timeZone?: string) => {
|
||||
return !timeZone || timeZone === 'Browser' ? moment.tz.guess() : timeZone;
|
||||
};
|
||||
|
||||
const sortDates = (date1?: number, date2?: number) => {
|
||||
return moment(date1)?.valueOf() - moment(date2)?.valueOf();
|
||||
const sortDates = (date1?: string, date2?: string) => {
|
||||
if (!date1 || !date2) return 0;
|
||||
return date1 < date2 ? 1 : date1 > date2 ? -1 : 0;
|
||||
};
|
||||
|
||||
export const getHistoryItems = (sortDirection: 'desc' | 'asc'): QueryHistoryItem[] => {
|
||||
const localStorageString = localStorage.getItem(QUERY_HISTORY_ITEM_KEY) ?? '[]';
|
||||
const historyItems: QueryHistoryItem[] = JSON.parse(localStorageString);
|
||||
const localStorageItems: QueryHistoryItem[] = JSON.parse(localStorageString);
|
||||
const historyItems: QueryHistoryItem[] = localStorageItems.map((item) => {
|
||||
return {
|
||||
status: item.status,
|
||||
queryString: item.queryString,
|
||||
timeRan: item.timeRan ? new Date(item.timeRan).toISOString() : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const sortedByDate = historyItems.sort((a, b) => {
|
||||
return sortDirection === 'desc'
|
||||
? sortDates(b.startDateMilliseconds, a.startDateMilliseconds)
|
||||
: sortDates(a.startDateMilliseconds, b.startDateMilliseconds);
|
||||
? sortDates(b.timeRan, a.timeRan)
|
||||
: sortDates(a.timeRan, b.timeRan);
|
||||
});
|
||||
return sortedByDate;
|
||||
};
|
||||
|
@ -58,24 +60,22 @@ export const getCachedQueries = (): QueryHistoryItem[] => {
|
|||
// Adding the maxQueriesAllowed here for testing purposes
|
||||
export const addQueriesToCache = (
|
||||
item: QueryHistoryItem,
|
||||
maxQueriesAllowed = MAX_QUERIES_NUMBER
|
||||
maxQueriesAllowed = MAX_HISTORY_QUERIES_NUMBER
|
||||
) => {
|
||||
// if the user is working on multiple tabs
|
||||
// the cachedQueries Map might not contain all
|
||||
// the localStorage queries
|
||||
const queries = getHistoryItems('desc');
|
||||
queries.forEach((queryItem) => {
|
||||
const trimmedQueryString = getKey(queryItem.queryString);
|
||||
const trimmedQueryString = getTrimmedQuery(queryItem.queryString);
|
||||
cachedQueries.set(trimmedQueryString, queryItem);
|
||||
});
|
||||
const trimmedQueryString = getKey(item.queryString);
|
||||
const trimmedQueryString = getTrimmedQuery(item.queryString);
|
||||
|
||||
if (item.queryString) {
|
||||
const tz = getMomentTimeZone(item.timeZone);
|
||||
cachedQueries.set(trimmedQueryString, {
|
||||
...item,
|
||||
timeRan: moment().tz(tz).format(dateFormat),
|
||||
startDateMilliseconds: moment().valueOf(),
|
||||
timeRan: new Date().toISOString(),
|
||||
status: item.status,
|
||||
});
|
||||
}
|
||||
|
@ -83,9 +83,7 @@ export const addQueriesToCache = (
|
|||
let allQueries = [...getCachedQueries()];
|
||||
|
||||
if (allQueries.length >= maxQueriesAllowed + 1) {
|
||||
const sortedByDate = allQueries.sort((a, b) =>
|
||||
sortDates(b?.startDateMilliseconds, a?.startDateMilliseconds)
|
||||
);
|
||||
const sortedByDate = allQueries.sort((a, b) => sortDates(b.timeRan, a.timeRan));
|
||||
|
||||
// queries to store in the localstorage
|
||||
allQueries = sortedByDate.slice(0, maxQueriesAllowed);
|
||||
|
|
|
@ -13,6 +13,8 @@ import type { AggregateQuery } from '@kbn/es-query';
|
|||
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
|
||||
import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types';
|
||||
import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public';
|
||||
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
|
||||
export interface ESQLEditorProps {
|
||||
/** The aggregate type query */
|
||||
|
@ -70,6 +72,8 @@ export interface ESQLEditorDeps {
|
|||
core: CoreStart;
|
||||
dataViews: DataViewsPublicPluginStart;
|
||||
expressions: ExpressionsStart;
|
||||
storage: Storage;
|
||||
indexManagementApiService?: IndexManagementPluginSetup['apiService'];
|
||||
fieldsMetadata?: FieldsMetadataPublicStart;
|
||||
usageCollection?: UsageCollectionStart;
|
||||
}
|
||||
|
|
|
@ -28,6 +28,10 @@
|
|||
"@kbn/fields-metadata-plugin",
|
||||
"@kbn/esql-validation-autocomplete",
|
||||
"@kbn/esql-utils",
|
||||
"@kbn/content-management-favorites-public",
|
||||
"@kbn/usage-collection-plugin",
|
||||
"@kbn/content-management-favorites-common",
|
||||
"@kbn/kibana-utils-plugin",
|
||||
"@kbn/shared-ux-table-persist",
|
||||
],
|
||||
"exclude": [
|
||||
|
|
|
@ -100,7 +100,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
|
|||
"event_loop_delays_daily": "01b967e8e043801357503de09199dfa3853bab88",
|
||||
"exception-list": "4aebc4e61fb5d608cae48eaeb0977e8db21c61a4",
|
||||
"exception-list-agnostic": "6d3262d58eee28ac381ec9654f93126a58be6f5d",
|
||||
"favorites": "a68c7c8ae22eaddcca324d8b3bfc80a94e3eec3a",
|
||||
"favorites": "e9773d802932ea85547b120e0efdd9a4f11ff4c6",
|
||||
"file": "6b65ae5899b60ebe08656fd163ea532e557d3c98",
|
||||
"file-upload-usage-collection-telemetry": "06e0a8c04f991e744e09d03ab2bd7f86b2088200",
|
||||
"fileShare": "5be52de1747d249a221b5241af2838264e19aaa1",
|
||||
|
|
|
@ -91,7 +91,7 @@ describe('ContentManagementPlugin', () => {
|
|||
const { plugin, coreSetup, pluginsSetup } = setup();
|
||||
const api = plugin.setup(coreSetup, pluginsSetup);
|
||||
|
||||
expect(Object.keys(api).sort()).toEqual(['crud', 'eventBus', 'register']);
|
||||
expect(Object.keys(api).sort()).toEqual(['crud', 'eventBus', 'favorites', 'register']);
|
||||
expect(api.crud('')).toBe('mockedCrud');
|
||||
expect(api.register({} as any)).toBe('mockedRegister');
|
||||
expect(api.eventBus.emit({} as any)).toBe('mockedEventBusEmit');
|
||||
|
|
|
@ -76,10 +76,15 @@ export class ContentManagementPlugin
|
|||
contentRegistry,
|
||||
});
|
||||
|
||||
registerFavorites({ core, logger: this.logger, usageCollection: plugins.usageCollection });
|
||||
const favoritesSetup = registerFavorites({
|
||||
core,
|
||||
logger: this.logger,
|
||||
usageCollection: plugins.usageCollection,
|
||||
});
|
||||
|
||||
return {
|
||||
...coreApi,
|
||||
favorites: favoritesSetup,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import type { Version } from '@kbn/object-versioning';
|
||||
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
|
||||
import type { FavoritesSetup } from '@kbn/content-management-favorites-server';
|
||||
import type { CoreApi, StorageContextGetTransformFn } from './core';
|
||||
|
||||
export interface ContentManagementServerSetupDependencies {
|
||||
|
@ -18,8 +19,9 @@ export interface ContentManagementServerSetupDependencies {
|
|||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface ContentManagementServerStartDependencies {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface ContentManagementServerSetup extends CoreApi {}
|
||||
export interface ContentManagementServerSetup extends CoreApi {
|
||||
favorites: FavoritesSetup;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface ContentManagementServerStart {}
|
||||
|
|
|
@ -75,6 +75,8 @@ export class DashboardPlugin
|
|||
},
|
||||
});
|
||||
|
||||
plugins.contentManagement.favorites.registerFavoriteType('dashboard');
|
||||
|
||||
if (plugins.taskManager) {
|
||||
initializeDashboardTelemetryTask(this.logger, core, plugins.taskManager, plugins.embeddable);
|
||||
}
|
||||
|
|
|
@ -10,16 +10,19 @@
|
|||
"browser": true,
|
||||
"optionalPlugins": [
|
||||
"indexManagement",
|
||||
"fieldsMetadata"
|
||||
"fieldsMetadata",
|
||||
"usageCollection"
|
||||
],
|
||||
"requiredPlugins": [
|
||||
"data",
|
||||
"expressions",
|
||||
"dataViews",
|
||||
"uiActions",
|
||||
"contentManagement"
|
||||
],
|
||||
"requiredBundles": [
|
||||
"kibanaReact",
|
||||
"kibanaUtils",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,8 +11,10 @@ import { BehaviorSubject } from 'rxjs';
|
|||
import type { CoreStart } from '@kbn/core/public';
|
||||
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
|
||||
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types';
|
||||
import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public';
|
||||
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
|
||||
|
||||
export let core: CoreStart;
|
||||
|
||||
|
@ -20,8 +22,10 @@ interface ServiceDeps {
|
|||
core: CoreStart;
|
||||
dataViews: DataViewsPublicPluginStart;
|
||||
expressions: ExpressionsStart;
|
||||
storage: Storage;
|
||||
indexManagementApiService?: IndexManagementPluginSetup['apiService'];
|
||||
fieldsMetadata?: FieldsMetadataPublicStart;
|
||||
usageCollection?: UsageCollectionStart;
|
||||
}
|
||||
|
||||
const servicesReady$ = new BehaviorSubject<ServiceDeps | undefined>(undefined);
|
||||
|
@ -41,15 +45,19 @@ export const setKibanaServices = (
|
|||
kibanaCore: CoreStart,
|
||||
dataViews: DataViewsPublicPluginStart,
|
||||
expressions: ExpressionsStart,
|
||||
storage: Storage,
|
||||
indexManagement?: IndexManagementPluginSetup,
|
||||
fieldsMetadata?: FieldsMetadataPublicStart
|
||||
fieldsMetadata?: FieldsMetadataPublicStart,
|
||||
usageCollection?: UsageCollectionStart
|
||||
) => {
|
||||
core = kibanaCore;
|
||||
servicesReady$.next({
|
||||
core,
|
||||
dataViews,
|
||||
expressions,
|
||||
storage,
|
||||
indexManagementApiService: indexManagement?.apiService,
|
||||
fieldsMetadata,
|
||||
usageCollection,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -14,6 +14,8 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
|||
import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types';
|
||||
import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public';
|
||||
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
|
||||
import { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import {
|
||||
updateESQLQueryTrigger,
|
||||
UpdateESQLQueryAction,
|
||||
|
@ -27,6 +29,7 @@ interface EsqlPluginStart {
|
|||
uiActions: UiActionsStart;
|
||||
data: DataPublicPluginStart;
|
||||
fieldsMetadata: FieldsMetadataPublicStart;
|
||||
usageCollection?: UsageCollectionStart;
|
||||
}
|
||||
|
||||
interface EsqlPluginSetup {
|
||||
|
@ -47,11 +50,20 @@ export class EsqlPlugin implements Plugin<{}, void> {
|
|||
|
||||
public start(
|
||||
core: CoreStart,
|
||||
{ dataViews, expressions, data, uiActions, fieldsMetadata }: EsqlPluginStart
|
||||
{ dataViews, expressions, data, uiActions, fieldsMetadata, usageCollection }: EsqlPluginStart
|
||||
): void {
|
||||
const storage = new Storage(localStorage);
|
||||
const appendESQLAction = new UpdateESQLQueryAction(data);
|
||||
uiActions.addTriggerAction(UPDATE_ESQL_QUERY_TRIGGER, appendESQLAction);
|
||||
setKibanaServices(core, dataViews, expressions, this.indexManagement, fieldsMetadata);
|
||||
setKibanaServices(
|
||||
core,
|
||||
dataViews,
|
||||
expressions,
|
||||
storage,
|
||||
this.indexManagement,
|
||||
fieldsMetadata,
|
||||
usageCollection
|
||||
);
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
|
|
|
@ -8,11 +8,21 @@
|
|||
*/
|
||||
|
||||
import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/server';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { ContentManagementServerSetup } from '@kbn/content-management-plugin/server';
|
||||
import { getUiSettings } from './ui_settings';
|
||||
|
||||
export class EsqlServerPlugin implements Plugin {
|
||||
public setup(core: CoreSetup) {
|
||||
public setup(core: CoreSetup, plugins: { contentManagement: ContentManagementServerSetup }) {
|
||||
core.uiSettings.register(getUiSettings());
|
||||
|
||||
plugins.contentManagement.favorites.registerFavoriteType('esql_query', {
|
||||
typeMetadataSchema: schema.object({
|
||||
queryString: schema.string(),
|
||||
createdAt: schema.string(),
|
||||
status: schema.string(),
|
||||
}),
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
|
@ -22,7 +22,10 @@
|
|||
"@kbn/ui-actions-plugin",
|
||||
"@kbn/data-plugin",
|
||||
"@kbn/es-query",
|
||||
"@kbn/fields-metadata-plugin"
|
||||
"@kbn/fields-metadata-plugin",
|
||||
"@kbn/usage-collection-plugin",
|
||||
"@kbn/content-management-plugin",
|
||||
"@kbn/kibana-utils-plugin",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -401,12 +401,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
|
||||
await testSubjects.click('ESQLEditor-toggle-query-history-button');
|
||||
const historyItems = await esql.getHistoryItems();
|
||||
log.debug(historyItems);
|
||||
const queryAdded = historyItems.some((item) => {
|
||||
return item[1] === 'FROM logstash-* | LIMIT 10';
|
||||
});
|
||||
|
||||
expect(queryAdded).to.be(true);
|
||||
await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', historyItems);
|
||||
});
|
||||
|
||||
it('updating the query should add this to the history', async () => {
|
||||
|
@ -423,12 +418,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
|
||||
await testSubjects.click('ESQLEditor-toggle-query-history-button');
|
||||
const historyItems = await esql.getHistoryItems();
|
||||
log.debug(historyItems);
|
||||
const queryAdded = historyItems.some((item) => {
|
||||
return item[1] === 'from logstash-* | limit 100 | drop @timestamp';
|
||||
});
|
||||
|
||||
expect(queryAdded).to.be(true);
|
||||
await esql.isQueryPresentInTable(
|
||||
'from logstash-* | limit 100 | drop @timestamp',
|
||||
historyItems
|
||||
);
|
||||
});
|
||||
|
||||
it('should select a query from the history and submit it', async () => {
|
||||
|
@ -442,12 +435,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await esql.clickHistoryItem(1);
|
||||
|
||||
const historyItems = await esql.getHistoryItems();
|
||||
log.debug(historyItems);
|
||||
const queryAdded = historyItems.some((item) => {
|
||||
return item[1] === 'from logstash-* | limit 100 | drop @timestamp';
|
||||
});
|
||||
|
||||
expect(queryAdded).to.be(true);
|
||||
await esql.isQueryPresentInTable(
|
||||
'from logstash-* | limit 100 | drop @timestamp',
|
||||
historyItems
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a failed query to the history', async () => {
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services';
|
||||
import { FtrService } from '../ftr_provider_context';
|
||||
|
||||
export class ESQLService extends FtrService {
|
||||
|
@ -20,9 +21,28 @@ export class ESQLService extends FtrService {
|
|||
expect(await codeEditor.getAttribute('innerText')).to.contain(statement);
|
||||
}
|
||||
|
||||
public async isQueryPresentInTable(query: string, items: string[][]) {
|
||||
const queryAdded = items.some((item) => {
|
||||
return item[2] === query;
|
||||
});
|
||||
|
||||
expect(queryAdded).to.be(true);
|
||||
}
|
||||
|
||||
public async getHistoryItems(): Promise<string[][]> {
|
||||
const queryHistory = await this.testSubjects.find('ESQLEditor-queryHistory');
|
||||
const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody'));
|
||||
const tableItems = await this.getStarredHistoryTableItems(queryHistory);
|
||||
return tableItems;
|
||||
}
|
||||
|
||||
public async getStarredItems(): Promise<string[][]> {
|
||||
const starredQueries = await this.testSubjects.find('ESQLEditor-starredQueries');
|
||||
const tableItems = await this.getStarredHistoryTableItems(starredQueries);
|
||||
return tableItems;
|
||||
}
|
||||
|
||||
private async getStarredHistoryTableItems(element: WebElementWrapper): Promise<string[][]> {
|
||||
const tableBody = await this.retry.try(async () => element.findByTagName('tbody'));
|
||||
const $ = await tableBody.parseDomContent();
|
||||
return $('tr')
|
||||
.toArray()
|
||||
|
@ -44,6 +64,20 @@ export class ESQLService extends FtrService {
|
|||
});
|
||||
}
|
||||
|
||||
public async getStarredItem(rowIndex = 0) {
|
||||
const queryHistory = await this.testSubjects.find('ESQLEditor-starredQueries');
|
||||
const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody'));
|
||||
const rows = await this.retry.try(async () => tableBody.findAllByTagName('tr'));
|
||||
|
||||
return rows[rowIndex];
|
||||
}
|
||||
|
||||
public async clickStarredItem(rowIndex = 0) {
|
||||
const row = await this.getStarredItem(rowIndex);
|
||||
const toggle = await row.findByTestSubject('ESQLEditor-history-starred-queries-run-button');
|
||||
await toggle.click();
|
||||
}
|
||||
|
||||
public async getHistoryItem(rowIndex = 0) {
|
||||
const queryHistory = await this.testSubjects.find('ESQLEditor-queryHistory');
|
||||
const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody'));
|
||||
|
@ -54,7 +88,7 @@ export class ESQLService extends FtrService {
|
|||
|
||||
public async clickHistoryItem(rowIndex = 0) {
|
||||
const row = await this.getHistoryItem(rowIndex);
|
||||
const toggle = await row.findByTestSubject('ESQLEditor-queryHistory-runQuery-button');
|
||||
const toggle = await row.findByTestSubject('ESQLEditor-history-starred-queries-run-button');
|
||||
await toggle.click();
|
||||
}
|
||||
|
||||
|
|
|
@ -206,6 +206,8 @@
|
|||
"@kbn/content-management-content-insights-server/*": ["packages/content-management/content_insights/content_insights_server/*"],
|
||||
"@kbn/content-management-examples-plugin": ["examples/content_management_examples"],
|
||||
"@kbn/content-management-examples-plugin/*": ["examples/content_management_examples/*"],
|
||||
"@kbn/content-management-favorites-common": ["packages/content-management/favorites/favorites_common"],
|
||||
"@kbn/content-management-favorites-common/*": ["packages/content-management/favorites/favorites_common/*"],
|
||||
"@kbn/content-management-favorites-public": ["packages/content-management/favorites/favorites_public"],
|
||||
"@kbn/content-management-favorites-public/*": ["packages/content-management/favorites/favorites_public/*"],
|
||||
"@kbn/content-management-favorites-server": ["packages/content-management/favorites/favorites_server"],
|
||||
|
|
|
@ -8,9 +8,32 @@ import { setKibanaServices } from '@kbn/esql/public/kibana_services';
|
|||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
|
||||
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
|
||||
class LocalStorageMock {
|
||||
public store: Record<string, unknown>;
|
||||
constructor(defaultStore: Record<string, unknown>) {
|
||||
this.store = defaultStore;
|
||||
}
|
||||
clear() {
|
||||
this.store = {};
|
||||
}
|
||||
get(key: string) {
|
||||
return this.store[key] || null;
|
||||
}
|
||||
set(key: string, value: unknown) {
|
||||
this.store[key] = String(value);
|
||||
}
|
||||
remove(key: string) {
|
||||
delete this.store[key];
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new LocalStorageMock({}) as unknown as Storage;
|
||||
|
||||
setKibanaServices(
|
||||
coreMock.createStart(),
|
||||
dataViewPluginMocks.createStartContract(),
|
||||
expressionsPluginMock.createStartContract()
|
||||
expressionsPluginMock.createStartContract(),
|
||||
storage
|
||||
);
|
||||
|
|
|
@ -3154,8 +3154,8 @@
|
|||
"esqlEditor.query.lineNumber": "Ligne {lineNumber}",
|
||||
"esqlEditor.query.querieshistory.error": "La requête a échouée",
|
||||
"esqlEditor.query.querieshistory.success": "La requête a été exécuté avec succès",
|
||||
"esqlEditor.query.querieshistoryCopy": "Copier la requête dans le presse-papier",
|
||||
"esqlEditor.query.querieshistoryRun": "Exécuter la requête",
|
||||
"esqlEditor.query.esqlQueriesCopy": "Copier la requête dans le presse-papier",
|
||||
"esqlEditor.query.esqlQueriesListRun": "Exécuter la requête",
|
||||
"esqlEditor.query.querieshistoryTable": "Tableau d'historique des recherches",
|
||||
"esqlEditor.query.recentQueriesColumnLabel": "Recherches récentes",
|
||||
"esqlEditor.query.refreshLabel": "Actualiser",
|
||||
|
|
|
@ -3148,8 +3148,8 @@
|
|||
"esqlEditor.query.lineNumber": "行{lineNumber}",
|
||||
"esqlEditor.query.querieshistory.error": "クエリ失敗",
|
||||
"esqlEditor.query.querieshistory.success": "クエリは正常に実行されました",
|
||||
"esqlEditor.query.querieshistoryCopy": "クエリをクリップボードにコピー",
|
||||
"esqlEditor.query.querieshistoryRun": "クエリーを実行",
|
||||
"esqlEditor.query.esqlQueriesCopy": "クエリをクリップボードにコピー",
|
||||
"esqlEditor.query.esqlQueriesListRun": "クエリーを実行",
|
||||
"esqlEditor.query.querieshistoryTable": "クエリ履歴テーブル",
|
||||
"esqlEditor.query.recentQueriesColumnLabel": "最近のクエリー",
|
||||
"esqlEditor.query.refreshLabel": "更新",
|
||||
|
|
|
@ -3104,8 +3104,8 @@
|
|||
"esqlEditor.query.lineNumber": "第 {lineNumber} 行",
|
||||
"esqlEditor.query.querieshistory.error": "查询失败",
|
||||
"esqlEditor.query.querieshistory.success": "已成功运行查询",
|
||||
"esqlEditor.query.querieshistoryCopy": "复制查询到剪贴板",
|
||||
"esqlEditor.query.querieshistoryRun": "运行查询",
|
||||
"esqlEditor.query.esqlQueriesCopy": "复制查询到剪贴板",
|
||||
"esqlEditor.query.esqlQueriesListRun": "运行查询",
|
||||
"esqlEditor.query.querieshistoryTable": "查询历史记录表",
|
||||
"esqlEditor.query.recentQueriesColumnLabel": "最近查询",
|
||||
"esqlEditor.query.refreshLabel": "刷新",
|
||||
|
|
|
@ -59,135 +59,293 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
await cleanupInteractiveUser({ getService });
|
||||
});
|
||||
|
||||
const api = {
|
||||
favorite: ({
|
||||
dashboardId,
|
||||
user,
|
||||
space,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
user: LoginAsInteractiveUserResponse;
|
||||
space?: string;
|
||||
}) => {
|
||||
return supertest
|
||||
.post(
|
||||
`${
|
||||
space ? `/s/${space}` : ''
|
||||
}/internal/content_management/favorites/dashboard/${dashboardId}/favorite`
|
||||
)
|
||||
.set(user.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.expect(200);
|
||||
},
|
||||
unfavorite: ({
|
||||
dashboardId,
|
||||
user,
|
||||
space,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
user: LoginAsInteractiveUserResponse;
|
||||
space?: string;
|
||||
}) => {
|
||||
return supertest
|
||||
.post(
|
||||
`${
|
||||
space ? `/s/${space}` : ''
|
||||
}/internal/content_management/favorites/dashboard/${dashboardId}/unfavorite`
|
||||
)
|
||||
.set(user.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.expect(200);
|
||||
},
|
||||
list: ({ user, space }: { user: LoginAsInteractiveUserResponse; space?: string }) => {
|
||||
return supertest
|
||||
.get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/dashboard`)
|
||||
.set(user.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.expect(200);
|
||||
},
|
||||
};
|
||||
|
||||
it('can favorite a dashboard', async () => {
|
||||
let response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
|
||||
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
|
||||
response = await api.favorite({ dashboardId: 'fav2', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']);
|
||||
|
||||
response = await api.unfavorite({ dashboardId: 'fav1', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
|
||||
response = await api.unfavorite({ dashboardId: 'fav3', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
|
||||
response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
|
||||
// check that the favorites aren't shared between users
|
||||
const interactiveUser2 = await loginAsInteractiveUser({
|
||||
getService,
|
||||
username: 'content_manager_dashboard_2',
|
||||
});
|
||||
|
||||
response = await api.list({ user: interactiveUser2 });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
// check that the favorites aren't shared between spaces
|
||||
response = await api.list({ user: interactiveUser, space: 'custom' });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
response = await api.favorite({
|
||||
dashboardId: 'fav1',
|
||||
user: interactiveUser,
|
||||
space: 'custom',
|
||||
});
|
||||
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
|
||||
response = await api.list({ user: interactiveUser, space: 'custom' });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
|
||||
response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
|
||||
// check that reader user can favorite
|
||||
const interactiveUser3 = await loginAsInteractiveUser({
|
||||
getService,
|
||||
username: 'content_reader_dashboard_2',
|
||||
});
|
||||
|
||||
response = await api.list({ user: interactiveUser3 });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser3 });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
it('fails to favorite type is invalid', async () => {
|
||||
await supertest
|
||||
.post(`/internal/content_management/favorites/invalid/fav1/favorite`)
|
||||
.set(interactiveUser.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
// depends on the state from previous test
|
||||
it('reports favorites stats', async () => {
|
||||
const { body }: { body: UnencryptedTelemetryPayload } = await getService('supertest')
|
||||
.post('/internal/telemetry/clusters/_stats')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set(ELASTIC_HTTP_VERSION_HEADER, '2')
|
||||
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
|
||||
.send({ unencrypted: true, refreshCache: true })
|
||||
.expect(200);
|
||||
|
||||
// @ts-ignore
|
||||
const favoritesStats = body[0].stats.stack_stats.kibana.plugins.favorites;
|
||||
expect(favoritesStats).to.eql({
|
||||
dashboard: {
|
||||
total: 3,
|
||||
total_users_spaces: 3,
|
||||
avg_per_user_per_space: 1,
|
||||
max_per_user_per_space: 1,
|
||||
describe('dashboard', () => {
|
||||
const api = {
|
||||
favorite: ({
|
||||
dashboardId,
|
||||
user,
|
||||
space,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
user: LoginAsInteractiveUserResponse;
|
||||
space?: string;
|
||||
}) => {
|
||||
return supertest
|
||||
.post(
|
||||
`${
|
||||
space ? `/s/${space}` : ''
|
||||
}/internal/content_management/favorites/dashboard/${dashboardId}/favorite`
|
||||
)
|
||||
.set(user.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.expect(200);
|
||||
},
|
||||
unfavorite: ({
|
||||
dashboardId,
|
||||
user,
|
||||
space,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
user: LoginAsInteractiveUserResponse;
|
||||
space?: string;
|
||||
}) => {
|
||||
return supertest
|
||||
.post(
|
||||
`${
|
||||
space ? `/s/${space}` : ''
|
||||
}/internal/content_management/favorites/dashboard/${dashboardId}/unfavorite`
|
||||
)
|
||||
.set(user.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.expect(200);
|
||||
},
|
||||
list: ({
|
||||
user,
|
||||
space,
|
||||
}: {
|
||||
user: LoginAsInteractiveUserResponse;
|
||||
space?: string;
|
||||
favoriteType?: string;
|
||||
}) => {
|
||||
return supertest
|
||||
.get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/dashboard`)
|
||||
.set(user.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.expect(200);
|
||||
},
|
||||
};
|
||||
|
||||
it('can favorite a dashboard', async () => {
|
||||
let response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
|
||||
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
|
||||
response = await api.favorite({ dashboardId: 'fav2', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']);
|
||||
|
||||
response = await api.unfavorite({ dashboardId: 'fav1', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
|
||||
response = await api.unfavorite({ dashboardId: 'fav3', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
|
||||
response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
|
||||
// check that the favorites aren't shared between users
|
||||
const interactiveUser2 = await loginAsInteractiveUser({
|
||||
getService,
|
||||
username: 'content_manager_dashboard_2',
|
||||
});
|
||||
|
||||
response = await api.list({ user: interactiveUser2 });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
// check that the favorites aren't shared between spaces
|
||||
response = await api.list({ user: interactiveUser, space: 'custom' });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
response = await api.favorite({
|
||||
dashboardId: 'fav1',
|
||||
user: interactiveUser,
|
||||
space: 'custom',
|
||||
});
|
||||
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
|
||||
response = await api.list({ user: interactiveUser, space: 'custom' });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
|
||||
response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
|
||||
// check that reader user can favorite
|
||||
const interactiveUser3 = await loginAsInteractiveUser({
|
||||
getService,
|
||||
username: 'content_reader_dashboard_2',
|
||||
});
|
||||
|
||||
response = await api.list({ user: interactiveUser3 });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser3 });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
});
|
||||
|
||||
it("fails to favorite if metadata is provided for type that doesn't support it", async () => {
|
||||
await supertest
|
||||
.post(`/internal/content_management/favorites/dashboard/fav1/favorite`)
|
||||
.set(interactiveUser.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send({ metadata: { foo: 'bar' } })
|
||||
.expect(400);
|
||||
|
||||
await supertest
|
||||
.post(`/internal/content_management/favorites/dashboard/fav1/favorite`)
|
||||
.set(interactiveUser.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send({ metadata: {} })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
// depends on the state from previous test
|
||||
it('reports favorites stats', async () => {
|
||||
const { body }: { body: UnencryptedTelemetryPayload } = await getService('supertest')
|
||||
.post('/internal/telemetry/clusters/_stats')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set(ELASTIC_HTTP_VERSION_HEADER, '2')
|
||||
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
|
||||
.send({ unencrypted: true, refreshCache: true })
|
||||
.expect(200);
|
||||
|
||||
// @ts-ignore
|
||||
const favoritesStats = body[0].stats.stack_stats.kibana.plugins.favorites;
|
||||
expect(favoritesStats).to.eql({
|
||||
dashboard: {
|
||||
total: 3,
|
||||
total_users_spaces: 3,
|
||||
avg_per_user_per_space: 1,
|
||||
max_per_user_per_space: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('esql_query', () => {
|
||||
const type = 'esql_query';
|
||||
const metadata1 = {
|
||||
queryString: 'SELECT * FROM test1',
|
||||
createdAt: '2021-09-01T00:00:00Z',
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
const metadata2 = {
|
||||
queryString: 'SELECT * FROM test2',
|
||||
createdAt: '2023-09-01T00:00:00Z',
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
const api = {
|
||||
favorite: ({
|
||||
queryId,
|
||||
metadata,
|
||||
user,
|
||||
}: {
|
||||
queryId: string;
|
||||
metadata: object;
|
||||
user: LoginAsInteractiveUserResponse;
|
||||
}) => {
|
||||
return supertest
|
||||
.post(`/internal/content_management/favorites/${type}/${queryId}/favorite`)
|
||||
.set(user.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.send({ metadata });
|
||||
},
|
||||
unfavorite: ({
|
||||
queryId,
|
||||
user,
|
||||
}: {
|
||||
queryId: string;
|
||||
user: LoginAsInteractiveUserResponse;
|
||||
}) => {
|
||||
return supertest
|
||||
.post(`/internal/content_management/favorites/${type}/${queryId}/unfavorite`)
|
||||
.set(user.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.expect(200);
|
||||
},
|
||||
list: ({
|
||||
user,
|
||||
space,
|
||||
}: {
|
||||
user: LoginAsInteractiveUserResponse;
|
||||
space?: string;
|
||||
favoriteType?: string;
|
||||
}) => {
|
||||
return supertest
|
||||
.get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/${type}`)
|
||||
.set(user.headers)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.expect(200);
|
||||
},
|
||||
};
|
||||
|
||||
it('fails to favorite if metadata is not valid', async () => {
|
||||
await api
|
||||
.favorite({
|
||||
queryId: 'fav1',
|
||||
metadata: { foo: 'bar' },
|
||||
user: interactiveUser,
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
await api
|
||||
.favorite({
|
||||
queryId: 'fav1',
|
||||
metadata: {},
|
||||
user: interactiveUser,
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('can favorite a query', async () => {
|
||||
let response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
response = await api.favorite({
|
||||
queryId: 'fav1',
|
||||
user: interactiveUser,
|
||||
metadata: metadata1,
|
||||
});
|
||||
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
|
||||
response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1']);
|
||||
expect(response.body.favoriteMetadata).to.eql({ fav1: metadata1 });
|
||||
|
||||
response = await api.favorite({
|
||||
queryId: 'fav2',
|
||||
user: interactiveUser,
|
||||
metadata: metadata2,
|
||||
});
|
||||
expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']);
|
||||
|
||||
response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']);
|
||||
expect(response.body.favoriteMetadata).to.eql({
|
||||
fav1: metadata1,
|
||||
fav2: metadata2,
|
||||
});
|
||||
|
||||
response = await api.unfavorite({ queryId: 'fav1', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
|
||||
response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql(['fav2']);
|
||||
expect(response.body.favoriteMetadata).to.eql({
|
||||
fav2: metadata2,
|
||||
});
|
||||
|
||||
response = await api.unfavorite({ queryId: 'fav2', user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
|
||||
response = await api.list({ user: interactiveUser });
|
||||
expect(response.body.favoriteIds).to.eql([]);
|
||||
expect(response.body.favoriteMetadata).to.eql({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
143
x-pack/test/functional/apps/discover/esql_starred.ts
Normal file
143
x-pack/test/functional/apps/discover/esql_starred.ts
Normal file
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const esArchiver = getService('esArchiver');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const monacoEditor = getService('monacoEditor');
|
||||
const { common, discover, header, unifiedFieldList, security } = getPageObjects([
|
||||
'common',
|
||||
'discover',
|
||||
'header',
|
||||
'unifiedFieldList',
|
||||
'security',
|
||||
]);
|
||||
const testSubjects = getService('testSubjects');
|
||||
const esql = getService('esql');
|
||||
const securityService = getService('security');
|
||||
const browser = getService('browser');
|
||||
|
||||
const user = 'discover_read_user';
|
||||
const role = 'discover_read_role';
|
||||
|
||||
describe('Discover ES|QL starred queries', () => {
|
||||
before('initialize tests', async () => {
|
||||
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional');
|
||||
await kibanaServer.importExport.load(
|
||||
'x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json'
|
||||
);
|
||||
|
||||
await security.forceLogout();
|
||||
|
||||
await securityService.role.create(role, {
|
||||
elasticsearch: {
|
||||
indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }],
|
||||
},
|
||||
kibana: [
|
||||
{
|
||||
feature: {
|
||||
discover: ['read'],
|
||||
},
|
||||
spaces: ['*'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await securityService.user.create(user, {
|
||||
password: 'changeme',
|
||||
roles: [role],
|
||||
full_name: user,
|
||||
});
|
||||
|
||||
await security.login(user, 'changeme', {
|
||||
expectSpaceSelector: false,
|
||||
});
|
||||
});
|
||||
|
||||
after('clean up archives', async () => {
|
||||
await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional');
|
||||
await kibanaServer.importExport.unload(
|
||||
'x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json'
|
||||
);
|
||||
await security.forceLogout();
|
||||
await securityService.user.delete(user);
|
||||
await securityService.role.delete(role);
|
||||
});
|
||||
|
||||
it('should star a query from the editor query history', async () => {
|
||||
await common.navigateToApp('discover');
|
||||
await discover.selectTextBaseLang();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await unifiedFieldList.waitUntilSidebarHasLoaded();
|
||||
|
||||
await testSubjects.click('ESQLEditor-toggle-query-history-button');
|
||||
const historyItem = await esql.getHistoryItem(0);
|
||||
await testSubjects.moveMouseTo('~ESQLFavoriteButton');
|
||||
const button = await historyItem.findByTestSubject('ESQLFavoriteButton');
|
||||
await button.click();
|
||||
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await testSubjects.click('starred-queries-tab');
|
||||
|
||||
const starredItems = await esql.getStarredItems();
|
||||
await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', starredItems);
|
||||
});
|
||||
|
||||
it('should persist the starred query after a browser refresh', async () => {
|
||||
await browser.refresh();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await unifiedFieldList.waitUntilSidebarHasLoaded();
|
||||
|
||||
await testSubjects.click('ESQLEditor-toggle-query-history-button');
|
||||
await testSubjects.click('starred-queries-tab');
|
||||
const starredItems = await esql.getStarredItems();
|
||||
await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', starredItems);
|
||||
});
|
||||
|
||||
it('should select a query from the starred and submit it', async () => {
|
||||
await common.navigateToApp('discover');
|
||||
await discover.selectTextBaseLang();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await unifiedFieldList.waitUntilSidebarHasLoaded();
|
||||
|
||||
await testSubjects.click('ESQLEditor-toggle-query-history-button');
|
||||
await testSubjects.click('starred-queries-tab');
|
||||
|
||||
await esql.clickStarredItem(0);
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
|
||||
const editorValue = await monacoEditor.getCodeEditorValue();
|
||||
expect(editorValue).to.eql(`FROM logstash-* | LIMIT 10`);
|
||||
});
|
||||
|
||||
it('should delete a query from the starred queries tab', async () => {
|
||||
await common.navigateToApp('discover');
|
||||
await discover.selectTextBaseLang();
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
await discover.waitUntilSearchingHasFinished();
|
||||
await unifiedFieldList.waitUntilSidebarHasLoaded();
|
||||
|
||||
await testSubjects.click('ESQLEditor-toggle-query-history-button');
|
||||
await testSubjects.click('starred-queries-tab');
|
||||
|
||||
const starredItem = await esql.getStarredItem(0);
|
||||
const button = await starredItem.findByTestSubject('ESQLFavoriteButton');
|
||||
await button.click();
|
||||
await testSubjects.click('esqlEditor-discard-starred-query-discard-btn');
|
||||
|
||||
await header.waitUntilLoadingHasFinished();
|
||||
|
||||
const starredItems = await esql.getStarredItems();
|
||||
expect(starredItems[0][0]).to.be('No items found');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -20,5 +20,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
|
|||
loadTestFile(require.resolve('./value_suggestions'));
|
||||
loadTestFile(require.resolve('./value_suggestions_non_timebased'));
|
||||
loadTestFile(require.resolve('./saved_search_embeddable'));
|
||||
loadTestFile(require.resolve('./esql_starred'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -375,12 +375,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
|
||||
await testSubjects.click('ESQLEditor-toggle-query-history-button');
|
||||
const historyItems = await esql.getHistoryItems();
|
||||
log.debug(historyItems);
|
||||
const queryAdded = historyItems.some((item) => {
|
||||
return item[1] === 'FROM logstash-* | LIMIT 10';
|
||||
});
|
||||
|
||||
expect(queryAdded).to.be(true);
|
||||
await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', historyItems);
|
||||
});
|
||||
|
||||
it('updating the query should add this to the history', async () => {
|
||||
|
@ -397,12 +392,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
|
||||
await testSubjects.click('ESQLEditor-toggle-query-history-button');
|
||||
const historyItems = await esql.getHistoryItems();
|
||||
log.debug(historyItems);
|
||||
const queryAdded = historyItems.some((item) => {
|
||||
return item[1] === 'from logstash-* | limit 100 | drop @timestamp';
|
||||
});
|
||||
|
||||
expect(queryAdded).to.be(true);
|
||||
await esql.isQueryPresentInTable(
|
||||
'from logstash-* | limit 100 | drop @timestamp',
|
||||
historyItems
|
||||
);
|
||||
});
|
||||
|
||||
it('should select a query from the history and submit it', async () => {
|
||||
|
@ -416,12 +409,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await esql.clickHistoryItem(1);
|
||||
|
||||
const historyItems = await esql.getHistoryItems();
|
||||
log.debug(historyItems);
|
||||
const queryAdded = historyItems.some((item) => {
|
||||
return item[1] === 'from logstash-* | limit 100 | drop @timestamp';
|
||||
});
|
||||
|
||||
expect(queryAdded).to.be(true);
|
||||
await esql.isQueryPresentInTable(
|
||||
'from logstash-* | limit 100 | drop @timestamp',
|
||||
historyItems
|
||||
);
|
||||
});
|
||||
|
||||
it('should add a failed query to the history', async () => {
|
||||
|
@ -437,7 +428,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await PageObjects.discover.waitUntilSearchingHasFinished();
|
||||
await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded();
|
||||
await testSubjects.click('ESQLEditor-toggle-query-history-button');
|
||||
await testSubjects.click('ESQLEditor-queryHistory-runQuery-button');
|
||||
await testSubjects.click('ESQLEditor-history-starred-queries-run-button');
|
||||
const historyItem = await esql.getHistoryItem(0);
|
||||
await historyItem.findByTestSubject('ESQLEditor-queryHistory-error');
|
||||
});
|
||||
|
|
|
@ -3666,6 +3666,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/content-management-favorites-common@link:packages/content-management/favorites/favorites_common":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/content-management-favorites-public@link:packages/content-management/favorites/favorites_public":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue