[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:
Anton Dosov 2024-11-18 21:53:46 +01:00 committed by GitHub
parent 974293fa01
commit 45972374f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1972 additions and 297 deletions

1
.github/CODEOWNERS vendored
View file

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

View file

@ -232,6 +232,7 @@
"@kbn/content-management-content-insights-public": "link:packages/content-management/content_insights/content_insights_public",
"@kbn/content-management-content-insights-server": "link:packages/content-management/content_insights/content_insights_server",
"@kbn/content-management-examples-plugin": "link:examples/content_management_examples",
"@kbn/content-management-favorites-common": "link:packages/content-management/favorites/favorites_common",
"@kbn/content-management-favorites-public": "link:packages/content-management/favorites/favorites_public",
"@kbn/content-management-favorites-server": "link:packages/content-management/favorites/favorites_server",
"@kbn/content-management-plugin": "link:src/plugins/content_management",

View file

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

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
// Limit the number of favorites to prevent too large objects due to metadata
export const FAVORITES_LIMIT = 100;

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../..',
roots: ['<rootDir>/packages/content-management/favorites/favorites_common'],
};

View file

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

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/content-management-favorites-common",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

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

View file

@ -9,36 +9,52 @@
import type { HttpStart } from '@kbn/core-http-browser';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import type { GetFavoritesResponse } from '@kbn/content-management-favorites-server';
import type {
GetFavoritesResponse as GetFavoritesResponseServer,
AddFavoriteResponse,
RemoveFavoriteResponse,
} from '@kbn/content-management-favorites-server';
export interface FavoritesClientPublic {
getFavorites(): Promise<GetFavoritesResponse>;
addFavorite({ id }: { id: string }): Promise<GetFavoritesResponse>;
removeFavorite({ id }: { id: string }): Promise<GetFavoritesResponse>;
export interface GetFavoritesResponse<Metadata extends object | void = void>
extends GetFavoritesResponseServer {
favoriteMetadata: Metadata extends object ? Record<string, Metadata> : never;
}
type AddFavoriteRequest<Metadata extends object | void> = Metadata extends object
? { id: string; metadata: Metadata }
: { id: string };
export interface FavoritesClientPublic<Metadata extends object | void = void> {
getFavorites(): Promise<GetFavoritesResponse<Metadata>>;
addFavorite(params: AddFavoriteRequest<Metadata>): Promise<AddFavoriteResponse>;
removeFavorite(params: { id: string }): Promise<RemoveFavoriteResponse>;
getFavoriteType(): string;
reportAddFavoriteClick(): void;
reportRemoveFavoriteClick(): void;
}
export class FavoritesClient implements FavoritesClientPublic {
export class FavoritesClient<Metadata extends object | void = void>
implements FavoritesClientPublic<Metadata>
{
constructor(
private readonly appName: string,
private readonly favoriteObjectType: string,
private readonly deps: { http: HttpStart; usageCollection?: UsageCollectionStart }
) {}
public async getFavorites(): Promise<GetFavoritesResponse> {
public async getFavorites(): Promise<GetFavoritesResponse<Metadata>> {
return this.deps.http.get(`/internal/content_management/favorites/${this.favoriteObjectType}`);
}
public async addFavorite({ id }: { id: string }): Promise<GetFavoritesResponse> {
public async addFavorite(params: AddFavoriteRequest<Metadata>): Promise<AddFavoriteResponse> {
return this.deps.http.post(
`/internal/content_management/favorites/${this.favoriteObjectType}/${id}/favorite`
`/internal/content_management/favorites/${this.favoriteObjectType}/${params.id}/favorite`,
{ body: 'metadata' in params ? JSON.stringify({ metadata: params.metadata }) : undefined }
);
}
public async removeFavorite({ id }: { id: string }): Promise<GetFavoritesResponse> {
public async removeFavorite({ id }: { id: string }): Promise<RemoveFavoriteResponse> {
return this.deps.http.post(
`/internal/content_management/favorites/${this.favoriteObjectType}/${id}/unfavorite`
);

View file

@ -11,6 +11,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import React from 'react';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { useFavoritesClient, useFavoritesContext } from './favorites_context';
const favoritesKeys = {
@ -54,14 +55,14 @@ export const useAddFavorite = () => {
onSuccess: (data) => {
queryClient.setQueryData(favoritesKeys.byType(favoritesClient!.getFavoriteType()), data);
},
onError: (error: Error) => {
onError: (error: IHttpFetchError<{ message?: string }>) => {
notifyError?.(
<>
{i18n.translate('contentManagement.favorites.addFavoriteError', {
defaultMessage: 'Error adding to Starred',
})}
</>,
error?.message
error?.body?.message ?? error.message
);
},
}

View file

@ -7,4 +7,10 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { registerFavorites, type GetFavoritesResponse } from './src';
export {
registerFavorites,
type GetFavoritesResponse,
type FavoritesSetup,
type AddFavoriteResponse,
type RemoveFavoriteResponse,
} from './src';

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ObjectType } from '@kbn/config-schema';
interface FavoriteTypeConfig {
typeMetadataSchema?: ObjectType;
}
export type FavoritesRegistrySetup = Pick<FavoritesRegistry, 'registerFavoriteType'>;
export class FavoritesRegistry {
private favoriteTypes = new Map<string, FavoriteTypeConfig>();
registerFavoriteType(type: string, config: FavoriteTypeConfig = {}) {
if (this.favoriteTypes.has(type)) {
throw new Error(`Favorite type ${type} is already registered`);
}
this.favoriteTypes.set(type, config);
}
hasType(type: string) {
return this.favoriteTypes.has(type);
}
validateMetadata(type: string, metadata?: object) {
if (!this.hasType(type)) {
throw new Error(`Favorite type ${type} is not registered`);
}
const typeConfig = this.favoriteTypes.get(type)!;
const typeMetadataSchema = typeConfig.typeMetadataSchema;
if (typeMetadataSchema) {
typeMetadataSchema.validate(metadata);
} else {
if (metadata === undefined) {
return; /* ok */
} else {
throw new Error(`Favorite type ${type} does not support metadata`);
}
}
}
}

View file

@ -14,12 +14,9 @@ import {
SECURITY_EXTENSION_ID,
} from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { FavoritesService } from './favorites_service';
import { FavoritesService, FavoritesLimitExceededError } from './favorites_service';
import { favoritesSavedObjectType } from './favorites_saved_object';
// only dashboard is supported for now
// TODO: make configurable or allow any string
const typeSchema = schema.oneOf([schema.literal('dashboard')]);
import { FavoritesRegistry } from './favorites_registry';
/**
* @public
@ -27,9 +24,45 @@ const typeSchema = schema.oneOf([schema.literal('dashboard')]);
*/
export interface GetFavoritesResponse {
favoriteIds: string[];
favoriteMetadata?: Record<string, object>;
}
export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; logger: Logger }) {
export interface AddFavoriteResponse {
favoriteIds: string[];
}
export interface RemoveFavoriteResponse {
favoriteIds: string[];
}
export function registerFavoritesRoutes({
core,
logger,
favoritesRegistry,
}: {
core: CoreSetup;
logger: Logger;
favoritesRegistry: FavoritesRegistry;
}) {
const typeSchema = schema.string({
validate: (type) => {
if (!favoritesRegistry.hasType(type)) {
return `Unknown favorite type: ${type}`;
}
},
});
const metadataSchema = schema.maybe(
schema.object(
{
// validated later by the registry depending on the type
},
{
unknowns: 'allow',
}
)
);
const router = core.http.createRouter();
const getSavedObjectClient = (coreRequestHandlerContext: CoreRequestHandlerContext) => {
@ -49,6 +82,13 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log
id: schema.string(),
type: typeSchema,
}),
body: schema.maybe(
schema.nullable(
schema.object({
metadata: metadataSchema,
})
)
),
},
// we don't protect the route with any access tags as
// we only give access to the current user's favorites ids
@ -67,13 +107,35 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log
const favorites = new FavoritesService(type, userId, {
savedObjectClient: getSavedObjectClient(coreRequestHandlerContext),
logger,
favoritesRegistry,
});
const favoriteIds: GetFavoritesResponse = await favorites.addFavorite({
id: request.params.id,
});
const id = request.params.id;
const metadata = request.body?.metadata;
return response.ok({ body: favoriteIds });
try {
favoritesRegistry.validateMetadata(type, metadata);
} catch (e) {
return response.badRequest({ body: { message: e.message } });
}
try {
const favoritesResult = await favorites.addFavorite({
id,
metadata,
});
const addFavoritesResponse: AddFavoriteResponse = {
favoriteIds: favoritesResult.favoriteIds,
};
return response.ok({ body: addFavoritesResponse });
} catch (e) {
if (e instanceof FavoritesLimitExceededError) {
return response.forbidden({ body: { message: e.message } });
}
throw e; // unexpected error, let the global error handler deal with it
}
}
);
@ -102,12 +164,18 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log
const favorites = new FavoritesService(type, userId, {
savedObjectClient: getSavedObjectClient(coreRequestHandlerContext),
logger,
favoritesRegistry,
});
const favoriteIds: GetFavoritesResponse = await favorites.removeFavorite({
const favoritesResult: GetFavoritesResponse = await favorites.removeFavorite({
id: request.params.id,
});
return response.ok({ body: favoriteIds });
const removeFavoriteResponse: RemoveFavoriteResponse = {
favoriteIds: favoritesResult.favoriteIds,
};
return response.ok({ body: removeFavoriteResponse });
}
);
@ -135,12 +203,18 @@ export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; log
const favorites = new FavoritesService(type, userId, {
savedObjectClient: getSavedObjectClient(coreRequestHandlerContext),
logger,
favoritesRegistry,
});
const getFavoritesResponse: GetFavoritesResponse = await favorites.getFavorites();
const favoritesResult = await favorites.getFavorites();
const favoritesResponse: GetFavoritesResponse = {
favoriteIds: favoritesResult.favoriteIds,
favoriteMetadata: favoritesResult.favoriteMetadata,
};
return response.ok({
body: getFavoritesResponse,
body: favoritesResponse,
});
}
);

View file

@ -14,6 +14,7 @@ export interface FavoritesSavedObjectAttributes {
userId: string;
type: string;
favoriteIds: string[];
favoriteMetadata?: Record<string, object>;
}
const schemaV1 = schema.object({
@ -22,6 +23,10 @@ const schemaV1 = schema.object({
favoriteIds: schema.arrayOf(schema.string()),
});
const schemaV3 = schemaV1.extends({
favoriteMetadata: schema.maybe(schema.object({}, { unknowns: 'allow' })),
});
export const favoritesSavedObjectName = 'favorites';
export const favoritesSavedObjectType: SavedObjectsType = {
@ -34,6 +39,7 @@ export const favoritesSavedObjectType: SavedObjectsType = {
userId: { type: 'keyword' },
type: { type: 'keyword' },
favoriteIds: { type: 'keyword' },
favoriteMetadata: { type: 'object', dynamic: false },
},
},
modelVersions: {
@ -65,5 +71,19 @@ export const favoritesSavedObjectType: SavedObjectsType = {
create: schemaV1,
},
},
3: {
changes: [
{
type: 'mappings_addition',
addedMappings: {
favoriteMetadata: { type: 'object', dynamic: false },
},
},
],
schemas: {
forwardCompatibility: schemaV3.extends({}, { unknowns: 'ignore' }),
create: schemaV3,
},
},
},
};

View file

@ -7,9 +7,17 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
// eslint-disable-next-line max-classes-per-file
import type { SavedObject, SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { FAVORITES_LIMIT } from '@kbn/content-management-favorites-common';
import { Logger, SavedObjectsErrorHelpers } from '@kbn/core/server';
import { favoritesSavedObjectType, FavoritesSavedObjectAttributes } from './favorites_saved_object';
import { FavoritesRegistry } from './favorites_registry';
export interface FavoritesState {
favoriteIds: string[];
favoriteMetadata?: Record<string, object>;
}
export class FavoritesService {
constructor(
@ -18,23 +26,38 @@ export class FavoritesService {
private readonly deps: {
savedObjectClient: SavedObjectsClientContract;
logger: Logger;
favoritesRegistry: FavoritesRegistry;
}
) {
if (!this.userId || !this.type) {
// This should never happen, but just in case let's do a runtime check
throw new Error('userId and object type are required to use a favorite service');
}
if (!this.deps.favoritesRegistry.hasType(this.type)) {
throw new Error(`Favorite type ${this.type} is not registered`);
}
}
public async getFavorites(): Promise<{ favoriteIds: string[] }> {
public async getFavorites(): Promise<FavoritesState> {
const favoritesSavedObject = await this.getFavoritesSavedObject();
const favoriteIds = favoritesSavedObject?.attributes?.favoriteIds ?? [];
const favoriteMetadata = favoritesSavedObject?.attributes?.favoriteMetadata;
return { favoriteIds };
return { favoriteIds, favoriteMetadata };
}
public async addFavorite({ id }: { id: string }): Promise<{ favoriteIds: string[] }> {
/**
* @throws {FavoritesLimitExceededError}
*/
public async addFavorite({
id,
metadata,
}: {
id: string;
metadata?: object;
}): Promise<FavoritesState> {
let favoritesSavedObject = await this.getFavoritesSavedObject();
if (!favoritesSavedObject) {
@ -44,14 +67,28 @@ export class FavoritesService {
userId: this.userId,
type: this.type,
favoriteIds: [id],
...(metadata
? {
favoriteMetadata: {
[id]: metadata,
},
}
: {}),
},
{
id: this.getFavoriteSavedObjectId(),
}
);
return { favoriteIds: favoritesSavedObject.attributes.favoriteIds };
return {
favoriteIds: favoritesSavedObject.attributes.favoriteIds,
favoriteMetadata: favoritesSavedObject.attributes.favoriteMetadata,
};
} else {
if ((favoritesSavedObject.attributes.favoriteIds ?? []).length >= FAVORITES_LIMIT) {
throw new FavoritesLimitExceededError();
}
const newFavoriteIds = [
...(favoritesSavedObject.attributes.favoriteIds ?? []).filter(
(favoriteId) => favoriteId !== id
@ -59,22 +96,34 @@ export class FavoritesService {
id,
];
const newFavoriteMetadata = metadata
? {
...favoritesSavedObject.attributes.favoriteMetadata,
[id]: metadata,
}
: undefined;
await this.deps.savedObjectClient.update(
favoritesSavedObjectType.name,
favoritesSavedObject.id,
{
favoriteIds: newFavoriteIds,
...(newFavoriteMetadata
? {
favoriteMetadata: newFavoriteMetadata,
}
: {}),
},
{
version: favoritesSavedObject.version,
}
);
return { favoriteIds: newFavoriteIds };
return { favoriteIds: newFavoriteIds, favoriteMetadata: newFavoriteMetadata };
}
}
public async removeFavorite({ id }: { id: string }): Promise<{ favoriteIds: string[] }> {
public async removeFavorite({ id }: { id: string }): Promise<FavoritesState> {
const favoritesSavedObject = await this.getFavoritesSavedObject();
if (!favoritesSavedObject) {
@ -85,19 +134,36 @@ export class FavoritesService {
(favoriteId) => favoriteId !== id
);
const newFavoriteMetadata = favoritesSavedObject.attributes.favoriteMetadata
? { ...favoritesSavedObject.attributes.favoriteMetadata }
: undefined;
if (newFavoriteMetadata) {
delete newFavoriteMetadata[id];
}
await this.deps.savedObjectClient.update(
favoritesSavedObjectType.name,
favoritesSavedObject.id,
{
...favoritesSavedObject.attributes,
favoriteIds: newFavoriteIds,
...(newFavoriteMetadata
? {
favoriteMetadata: newFavoriteMetadata,
}
: {}),
},
{
version: favoritesSavedObject.version,
// We don't want to merge the attributes here because we want to remove the keys from the metadata
mergeAttributes: false,
}
);
return {
favoriteIds: newFavoriteIds,
favoriteMetadata: newFavoriteMetadata,
};
}
@ -123,3 +189,14 @@ export class FavoritesService {
return `${this.type}:${this.userId}`;
}
}
export class FavoritesLimitExceededError extends Error {
constructor() {
super(
`Limit reached: This list can contain a maximum of ${FAVORITES_LIMIT} items. Please remove an item before adding a new one.`
);
this.name = 'FavoritesLimitExceededError';
Object.setPrototypeOf(this, FavoritesLimitExceededError.prototype); // For TypeScript compatibility
}
}

View file

@ -12,8 +12,19 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { registerFavoritesRoutes } from './favorites_routes';
import { favoritesSavedObjectType } from './favorites_saved_object';
import { registerFavoritesUsageCollection } from './favorites_usage_collection';
import { FavoritesRegistry, FavoritesRegistrySetup } from './favorites_registry';
export type { GetFavoritesResponse } from './favorites_routes';
export type {
GetFavoritesResponse,
AddFavoriteResponse,
RemoveFavoriteResponse,
} from './favorites_routes';
/**
* @public
* Setup contract for the favorites feature.
*/
export type FavoritesSetup = FavoritesRegistrySetup;
/**
* @public
@ -31,11 +42,14 @@ export function registerFavorites({
core: CoreSetup;
logger: Logger;
usageCollection?: UsageCollectionSetup;
}) {
}): FavoritesSetup {
const favoritesRegistry = new FavoritesRegistry();
core.savedObjects.registerType(favoritesSavedObjectType);
registerFavoritesRoutes({ core, logger });
registerFavoritesRoutes({ core, logger, favoritesRegistry });
if (usageCollection) {
registerFavoritesUsageCollection({ core, usageCollection });
}
return favoritesRegistry;
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useState, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiButton,
EuiButtonEmpty,
EuiText,
EuiCheckbox,
EuiFlexItem,
EuiFlexGroup,
EuiHorizontalRule,
} from '@elastic/eui';
export interface DiscardStarredQueryModalProps {
onClose: (dismissFlag?: boolean, removeQuery?: boolean) => Promise<void>;
}
// Needed for React.lazy
// eslint-disable-next-line import/no-default-export
export default function DiscardStarredQueryModal({ onClose }: DiscardStarredQueryModalProps) {
const [dismissModalChecked, setDismissModalChecked] = useState(false);
const onTransitionModalDismiss = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setDismissModalChecked(e.target.checked);
}, []);
return (
<EuiModal
onClose={() => onClose()}
style={{ width: 700 }}
data-test-subj="discard-starred-query-modal"
>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('esqlEditor.discardStarredQueryModal.title', {
defaultMessage: 'Discard starred query',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText size="m">
{i18n.translate('esqlEditor.discardStarredQueryModal.body', {
defaultMessage:
'Removing a starred query will remove it from the list. This has no impact on the recent query history.',
})}
</EuiText>
<EuiHorizontalRule margin="s" />
</EuiModalBody>
<EuiModalFooter css={{ paddingBlockStart: 0 }}>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiCheckbox
id="dismiss-discard-starred-query-modal"
label={i18n.translate('esqlEditor.discardStarredQueryModal.dismissButtonLabel', {
defaultMessage: "Don't ask me again",
})}
checked={dismissModalChecked}
onChange={onTransitionModalDismiss}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={async () => {
await onClose(dismissModalChecked, false);
}}
color="primary"
data-test-subj="esqlEditor-discard-starred-query-cancel-btn"
>
{i18n.translate('esqlEditor.discardStarredQueryModal.cancelLabel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
onClick={async () => {
await onClose(dismissModalChecked, true);
}}
color="danger"
iconType="trash"
data-test-subj="esqlEditor-discard-starred-query-discard-btn"
>
{i18n.translate('esqlEditor.discardStarredQueryModal.discardQueryLabel', {
defaultMessage: 'Discard query',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
);
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import type { DiscardStarredQueryModalProps } from './discard_starred_query_modal';
const Fallback = () => <div />;
const LazyDiscardStarredQueryModal = React.lazy(() => import('./discard_starred_query_modal'));
export const DiscardStarredQueryModal = (props: DiscardStarredQueryModalProps) => (
<React.Suspense fallback={<Fallback />}>
<LazyDiscardStarredQueryModal {...props} />
</React.Suspense>
);

View file

@ -0,0 +1,203 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EsqlStarredQueriesService } from './esql_starred_queries_service';
import { coreMock } from '@kbn/core/public/mocks';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
class LocalStorageMock {
public store: Record<string, unknown>;
constructor(defaultStore: Record<string, unknown>) {
this.store = defaultStore;
}
clear() {
this.store = {};
}
get(key: string) {
return this.store[key] || null;
}
set(key: string, value: unknown) {
this.store[key] = String(value);
}
remove(key: string) {
delete this.store[key];
}
}
describe('EsqlStarredQueriesService', () => {
const core = coreMock.createStart();
const storage = new LocalStorageMock({}) as unknown as Storage;
it('should initialize', async () => {
const service = await EsqlStarredQueriesService.initialize({
http: core.http,
storage,
});
expect(service).toBeDefined();
expect(service.queries$.value).toEqual([]);
});
it('should add a new starred query', async () => {
const service = await EsqlStarredQueriesService.initialize({
http: core.http,
storage,
});
const query = {
queryString: 'SELECT * FROM test',
timeRan: '2021-09-01T00:00:00Z',
status: 'success' as const,
};
await service.addStarredQuery(query);
expect(service.queries$.value).toEqual([
{
id: expect.any(String),
...query,
// stores now()
timeRan: expect.any(String),
},
]);
});
it('should not add the same query twice', async () => {
const service = await EsqlStarredQueriesService.initialize({
http: core.http,
storage,
});
const query = {
queryString: 'SELECT * FROM test',
timeRan: '2021-09-01T00:00:00Z',
status: 'success' as const,
};
const expected = {
id: expect.any(String),
...query,
// stores now()
timeRan: expect.any(String),
// trimmed query
queryString: 'SELECT * FROM test',
};
await service.addStarredQuery(query);
expect(service.queries$.value).toEqual([expected]);
// second time
await service.addStarredQuery(query);
expect(service.queries$.value).toEqual([expected]);
});
it('should add the query trimmed', async () => {
const service = await EsqlStarredQueriesService.initialize({
http: core.http,
storage,
});
const query = {
queryString: `SELECT * FROM test |
WHERE field != 'value'`,
timeRan: '2021-09-01T00:00:00Z',
status: 'error' as const,
};
await service.addStarredQuery(query);
expect(service.queries$.value).toEqual([
{
id: expect.any(String),
...query,
timeRan: expect.any(String),
// trimmed query
queryString: `SELECT * FROM test | WHERE field != 'value'`,
},
]);
});
it('should remove a query', async () => {
const service = await EsqlStarredQueriesService.initialize({
http: core.http,
storage,
});
const query = {
queryString: `SELECT * FROM test | WHERE field != 'value'`,
timeRan: '2021-09-01T00:00:00Z',
status: 'error' as const,
};
await service.addStarredQuery(query);
expect(service.queries$.value).toEqual([
{
id: expect.any(String),
...query,
timeRan: expect.any(String),
// trimmed query
queryString: `SELECT * FROM test | WHERE field != 'value'`,
},
]);
await service.removeStarredQuery(query.queryString);
expect(service.queries$.value).toEqual([]);
});
it('should return the button correctly', async () => {
const service = await EsqlStarredQueriesService.initialize({
http: core.http,
storage,
});
const query = {
queryString: 'SELECT * FROM test',
timeRan: '2021-09-01T00:00:00Z',
status: 'success' as const,
};
await service.addStarredQuery(query);
const buttonWithTooltip = service.renderStarredButton(query);
const button = buttonWithTooltip.props.children;
expect(button.props.title).toEqual('Remove ES|QL query from Starred');
expect(button.props.iconType).toEqual('starFilled');
});
it('should display the modal when the Remove button is clicked', async () => {
const service = await EsqlStarredQueriesService.initialize({
http: core.http,
storage,
});
const query = {
queryString: 'SELECT * FROM test',
timeRan: '2021-09-01T00:00:00Z',
status: 'success' as const,
};
await service.addStarredQuery(query);
const buttonWithTooltip = service.renderStarredButton(query);
const button = buttonWithTooltip.props.children;
expect(button.props.title).toEqual('Remove ES|QL query from Starred');
button.props.onClick();
expect(service.discardModalVisibility$.value).toEqual(true);
});
it('should NOT display the modal when Remove the button is clicked but the user has dismissed the modal permanently', async () => {
storage.set('esqlEditor.starredQueriesDiscard', true);
const service = await EsqlStarredQueriesService.initialize({
http: core.http,
storage,
});
const query = {
queryString: 'SELECT * FROM test',
timeRan: '2021-09-01T00:00:00Z',
status: 'success' as const,
};
await service.addStarredQuery(query);
const buttonWithTooltip = service.renderStarredButton(query);
const button = buttonWithTooltip.props.children;
button.props.onClick();
expect(service.discardModalVisibility$.value).toEqual(false);
});
});

View file

@ -0,0 +1,241 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { i18n } from '@kbn/i18n';
import { v4 as uuidv4 } from 'uuid';
import type { CoreStart } from '@kbn/core/public';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import { EuiButtonIcon } from '@elastic/eui';
import { FavoritesClient } from '@kbn/content-management-favorites-public';
import { FAVORITES_LIMIT as ESQL_STARRED_QUERIES_LIMIT } from '@kbn/content-management-favorites-common';
import { type QueryHistoryItem, getTrimmedQuery } from '../history_local_storage';
import { TooltipWrapper } from './tooltip_wrapper';
const STARRED_QUERIES_DISCARD_KEY = 'esqlEditor.starredQueriesDiscard';
/**
* EsqlStarredQueriesService is a service that manages the starred queries in the ES|QL editor.
* It provides methods to add and remove queries from the starred list.
* It also provides a method to render the starred button in the editor list table.
*
* @param client - The FavoritesClient instance.
* @param starredQueries - The list of starred queries.
* @param queries$ - The BehaviorSubject that emits the starred queries list.
* @method initialize - Initializes the service and retrieves the starred queries from the favoriteService.
* @method checkIfQueryIsStarred - Checks if a query is already starred.
* @method addStarredQuery - Adds a query to the starred list.
* @method removeStarredQuery - Removes a query from the starred list.
* @method renderStarredButton - Renders the starred button in the editor list table.
* @returns EsqlStarredQueriesService instance.
*
*/
export interface StarredQueryItem extends QueryHistoryItem {
id: string;
}
interface EsqlStarredQueriesServices {
http: CoreStart['http'];
storage: Storage;
usageCollection?: UsageCollectionStart;
}
interface EsqlStarredQueriesParams {
client: FavoritesClient<StarredQueryMetadata>;
starredQueries: StarredQueryItem[];
storage: Storage;
}
function generateId() {
return uuidv4();
}
interface StarredQueryMetadata {
queryString: string;
createdAt: string;
status: 'success' | 'warning' | 'error';
}
export class EsqlStarredQueriesService {
private client: FavoritesClient<StarredQueryMetadata>;
private starredQueries: StarredQueryItem[] = [];
private queryToEdit: string = '';
private storage: Storage;
queries$: BehaviorSubject<StarredQueryItem[]>;
discardModalVisibility$: BehaviorSubject<boolean> = new BehaviorSubject(false);
constructor({ client, starredQueries, storage }: EsqlStarredQueriesParams) {
this.client = client;
this.starredQueries = starredQueries;
this.queries$ = new BehaviorSubject(starredQueries);
this.storage = storage;
}
static async initialize(services: EsqlStarredQueriesServices) {
const client = new FavoritesClient<StarredQueryMetadata>('esql_editor', 'esql_query', {
http: services.http,
usageCollection: services.usageCollection,
});
const { favoriteMetadata } = (await client?.getFavorites()) || {};
const retrievedQueries: StarredQueryItem[] = [];
if (!favoriteMetadata) {
return new EsqlStarredQueriesService({
client,
starredQueries: [],
storage: services.storage,
});
}
Object.keys(favoriteMetadata).forEach((id) => {
const item = favoriteMetadata[id];
const { queryString, createdAt, status } = item;
retrievedQueries.push({ id, queryString, timeRan: createdAt, status });
});
return new EsqlStarredQueriesService({
client,
starredQueries: retrievedQueries,
storage: services.storage,
});
}
private checkIfQueryIsStarred(queryString: string) {
return this.starredQueries.some((item) => item.queryString === queryString);
}
private checkIfStarredQueriesLimitReached() {
return this.starredQueries.length >= ESQL_STARRED_QUERIES_LIMIT;
}
async addStarredQuery(item: Pick<QueryHistoryItem, 'queryString' | 'status'>) {
const favoriteItem: { id: string; metadata: StarredQueryMetadata } = {
id: generateId(),
metadata: {
queryString: getTrimmedQuery(item.queryString),
createdAt: new Date().toISOString(),
status: item.status ?? 'success',
},
};
// do not add the query if it's already starred or has reached the limit
if (
this.checkIfQueryIsStarred(favoriteItem.metadata.queryString) ||
this.checkIfStarredQueriesLimitReached()
) {
return;
}
const starredQueries = [...this.starredQueries];
starredQueries.push({
queryString: favoriteItem.metadata.queryString,
timeRan: favoriteItem.metadata.createdAt,
status: favoriteItem.metadata.status,
id: favoriteItem.id,
});
this.queries$.next(starredQueries);
this.starredQueries = starredQueries;
await this.client.addFavorite(favoriteItem);
// telemetry, add favorite click event
this.client.reportAddFavoriteClick();
}
async removeStarredQuery(queryString: string) {
const trimmedQueryString = getTrimmedQuery(queryString);
const favoriteItem = this.starredQueries.find(
(item) => item.queryString === trimmedQueryString
);
if (!favoriteItem) {
return;
}
this.starredQueries = this.starredQueries.filter(
(item) => item.queryString !== trimmedQueryString
);
this.queries$.next(this.starredQueries);
await this.client.removeFavorite({ id: favoriteItem.id });
// telemetry, remove favorite click event
this.client.reportRemoveFavoriteClick();
}
async onDiscardModalClose(shouldDismissModal?: boolean, removeQuery?: boolean) {
if (shouldDismissModal) {
// set the local storage flag to not show the modal again
this.storage.set(STARRED_QUERIES_DISCARD_KEY, true);
}
this.discardModalVisibility$.next(false);
if (removeQuery) {
// remove the query
await this.removeStarredQuery(this.queryToEdit);
}
}
renderStarredButton(item: QueryHistoryItem) {
const trimmedQueryString = getTrimmedQuery(item.queryString);
const isStarred = this.checkIfQueryIsStarred(trimmedQueryString);
return (
<TooltipWrapper
tooltipContent={i18n.translate(
'esqlEditor.query.querieshistory.starredQueriesReachedLimitTooltip',
{
defaultMessage:
'Limit reached: This list can contain a maximum of {limit} items. Please remove an item before adding a new one.',
values: { limit: ESQL_STARRED_QUERIES_LIMIT },
}
)}
condition={!isStarred && this.checkIfStarredQueriesLimitReached()}
>
<EuiButtonIcon
title={
isStarred
? i18n.translate('esqlEditor.query.querieshistory.removeFavoriteTitle', {
defaultMessage: 'Remove ES|QL query from Starred',
})
: i18n.translate('esqlEditor.query.querieshistory.addFavoriteTitle', {
defaultMessage: 'Add ES|QL query to Starred',
})
}
className={!isStarred ? 'cm-favorite-button--empty' : ''}
aria-label={
isStarred
? i18n.translate('esqlEditor.query.querieshistory.removeFavoriteTitle', {
defaultMessage: 'Remove ES|QL query from Starred',
})
: i18n.translate('esqlEditor.query.querieshistory.addFavoriteTitle', {
defaultMessage: 'Add ES|QL query to Starred',
})
}
iconType={isStarred ? 'starFilled' : 'starEmpty'}
disabled={!isStarred && this.checkIfStarredQueriesLimitReached()}
onClick={async () => {
this.queryToEdit = trimmedQueryString;
if (isStarred) {
// show the discard modal only if the user has not dismissed it
if (!this.storage.get(STARRED_QUERIES_DISCARD_KEY)) {
this.discardModalVisibility$.next(true);
} else {
await this.removeStarredQuery(item.queryString);
}
} else {
await this.addStarredQuery(item);
}
}}
data-test-subj="ESQLFavoriteButton"
/>
</TooltipWrapper>
);
}
}

View file

@ -8,8 +8,15 @@
*/
import React from 'react';
import { QueryHistoryAction, getTableColumns, QueryColumn } from './query_history';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { coreMock } from '@kbn/core/public/mocks';
import { render, screen } from '@testing-library/react';
import {
QueryHistoryAction,
getTableColumns,
QueryColumn,
HistoryAndStarredQueriesTabs,
} from './history_starred_queries';
jest.mock('../history_local_storage', () => {
const module = jest.requireActual('../history_local_storage');
@ -18,7 +25,6 @@ jest.mock('../history_local_storage', () => {
getHistoryItems: () => [
{
queryString: 'from kibana_sample_data_flights | limit 10',
timeZone: 'Browser',
timeRan: 'Mar. 25, 24 08:45:27',
queryRunning: false,
status: 'success',
@ -27,7 +33,7 @@ jest.mock('../history_local_storage', () => {
};
});
describe('QueryHistory', () => {
describe('Starred and History queries components', () => {
describe('QueryHistoryAction', () => {
it('should render the history action component as a button if is spaceReduced is undefined', () => {
render(<QueryHistoryAction toggleHistory={jest.fn()} isHistoryOpen />);
@ -47,9 +53,14 @@ describe('QueryHistory', () => {
});
describe('getTableColumns', () => {
it('should get the history table columns correctly', async () => {
it('should get the table columns correctly', async () => {
const columns = getTableColumns(50, false, []);
expect(columns).toEqual([
{
'data-test-subj': 'favoriteBtn',
render: expect.anything(),
width: '40px',
},
{
css: {
height: '100%',
@ -64,7 +75,7 @@ describe('QueryHistory', () => {
{
'data-test-subj': 'queryString',
field: 'queryString',
name: 'Recent queries',
name: 'Query',
render: expect.anything(),
},
{
@ -83,11 +94,58 @@ describe('QueryHistory', () => {
},
]);
});
it('should get the table columns correctly for the starred list', async () => {
const columns = getTableColumns(50, false, [], true);
expect(columns).toEqual([
{
'data-test-subj': 'favoriteBtn',
render: expect.anything(),
width: '40px',
},
{
css: {
height: '100%',
},
'data-test-subj': 'status',
field: 'status',
name: '',
render: expect.anything(),
sortable: false,
width: '40px',
},
{
'data-test-subj': 'queryString',
field: 'queryString',
name: 'Query',
render: expect.anything(),
},
{
'data-test-subj': 'timeRan',
field: 'timeRan',
name: 'Date Added',
render: expect.anything(),
sortable: true,
width: '240px',
},
{
actions: [],
'data-test-subj': 'actions',
name: '',
width: '60px',
},
]);
});
});
it('should get the history table columns correctly for reduced space', async () => {
const columns = getTableColumns(50, true, []);
expect(columns).toEqual([
{
'data-test-subj': 'favoriteBtn',
render: expect.anything(),
width: 'auto',
},
{
css: {
height: '100%',
@ -110,7 +168,7 @@ describe('QueryHistory', () => {
{
'data-test-subj': 'queryString',
field: 'queryString',
name: 'Recent queries',
name: 'Query',
render: expect.anything(),
},
{
@ -132,7 +190,7 @@ describe('QueryHistory', () => {
/>
);
expect(
screen.queryByTestId('ESQLEditor-queryHistory-queryString-expanded')
screen.queryByTestId('ESQLEditor-queryList-queryString-expanded')
).not.toBeInTheDocument();
});
@ -152,9 +210,66 @@ describe('QueryHistory', () => {
isOnReducedSpaceLayout={true}
/>
);
expect(
screen.getByTestId('ESQLEditor-queryHistory-queryString-expanded')
).toBeInTheDocument();
expect(screen.getByTestId('ESQLEditor-queryList-queryString-expanded')).toBeInTheDocument();
});
});
describe('HistoryAndStarredQueriesTabs', () => {
const services = {
core: coreMock.createStart(),
};
it('should render two tabs', () => {
render(
<KibanaContextProvider services={services}>
<HistoryAndStarredQueriesTabs
containerCSS={{}}
containerWidth={1024}
onUpdateAndSubmit={jest.fn()}
height={200}
/>
</KibanaContextProvider>
);
expect(screen.getByTestId('history-queries-tab')).toBeInTheDocument();
expect(screen.getByTestId('history-queries-tab')).toHaveTextContent('Recent');
expect(screen.getByTestId('starred-queries-tab')).toBeInTheDocument();
expect(screen.getByTestId('starred-queries-tab')).toHaveTextContent('Starred');
});
it('should render the history queries tab by default', () => {
render(
<KibanaContextProvider services={services}>
<HistoryAndStarredQueriesTabs
containerCSS={{}}
containerWidth={1024}
onUpdateAndSubmit={jest.fn()}
height={200}
/>
</KibanaContextProvider>
);
expect(screen.getByTestId('ESQLEditor-queryHistory')).toBeInTheDocument();
expect(screen.getByTestId('ESQLEditor-history-starred-queries-helpText')).toHaveTextContent(
'Showing last 20 queries'
);
});
it('should render the starred queries if the corresponding btn is clicked', () => {
render(
<KibanaContextProvider services={services}>
<HistoryAndStarredQueriesTabs
containerCSS={{}}
containerWidth={1024}
onUpdateAndSubmit={jest.fn()}
height={200}
/>
</KibanaContextProvider>
);
// click the starred queries tab
screen.getByTestId('starred-queries-tab').click();
expect(screen.getByTestId('ESQLEditor-starredQueries')).toBeInTheDocument();
expect(screen.getByTestId('ESQLEditor-history-starred-queries-helpText')).toHaveTextContent(
'Showing 0 queries (max 100)'
);
});
});
});

View file

@ -6,8 +6,8 @@
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useState, useRef, useEffect, useMemo } from 'react';
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import {
EuiFlexGroup,
@ -22,11 +22,26 @@ import {
EuiCopy,
EuiToolTip,
euiScrollBarStyles,
EuiTab,
EuiTabs,
EuiNotificationBadge,
EuiText,
} from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { cssFavoriteHoverWithinEuiTableRow } from '@kbn/content-management-favorites-public';
import { FAVORITES_LIMIT as ESQL_STARRED_QUERIES_LIMIT } from '@kbn/content-management-favorites-common';
import { css, Interpolation, Theme } from '@emotion/react';
import { useEuiTablePersist } from '@kbn/shared-ux-table-persist';
import { type QueryHistoryItem, getHistoryItems } from '../history_local_storage';
import { getReducedSpaceStyling, swapArrayElements } from './query_history_helpers';
import {
type QueryHistoryItem,
getHistoryItems,
MAX_HISTORY_QUERIES_NUMBER,
dateFormat,
} from '../history_local_storage';
import type { ESQLEditorDeps } from '../types';
import { getReducedSpaceStyling, swapArrayElements } from './history_starred_queries_helpers';
import { EsqlStarredQueriesService, StarredQueryItem } from './esql_starred_queries_service';
import { DiscardStarredQueryModal } from './discard_starred_query';
export function QueryHistoryAction({
toggleHistory,
@ -99,9 +114,22 @@ export function QueryHistoryAction({
export const getTableColumns = (
width: number,
isOnReducedSpaceLayout: boolean,
actions: Array<CustomItemAction<QueryHistoryItem>>
actions: Array<CustomItemAction<QueryHistoryItem>>,
isStarredTab = false,
starredQueriesService?: EsqlStarredQueriesService
): Array<EuiBasicTableColumn<QueryHistoryItem>> => {
const columnsArray = [
{
'data-test-subj': 'favoriteBtn',
render: (item: QueryHistoryItem) => {
const StarredQueryButton = starredQueriesService?.renderStarredButton(item);
if (!StarredQueryButton) {
return null;
}
return StarredQueryButton;
},
width: isOnReducedSpaceLayout ? 'auto' : '40px',
},
{
field: 'status',
name: '',
@ -167,7 +195,7 @@ export const getTableColumns = (
field: 'queryString',
'data-test-subj': 'queryString',
name: i18n.translate('esqlEditor.query.recentQueriesColumnLabel', {
defaultMessage: 'Recent queries',
defaultMessage: 'Query',
}),
render: (queryString: QueryHistoryItem['queryString']) => (
<QueryColumn
@ -180,11 +208,15 @@ export const getTableColumns = (
{
field: 'timeRan',
'data-test-subj': 'timeRan',
name: i18n.translate('esqlEditor.query.timeRanColumnLabel', {
defaultMessage: 'Time ran',
}),
name: isStarredTab
? i18n.translate('esqlEditor.query.dateAddedColumnLabel', {
defaultMessage: 'Date Added',
})
: i18n.translate('esqlEditor.query.timeRanColumnLabel', {
defaultMessage: 'Time ran',
}),
sortable: true,
render: (timeRan: QueryHistoryItem['timeRan']) => timeRan,
render: (timeRan: QueryHistoryItem['timeRan']) => moment(timeRan).format(dateFormat),
width: isOnReducedSpaceLayout ? 'auto' : '240px',
},
{
@ -196,22 +228,33 @@ export const getTableColumns = (
];
// I need to swap the elements here to get the desired design
return isOnReducedSpaceLayout ? swapArrayElements(columnsArray, 1, 2) : columnsArray;
return isOnReducedSpaceLayout ? swapArrayElements(columnsArray, 2, 3) : columnsArray;
};
export function QueryHistory({
export function QueryList({
containerCSS,
containerWidth,
onUpdateAndSubmit,
height,
listItems,
starredQueriesService,
tableCaption,
dataTestSubj,
isStarredTab = false,
}: {
listItems: QueryHistoryItem[];
containerCSS: Interpolation<Theme>;
containerWidth: number;
onUpdateAndSubmit: (qs: string) => void;
height: number;
starredQueriesService?: EsqlStarredQueriesService;
tableCaption?: string;
dataTestSubj?: string;
isStarredTab?: boolean;
}) {
const theme = useEuiTheme();
const scrollBarStyles = euiScrollBarStyles(theme);
const [isDiscardQueryModalVisible, setIsDiscardQueryModalVisible] = useState(false);
const { sorting, onTableChange } = useEuiTablePersist<QueryHistoryItem>({
tableId: 'esqlQueryHistory',
@ -221,8 +264,6 @@ export function QueryHistory({
},
});
const historyItems: QueryHistoryItem[] = getHistoryItems(sorting.sort.direction);
const actions: Array<CustomItemAction<QueryHistoryItem>> = useMemo(() => {
return [
{
@ -232,16 +273,16 @@ export function QueryHistory({
<EuiFlexItem grow={false}>
<EuiToolTip
position="top"
content={i18n.translate('esqlEditor.query.querieshistoryRun', {
content={i18n.translate('esqlEditor.query.esqlQueriesListRun', {
defaultMessage: 'Run query',
})}
>
<EuiButtonIcon
iconType="play"
aria-label={i18n.translate('esqlEditor.query.querieshistoryRun', {
aria-label={i18n.translate('esqlEditor.query.esqlQueriesListRun', {
defaultMessage: 'Run query',
})}
data-test-subj="ESQLEditor-queryHistory-runQuery-button"
data-test-subj="ESQLEditor-history-starred-queries-run-button"
role="button"
iconSize="m"
onClick={() => onUpdateAndSubmit(item.queryString)}
@ -254,7 +295,7 @@ export function QueryHistory({
<EuiFlexItem grow={false}>
<EuiCopy
textToCopy={item.queryString}
content={i18n.translate('esqlEditor.query.querieshistoryCopy', {
content={i18n.translate('esqlEditor.query.esqlQueriesCopy', {
defaultMessage: 'Copy query to clipboard',
})}
>
@ -266,7 +307,7 @@ export function QueryHistory({
css={css`
cursor: pointer;
`}
aria-label={i18n.translate('esqlEditor.query.querieshistoryCopy', {
aria-label={i18n.translate('esqlEditor.query.esqlQueriesCopy', {
defaultMessage: 'Copy query to clipboard',
})}
/>
@ -279,14 +320,23 @@ export function QueryHistory({
},
];
}, [onUpdateAndSubmit]);
const isOnReducedSpaceLayout = containerWidth < 560;
const columns = useMemo(() => {
return getTableColumns(containerWidth, isOnReducedSpaceLayout, actions);
}, [actions, containerWidth, isOnReducedSpaceLayout]);
return getTableColumns(
containerWidth,
isOnReducedSpaceLayout,
actions,
isStarredTab,
starredQueriesService
);
}, [containerWidth, isOnReducedSpaceLayout, actions, isStarredTab, starredQueriesService]);
const { euiTheme } = theme;
const extraStyling = isOnReducedSpaceLayout ? getReducedSpaceStyling() : '';
const starredQueriesCellStyling = cssFavoriteHoverWithinEuiTableRow(theme.euiTheme);
const tableStyling = css`
.euiTable {
background-color: ${euiTheme.colors.lightestShade};
@ -305,22 +355,40 @@ export function QueryHistory({
overflow-y: auto;
${scrollBarStyles}
${extraStyling}
${starredQueriesCellStyling}
`;
starredQueriesService?.discardModalVisibility$.subscribe((nextVisibility) => {
if (isDiscardQueryModalVisible !== nextVisibility) {
setIsDiscardQueryModalVisible(nextVisibility);
}
});
return (
<div data-test-subj="ESQLEditor-queryHistory" css={containerCSS}>
<div data-test-subj={dataTestSubj ?? 'ESQLEditor-queryList'} css={containerCSS}>
<EuiInMemoryTable
tableCaption={i18n.translate('esqlEditor.query.querieshistoryTable', {
defaultMessage: 'Queries history table',
})}
tableCaption={
tableCaption ||
i18n.translate('esqlEditor.query.queriesListTable', {
defaultMessage: 'ES|QL Queries table',
})
}
responsiveBreakpoint={false}
items={historyItems}
items={listItems}
columns={columns}
sorting={sorting}
onChange={onTableChange}
css={tableStyling}
tableLayout={containerWidth < 560 ? 'auto' : 'fixed'}
/>
{isDiscardQueryModalVisible && (
<DiscardStarredQueryModal
onClose={async (dismissFlag, removeQuery) =>
(await starredQueriesService?.onDiscardModalClose(dismissFlag, removeQuery)) ??
Promise.resolve()
}
/>
)}
</div>
);
}
@ -354,7 +422,7 @@ export function QueryColumn({
onClick={() => {
setIsRowExpanded(!isRowExpanded);
}}
data-test-subj="ESQLEditor-queryHistory-queryString-expanded"
data-test-subj="ESQLEditor-queryList-queryString-expanded"
aria-label={
isRowExpanded
? i18n.translate('esqlEditor.query.collapseLabel', {
@ -387,3 +455,171 @@ export function QueryColumn({
</>
);
}
export function HistoryAndStarredQueriesTabs({
containerCSS,
containerWidth,
onUpdateAndSubmit,
height,
}: {
containerCSS: Interpolation<Theme>;
containerWidth: number;
onUpdateAndSubmit: (qs: string) => void;
height: number;
}) {
const kibana = useKibana<ESQLEditorDeps>();
const { core, usageCollection, storage } = kibana.services;
const [starredQueriesService, setStarredQueriesService] = useState<EsqlStarredQueriesService>();
const [starredQueries, setStarredQueries] = useState<StarredQueryItem[]>([]);
useEffect(() => {
const initializeService = async () => {
const starredService = await EsqlStarredQueriesService.initialize({
http: core.http,
usageCollection,
storage,
});
setStarredQueriesService(starredService);
};
if (!starredQueriesService) {
initializeService();
}
}, [core.http, starredQueriesService, storage, usageCollection]);
starredQueriesService?.queries$.subscribe((nextQueries) => {
if (nextQueries.length !== starredQueries.length) {
setStarredQueries(nextQueries);
}
});
const { euiTheme } = useEuiTheme();
const tabs = useMemo(() => {
return [
{
id: 'history-queries-tab',
name: i18n.translate('esqlEditor.query.historyQueriesTabLabel', {
defaultMessage: 'Recent',
}),
dataTestSubj: 'history-queries-tab',
content: (
<QueryList
containerCSS={containerCSS}
onUpdateAndSubmit={onUpdateAndSubmit}
containerWidth={containerWidth}
height={height}
listItems={getHistoryItems('desc')}
dataTestSubj="ESQLEditor-queryHistory"
tableCaption={i18n.translate('esqlEditor.query.querieshistoryTable', {
defaultMessage: 'Queries history table',
})}
starredQueriesService={starredQueriesService}
/>
),
},
{
id: 'starred-queries-tab',
dataTestSubj: 'starred-queries-tab',
name: i18n.translate('esqlEditor.query.starredQueriesTabLabel', {
defaultMessage: 'Starred',
}),
append: (
<EuiNotificationBadge className="eui-alignCenter" size="m" color="subdued">
{starredQueries?.length}
</EuiNotificationBadge>
),
content: (
<QueryList
containerCSS={containerCSS}
onUpdateAndSubmit={onUpdateAndSubmit}
containerWidth={containerWidth}
height={height}
listItems={starredQueries}
dataTestSubj="ESQLEditor-starredQueries"
tableCaption={i18n.translate('esqlEditor.query.starredQueriesTable', {
defaultMessage: 'Starred queries table',
})}
starredQueriesService={starredQueriesService}
isStarredTab={true}
/>
),
},
];
}, [
containerCSS,
containerWidth,
height,
onUpdateAndSubmit,
starredQueries,
starredQueriesService,
]);
const [selectedTabId, setSelectedTabId] = useState('history-queries-tab');
const selectedTabContent = useMemo(() => {
return tabs.find((obj) => obj.id === selectedTabId)?.content;
}, [selectedTabId, tabs]);
const onSelectedTabChanged = (id: string) => {
setSelectedTabId(id);
};
const renderTabs = useCallback(() => {
return tabs.map((tab, index) => (
<EuiTab
key={index}
onClick={() => onSelectedTabChanged(tab.id)}
isSelected={tab.id === selectedTabId}
append={tab.append}
data-test-subj={tab.dataTestSubj}
>
{tab.name}
</EuiTab>
));
}, [selectedTabId, tabs]);
return (
<>
<EuiFlexGroup
responsive={false}
alignItems="center"
justifyContent="spaceBetween"
css={css`
background-color: ${euiTheme.colors.lightestShade};
padding-left: ${euiTheme.size.s};
padding-right: ${euiTheme.size.s};
border-block-end: ${euiTheme.border.thin};
`}
>
<EuiTabs bottomBorder={false} size="s">
{renderTabs()}
</EuiTabs>
<EuiFlexItem grow={false}>
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
<EuiText
size="xs"
color="subdued"
data-test-subj="ESQLEditor-history-starred-queries-helpText"
>
<p>
{selectedTabId === 'history-queries-tab'
? i18n.translate('esqlEditor.history.historyItemslimit', {
defaultMessage: 'Showing last {historyItemsLimit} queries',
values: { historyItemsLimit: MAX_HISTORY_QUERIES_NUMBER },
})
: i18n.translate('esqlEditor.history.starredItemslimit', {
defaultMessage:
'Showing {starredItemsCount} queries (max {starredItemsLimit})',
values: {
starredItemsLimit: ESQL_STARRED_QUERIES_LIMIT,
starredItemsCount: starredQueries.length ?? 0,
},
})}
</p>
</EuiText>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{selectedTabContent}
</>
);
}

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { swapArrayElements } from './query_history_helpers';
import { swapArrayElements } from './history_starred_queries_helpers';
describe('query history helpers', function () {
it('should swap 2 elements in an array', function () {

View file

@ -19,16 +19,19 @@ export const getReducedSpaceStyling = () => {
}
.euiTable thead tr {
display: grid;
grid-template-columns: 40px 1fr 0 auto;
grid-template-columns: 40px 40px 1fr 0 auto;
}
.euiTable tbody tr {
display: grid;
grid-template-columns: 40px 1fr auto;
grid-template-columns: 40px 40px 1fr auto;
grid-template-areas:
'status timeRan lastDuration actions'
'. queryString queryString queryString';
'favoriteBtn status timeRan lastDuration actions'
'. . queryString queryString queryString';
}
/* Set grid template areas */
.euiTable td[data-test-subj='favoriteBtn'] {
grid-area: favoriteBtn;
}
.euiTable td[data-test-subj='status'] {
grid-area: status;
}

View file

@ -8,7 +8,6 @@
*/
import React, { memo, useState, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiText,
@ -27,7 +26,7 @@ import {
import { getLimitFromESQLQuery } from '@kbn/esql-utils';
import { type MonacoMessage } from '../helpers';
import { ErrorsWarningsFooterPopover } from './errors_warnings_popover';
import { QueryHistoryAction, QueryHistory } from './query_history';
import { QueryHistoryAction, HistoryAndStarredQueriesTabs } from './history_starred_queries';
import { SubmitFeedbackComponent } from './feedback_component';
import { QueryWrapComponent } from './query_wrap_component';
import type { ESQLEditorDeps } from '../types';
@ -60,7 +59,6 @@ interface EditorFooterProps {
isSpaceReduced?: boolean;
hideTimeFilterInfo?: boolean;
hideQueryHistory?: boolean;
isInCompactMode?: boolean;
displayDocumentationAsFlyout?: boolean;
}
@ -84,7 +82,6 @@ export const EditorFooter = memo(function EditorFooter({
isLanguageComponentOpen,
setIsLanguageComponentOpen,
hideQueryHistory,
isInCompactMode,
displayDocumentationAsFlyout,
measuredContainerWidth,
code,
@ -310,7 +307,7 @@ export const EditorFooter = memo(function EditorFooter({
</EuiFlexItem>
{isHistoryOpen && (
<EuiFlexItem grow={false}>
<QueryHistory
<HistoryAndStarredQueriesTabs
containerCSS={styles.historyContainer}
onUpdateAndSubmit={onUpdateAndSubmit}
containerWidth={measuredContainerWidth}

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiToolTip, EuiToolTipProps } from '@elastic/eui';
export type TooltipWrapperProps = Partial<Omit<EuiToolTipProps, 'content'>> & {
tooltipContent: string;
/** When the condition is truthy, the tooltip will be shown */
condition: boolean;
};
export const TooltipWrapper: React.FunctionComponent<TooltipWrapperProps> = ({
children,
condition,
tooltipContent,
...tooltipProps
}) => {
return (
<>
{condition ? (
<EuiToolTip content={tooltipContent} delay="regular" {...tooltipProps}>
<>{children}</>
</EuiToolTip>
) : (
children
)}
</>
);
};

View file

@ -99,7 +99,7 @@ export const ESQLEditor = memo(function ESQLEditor({
uiSettings,
} = kibana.services;
const darkMode = core.theme?.getTheme().darkMode;
const timeZone = uiSettings?.get('dateFormat:tz');
const histogramBarTarget = uiSettings?.get('histogram:barTarget') ?? 50;
const [code, setCode] = useState<string>(query.esql ?? '');
// To make server side errors less "sticky", register the state of the code when submitting
@ -464,11 +464,10 @@ export const ESQLEditor = memo(function ESQLEditor({
validateQuery();
addQueriesToCache({
queryString: code,
timeZone,
status: clientParserStatus,
});
}
}, [clientParserStatus, isLoading, isQueryLoading, parseMessages, code, timeZone]);
}, [clientParserStatus, isLoading, isQueryLoading, parseMessages, code]);
const queryValidation = useCallback(
async ({ active }: { active: boolean }) => {

View file

@ -20,7 +20,6 @@ describe('history local storage', function () {
it('should add queries to cache correctly ', function () {
addQueriesToCache({
queryString: 'from kibana_sample_data_flights | limit 10',
timeZone: 'Browser',
});
const historyItems = getCachedQueries();
expect(historyItems.length).toBe(1);
@ -31,7 +30,6 @@ describe('history local storage', function () {
it('should update queries to cache correctly ', function () {
addQueriesToCache({
queryString: 'from kibana_sample_data_flights \n | limit 10 \n | stats meow = avg(woof)',
timeZone: 'Browser',
status: 'success',
});
@ -49,7 +47,6 @@ describe('history local storage', function () {
it('should update queries to cache correctly if they are the same with different format', function () {
addQueriesToCache({
queryString: 'from kibana_sample_data_flights | limit 10 | stats meow = avg(woof) ',
timeZone: 'Browser',
status: 'success',
});
@ -68,7 +65,6 @@ describe('history local storage', function () {
addQueriesToCache(
{
queryString: 'row 1',
timeZone: 'Browser',
status: 'success',
},
2

View file

@ -7,10 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import moment from 'moment';
import 'moment-timezone';
const QUERY_HISTORY_ITEM_KEY = 'QUERY_HISTORY_ITEM_KEY';
const dateFormat = 'MMM. D, YY HH:mm:ss.SSS';
export const dateFormat = 'MMM. D, YY HH:mm:ss';
/**
* We show maximum 20 ES|QL queries in the Query history component
@ -19,32 +18,35 @@ const dateFormat = 'MMM. D, YY HH:mm:ss.SSS';
export interface QueryHistoryItem {
status?: 'success' | 'error' | 'warning';
queryString: string;
startDateMilliseconds?: number;
timeRan?: string;
timeZone?: string;
}
const MAX_QUERIES_NUMBER = 20;
export const MAX_HISTORY_QUERIES_NUMBER = 20;
const getKey = (queryString: string) => {
export const getTrimmedQuery = (queryString: string) => {
return queryString.replaceAll('\n', '').trim().replace(/\s\s+/g, ' ');
};
const getMomentTimeZone = (timeZone?: string) => {
return !timeZone || timeZone === 'Browser' ? moment.tz.guess() : timeZone;
};
const sortDates = (date1?: number, date2?: number) => {
return moment(date1)?.valueOf() - moment(date2)?.valueOf();
const sortDates = (date1?: string, date2?: string) => {
if (!date1 || !date2) return 0;
return date1 < date2 ? 1 : date1 > date2 ? -1 : 0;
};
export const getHistoryItems = (sortDirection: 'desc' | 'asc'): QueryHistoryItem[] => {
const localStorageString = localStorage.getItem(QUERY_HISTORY_ITEM_KEY) ?? '[]';
const historyItems: QueryHistoryItem[] = JSON.parse(localStorageString);
const localStorageItems: QueryHistoryItem[] = JSON.parse(localStorageString);
const historyItems: QueryHistoryItem[] = localStorageItems.map((item) => {
return {
status: item.status,
queryString: item.queryString,
timeRan: item.timeRan ? new Date(item.timeRan).toISOString() : undefined,
};
});
const sortedByDate = historyItems.sort((a, b) => {
return sortDirection === 'desc'
? sortDates(b.startDateMilliseconds, a.startDateMilliseconds)
: sortDates(a.startDateMilliseconds, b.startDateMilliseconds);
? sortDates(b.timeRan, a.timeRan)
: sortDates(a.timeRan, b.timeRan);
});
return sortedByDate;
};
@ -58,24 +60,22 @@ export const getCachedQueries = (): QueryHistoryItem[] => {
// Adding the maxQueriesAllowed here for testing purposes
export const addQueriesToCache = (
item: QueryHistoryItem,
maxQueriesAllowed = MAX_QUERIES_NUMBER
maxQueriesAllowed = MAX_HISTORY_QUERIES_NUMBER
) => {
// if the user is working on multiple tabs
// the cachedQueries Map might not contain all
// the localStorage queries
const queries = getHistoryItems('desc');
queries.forEach((queryItem) => {
const trimmedQueryString = getKey(queryItem.queryString);
const trimmedQueryString = getTrimmedQuery(queryItem.queryString);
cachedQueries.set(trimmedQueryString, queryItem);
});
const trimmedQueryString = getKey(item.queryString);
const trimmedQueryString = getTrimmedQuery(item.queryString);
if (item.queryString) {
const tz = getMomentTimeZone(item.timeZone);
cachedQueries.set(trimmedQueryString, {
...item,
timeRan: moment().tz(tz).format(dateFormat),
startDateMilliseconds: moment().valueOf(),
timeRan: new Date().toISOString(),
status: item.status,
});
}
@ -83,9 +83,7 @@ export const addQueriesToCache = (
let allQueries = [...getCachedQueries()];
if (allQueries.length >= maxQueriesAllowed + 1) {
const sortedByDate = allQueries.sort((a, b) =>
sortDates(b?.startDateMilliseconds, a?.startDateMilliseconds)
);
const sortedByDate = allQueries.sort((a, b) => sortDates(b.timeRan, a.timeRan));
// queries to store in the localstorage
allQueries = sortedByDate.slice(0, maxQueriesAllowed);

View file

@ -13,6 +13,8 @@ import type { AggregateQuery } from '@kbn/es-query';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types';
import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
export interface ESQLEditorProps {
/** The aggregate type query */
@ -70,6 +72,8 @@ export interface ESQLEditorDeps {
core: CoreStart;
dataViews: DataViewsPublicPluginStart;
expressions: ExpressionsStart;
storage: Storage;
indexManagementApiService?: IndexManagementPluginSetup['apiService'];
fieldsMetadata?: FieldsMetadataPublicStart;
usageCollection?: UsageCollectionStart;
}

View file

@ -28,6 +28,10 @@
"@kbn/fields-metadata-plugin",
"@kbn/esql-validation-autocomplete",
"@kbn/esql-utils",
"@kbn/content-management-favorites-public",
"@kbn/usage-collection-plugin",
"@kbn/content-management-favorites-common",
"@kbn/kibana-utils-plugin",
"@kbn/shared-ux-table-persist",
],
"exclude": [

View file

@ -100,7 +100,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"event_loop_delays_daily": "01b967e8e043801357503de09199dfa3853bab88",
"exception-list": "4aebc4e61fb5d608cae48eaeb0977e8db21c61a4",
"exception-list-agnostic": "6d3262d58eee28ac381ec9654f93126a58be6f5d",
"favorites": "a68c7c8ae22eaddcca324d8b3bfc80a94e3eec3a",
"favorites": "e9773d802932ea85547b120e0efdd9a4f11ff4c6",
"file": "6b65ae5899b60ebe08656fd163ea532e557d3c98",
"file-upload-usage-collection-telemetry": "06e0a8c04f991e744e09d03ab2bd7f86b2088200",
"fileShare": "5be52de1747d249a221b5241af2838264e19aaa1",

View file

@ -91,7 +91,7 @@ describe('ContentManagementPlugin', () => {
const { plugin, coreSetup, pluginsSetup } = setup();
const api = plugin.setup(coreSetup, pluginsSetup);
expect(Object.keys(api).sort()).toEqual(['crud', 'eventBus', 'register']);
expect(Object.keys(api).sort()).toEqual(['crud', 'eventBus', 'favorites', 'register']);
expect(api.crud('')).toBe('mockedCrud');
expect(api.register({} as any)).toBe('mockedRegister');
expect(api.eventBus.emit({} as any)).toBe('mockedEventBusEmit');

View file

@ -76,10 +76,15 @@ export class ContentManagementPlugin
contentRegistry,
});
registerFavorites({ core, logger: this.logger, usageCollection: plugins.usageCollection });
const favoritesSetup = registerFavorites({
core,
logger: this.logger,
usageCollection: plugins.usageCollection,
});
return {
...coreApi,
favorites: favoritesSetup,
};
}

View file

@ -9,6 +9,7 @@
import type { Version } from '@kbn/object-versioning';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { FavoritesSetup } from '@kbn/content-management-favorites-server';
import type { CoreApi, StorageContextGetTransformFn } from './core';
export interface ContentManagementServerSetupDependencies {
@ -18,8 +19,9 @@ export interface ContentManagementServerSetupDependencies {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ContentManagementServerStartDependencies {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ContentManagementServerSetup extends CoreApi {}
export interface ContentManagementServerSetup extends CoreApi {
favorites: FavoritesSetup;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ContentManagementServerStart {}

View file

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

View file

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

View file

@ -11,8 +11,10 @@ import { BehaviorSubject } from 'rxjs';
import type { CoreStart } from '@kbn/core/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types';
import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
export let core: CoreStart;
@ -20,8 +22,10 @@ interface ServiceDeps {
core: CoreStart;
dataViews: DataViewsPublicPluginStart;
expressions: ExpressionsStart;
storage: Storage;
indexManagementApiService?: IndexManagementPluginSetup['apiService'];
fieldsMetadata?: FieldsMetadataPublicStart;
usageCollection?: UsageCollectionStart;
}
const servicesReady$ = new BehaviorSubject<ServiceDeps | undefined>(undefined);
@ -41,15 +45,19 @@ export const setKibanaServices = (
kibanaCore: CoreStart,
dataViews: DataViewsPublicPluginStart,
expressions: ExpressionsStart,
storage: Storage,
indexManagement?: IndexManagementPluginSetup,
fieldsMetadata?: FieldsMetadataPublicStart
fieldsMetadata?: FieldsMetadataPublicStart,
usageCollection?: UsageCollectionStart
) => {
core = kibanaCore;
servicesReady$.next({
core,
dataViews,
expressions,
storage,
indexManagementApiService: indexManagement?.apiService,
fieldsMetadata,
usageCollection,
});
};

View file

@ -14,6 +14,8 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types';
import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import {
updateESQLQueryTrigger,
UpdateESQLQueryAction,
@ -27,6 +29,7 @@ interface EsqlPluginStart {
uiActions: UiActionsStart;
data: DataPublicPluginStart;
fieldsMetadata: FieldsMetadataPublicStart;
usageCollection?: UsageCollectionStart;
}
interface EsqlPluginSetup {
@ -47,11 +50,20 @@ export class EsqlPlugin implements Plugin<{}, void> {
public start(
core: CoreStart,
{ dataViews, expressions, data, uiActions, fieldsMetadata }: EsqlPluginStart
{ dataViews, expressions, data, uiActions, fieldsMetadata, usageCollection }: EsqlPluginStart
): void {
const storage = new Storage(localStorage);
const appendESQLAction = new UpdateESQLQueryAction(data);
uiActions.addTriggerAction(UPDATE_ESQL_QUERY_TRIGGER, appendESQLAction);
setKibanaServices(core, dataViews, expressions, this.indexManagement, fieldsMetadata);
setKibanaServices(
core,
dataViews,
expressions,
storage,
this.indexManagement,
fieldsMetadata,
usageCollection
);
}
public stop() {}

View file

@ -8,11 +8,21 @@
*/
import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ContentManagementServerSetup } from '@kbn/content-management-plugin/server';
import { getUiSettings } from './ui_settings';
export class EsqlServerPlugin implements Plugin {
public setup(core: CoreSetup) {
public setup(core: CoreSetup, plugins: { contentManagement: ContentManagementServerSetup }) {
core.uiSettings.register(getUiSettings());
plugins.contentManagement.favorites.registerFavoriteType('esql_query', {
typeMetadataSchema: schema.object({
queryString: schema.string(),
createdAt: schema.string(),
status: schema.string(),
}),
});
return {};
}

View file

@ -22,7 +22,10 @@
"@kbn/ui-actions-plugin",
"@kbn/data-plugin",
"@kbn/es-query",
"@kbn/fields-metadata-plugin"
"@kbn/fields-metadata-plugin",
"@kbn/usage-collection-plugin",
"@kbn/content-management-plugin",
"@kbn/kibana-utils-plugin",
],
"exclude": [
"target/**/*",

View file

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

View file

@ -8,6 +8,7 @@
*/
import expect from '@kbn/expect';
import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services';
import { FtrService } from '../ftr_provider_context';
export class ESQLService extends FtrService {
@ -20,9 +21,28 @@ export class ESQLService extends FtrService {
expect(await codeEditor.getAttribute('innerText')).to.contain(statement);
}
public async isQueryPresentInTable(query: string, items: string[][]) {
const queryAdded = items.some((item) => {
return item[2] === query;
});
expect(queryAdded).to.be(true);
}
public async getHistoryItems(): Promise<string[][]> {
const queryHistory = await this.testSubjects.find('ESQLEditor-queryHistory');
const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody'));
const tableItems = await this.getStarredHistoryTableItems(queryHistory);
return tableItems;
}
public async getStarredItems(): Promise<string[][]> {
const starredQueries = await this.testSubjects.find('ESQLEditor-starredQueries');
const tableItems = await this.getStarredHistoryTableItems(starredQueries);
return tableItems;
}
private async getStarredHistoryTableItems(element: WebElementWrapper): Promise<string[][]> {
const tableBody = await this.retry.try(async () => element.findByTagName('tbody'));
const $ = await tableBody.parseDomContent();
return $('tr')
.toArray()
@ -44,6 +64,20 @@ export class ESQLService extends FtrService {
});
}
public async getStarredItem(rowIndex = 0) {
const queryHistory = await this.testSubjects.find('ESQLEditor-starredQueries');
const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody'));
const rows = await this.retry.try(async () => tableBody.findAllByTagName('tr'));
return rows[rowIndex];
}
public async clickStarredItem(rowIndex = 0) {
const row = await this.getStarredItem(rowIndex);
const toggle = await row.findByTestSubject('ESQLEditor-history-starred-queries-run-button');
await toggle.click();
}
public async getHistoryItem(rowIndex = 0) {
const queryHistory = await this.testSubjects.find('ESQLEditor-queryHistory');
const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody'));
@ -54,7 +88,7 @@ export class ESQLService extends FtrService {
public async clickHistoryItem(rowIndex = 0) {
const row = await this.getHistoryItem(rowIndex);
const toggle = await row.findByTestSubject('ESQLEditor-queryHistory-runQuery-button');
const toggle = await row.findByTestSubject('ESQLEditor-history-starred-queries-run-button');
await toggle.click();
}

View file

@ -206,6 +206,8 @@
"@kbn/content-management-content-insights-server/*": ["packages/content-management/content_insights/content_insights_server/*"],
"@kbn/content-management-examples-plugin": ["examples/content_management_examples"],
"@kbn/content-management-examples-plugin/*": ["examples/content_management_examples/*"],
"@kbn/content-management-favorites-common": ["packages/content-management/favorites/favorites_common"],
"@kbn/content-management-favorites-common/*": ["packages/content-management/favorites/favorites_common/*"],
"@kbn/content-management-favorites-public": ["packages/content-management/favorites/favorites_public"],
"@kbn/content-management-favorites-public/*": ["packages/content-management/favorites/favorites_public/*"],
"@kbn/content-management-favorites-server": ["packages/content-management/favorites/favorites_server"],

View file

@ -8,9 +8,32 @@ import { setKibanaServices } from '@kbn/esql/public/kibana_services';
import { coreMock } from '@kbn/core/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
class LocalStorageMock {
public store: Record<string, unknown>;
constructor(defaultStore: Record<string, unknown>) {
this.store = defaultStore;
}
clear() {
this.store = {};
}
get(key: string) {
return this.store[key] || null;
}
set(key: string, value: unknown) {
this.store[key] = String(value);
}
remove(key: string) {
delete this.store[key];
}
}
const storage = new LocalStorageMock({}) as unknown as Storage;
setKibanaServices(
coreMock.createStart(),
dataViewPluginMocks.createStartContract(),
expressionsPluginMock.createStartContract()
expressionsPluginMock.createStartContract(),
storage
);

View file

@ -3154,8 +3154,8 @@
"esqlEditor.query.lineNumber": "Ligne {lineNumber}",
"esqlEditor.query.querieshistory.error": "La requête a échouée",
"esqlEditor.query.querieshistory.success": "La requête a été exécuté avec succès",
"esqlEditor.query.querieshistoryCopy": "Copier la requête dans le presse-papier",
"esqlEditor.query.querieshistoryRun": "Exécuter la requête",
"esqlEditor.query.esqlQueriesCopy": "Copier la requête dans le presse-papier",
"esqlEditor.query.esqlQueriesListRun": "Exécuter la requête",
"esqlEditor.query.querieshistoryTable": "Tableau d'historique des recherches",
"esqlEditor.query.recentQueriesColumnLabel": "Recherches récentes",
"esqlEditor.query.refreshLabel": "Actualiser",

View file

@ -3148,8 +3148,8 @@
"esqlEditor.query.lineNumber": "行{lineNumber}",
"esqlEditor.query.querieshistory.error": "クエリ失敗",
"esqlEditor.query.querieshistory.success": "クエリは正常に実行されました",
"esqlEditor.query.querieshistoryCopy": "クエリをクリップボードにコピー",
"esqlEditor.query.querieshistoryRun": "クエリーを実行",
"esqlEditor.query.esqlQueriesCopy": "クエリをクリップボードにコピー",
"esqlEditor.query.esqlQueriesListRun": "クエリーを実行",
"esqlEditor.query.querieshistoryTable": "クエリ履歴テーブル",
"esqlEditor.query.recentQueriesColumnLabel": "最近のクエリー",
"esqlEditor.query.refreshLabel": "更新",

View file

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

View file

@ -59,135 +59,293 @@ export default function ({ getService }: FtrProviderContext) {
await cleanupInteractiveUser({ getService });
});
const api = {
favorite: ({
dashboardId,
user,
space,
}: {
dashboardId: string;
user: LoginAsInteractiveUserResponse;
space?: string;
}) => {
return supertest
.post(
`${
space ? `/s/${space}` : ''
}/internal/content_management/favorites/dashboard/${dashboardId}/favorite`
)
.set(user.headers)
.set('kbn-xsrf', 'true')
.expect(200);
},
unfavorite: ({
dashboardId,
user,
space,
}: {
dashboardId: string;
user: LoginAsInteractiveUserResponse;
space?: string;
}) => {
return supertest
.post(
`${
space ? `/s/${space}` : ''
}/internal/content_management/favorites/dashboard/${dashboardId}/unfavorite`
)
.set(user.headers)
.set('kbn-xsrf', 'true')
.expect(200);
},
list: ({ user, space }: { user: LoginAsInteractiveUserResponse; space?: string }) => {
return supertest
.get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/dashboard`)
.set(user.headers)
.set('kbn-xsrf', 'true')
.expect(200);
},
};
it('can favorite a dashboard', async () => {
let response = await api.list({ user: interactiveUser });
expect(response.body.favoriteIds).to.eql([]);
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav1']);
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav1']);
response = await api.favorite({ dashboardId: 'fav2', user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']);
response = await api.unfavorite({ dashboardId: 'fav1', user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav2']);
response = await api.unfavorite({ dashboardId: 'fav3', user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav2']);
response = await api.list({ user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav2']);
// check that the favorites aren't shared between users
const interactiveUser2 = await loginAsInteractiveUser({
getService,
username: 'content_manager_dashboard_2',
});
response = await api.list({ user: interactiveUser2 });
expect(response.body.favoriteIds).to.eql([]);
// check that the favorites aren't shared between spaces
response = await api.list({ user: interactiveUser, space: 'custom' });
expect(response.body.favoriteIds).to.eql([]);
response = await api.favorite({
dashboardId: 'fav1',
user: interactiveUser,
space: 'custom',
});
expect(response.body.favoriteIds).to.eql(['fav1']);
response = await api.list({ user: interactiveUser, space: 'custom' });
expect(response.body.favoriteIds).to.eql(['fav1']);
response = await api.list({ user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav2']);
// check that reader user can favorite
const interactiveUser3 = await loginAsInteractiveUser({
getService,
username: 'content_reader_dashboard_2',
});
response = await api.list({ user: interactiveUser3 });
expect(response.body.favoriteIds).to.eql([]);
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser3 });
expect(response.body.favoriteIds).to.eql(['fav1']);
it('fails to favorite type is invalid', async () => {
await supertest
.post(`/internal/content_management/favorites/invalid/fav1/favorite`)
.set(interactiveUser.headers)
.set('kbn-xsrf', 'true')
.expect(400);
});
// depends on the state from previous test
it('reports favorites stats', async () => {
const { body }: { body: UnencryptedTelemetryPayload } = await getService('supertest')
.post('/internal/telemetry/clusters/_stats')
.set('kbn-xsrf', 'xxx')
.set(ELASTIC_HTTP_VERSION_HEADER, '2')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.send({ unencrypted: true, refreshCache: true })
.expect(200);
// @ts-ignore
const favoritesStats = body[0].stats.stack_stats.kibana.plugins.favorites;
expect(favoritesStats).to.eql({
dashboard: {
total: 3,
total_users_spaces: 3,
avg_per_user_per_space: 1,
max_per_user_per_space: 1,
describe('dashboard', () => {
const api = {
favorite: ({
dashboardId,
user,
space,
}: {
dashboardId: string;
user: LoginAsInteractiveUserResponse;
space?: string;
}) => {
return supertest
.post(
`${
space ? `/s/${space}` : ''
}/internal/content_management/favorites/dashboard/${dashboardId}/favorite`
)
.set(user.headers)
.set('kbn-xsrf', 'true')
.expect(200);
},
unfavorite: ({
dashboardId,
user,
space,
}: {
dashboardId: string;
user: LoginAsInteractiveUserResponse;
space?: string;
}) => {
return supertest
.post(
`${
space ? `/s/${space}` : ''
}/internal/content_management/favorites/dashboard/${dashboardId}/unfavorite`
)
.set(user.headers)
.set('kbn-xsrf', 'true')
.expect(200);
},
list: ({
user,
space,
}: {
user: LoginAsInteractiveUserResponse;
space?: string;
favoriteType?: string;
}) => {
return supertest
.get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/dashboard`)
.set(user.headers)
.set('kbn-xsrf', 'true')
.expect(200);
},
};
it('can favorite a dashboard', async () => {
let response = await api.list({ user: interactiveUser });
expect(response.body.favoriteIds).to.eql([]);
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav1']);
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav1']);
response = await api.favorite({ dashboardId: 'fav2', user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']);
response = await api.unfavorite({ dashboardId: 'fav1', user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav2']);
response = await api.unfavorite({ dashboardId: 'fav3', user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav2']);
response = await api.list({ user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav2']);
// check that the favorites aren't shared between users
const interactiveUser2 = await loginAsInteractiveUser({
getService,
username: 'content_manager_dashboard_2',
});
response = await api.list({ user: interactiveUser2 });
expect(response.body.favoriteIds).to.eql([]);
// check that the favorites aren't shared between spaces
response = await api.list({ user: interactiveUser, space: 'custom' });
expect(response.body.favoriteIds).to.eql([]);
response = await api.favorite({
dashboardId: 'fav1',
user: interactiveUser,
space: 'custom',
});
expect(response.body.favoriteIds).to.eql(['fav1']);
response = await api.list({ user: interactiveUser, space: 'custom' });
expect(response.body.favoriteIds).to.eql(['fav1']);
response = await api.list({ user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav2']);
// check that reader user can favorite
const interactiveUser3 = await loginAsInteractiveUser({
getService,
username: 'content_reader_dashboard_2',
});
response = await api.list({ user: interactiveUser3 });
expect(response.body.favoriteIds).to.eql([]);
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser3 });
expect(response.body.favoriteIds).to.eql(['fav1']);
});
it("fails to favorite if metadata is provided for type that doesn't support it", async () => {
await supertest
.post(`/internal/content_management/favorites/dashboard/fav1/favorite`)
.set(interactiveUser.headers)
.set('kbn-xsrf', 'true')
.send({ metadata: { foo: 'bar' } })
.expect(400);
await supertest
.post(`/internal/content_management/favorites/dashboard/fav1/favorite`)
.set(interactiveUser.headers)
.set('kbn-xsrf', 'true')
.send({ metadata: {} })
.expect(400);
});
// depends on the state from previous test
it('reports favorites stats', async () => {
const { body }: { body: UnencryptedTelemetryPayload } = await getService('supertest')
.post('/internal/telemetry/clusters/_stats')
.set('kbn-xsrf', 'xxx')
.set(ELASTIC_HTTP_VERSION_HEADER, '2')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.send({ unencrypted: true, refreshCache: true })
.expect(200);
// @ts-ignore
const favoritesStats = body[0].stats.stack_stats.kibana.plugins.favorites;
expect(favoritesStats).to.eql({
dashboard: {
total: 3,
total_users_spaces: 3,
avg_per_user_per_space: 1,
max_per_user_per_space: 1,
},
});
});
});
describe('esql_query', () => {
const type = 'esql_query';
const metadata1 = {
queryString: 'SELECT * FROM test1',
createdAt: '2021-09-01T00:00:00Z',
status: 'success',
};
const metadata2 = {
queryString: 'SELECT * FROM test2',
createdAt: '2023-09-01T00:00:00Z',
status: 'success',
};
const api = {
favorite: ({
queryId,
metadata,
user,
}: {
queryId: string;
metadata: object;
user: LoginAsInteractiveUserResponse;
}) => {
return supertest
.post(`/internal/content_management/favorites/${type}/${queryId}/favorite`)
.set(user.headers)
.set('kbn-xsrf', 'true')
.send({ metadata });
},
unfavorite: ({
queryId,
user,
}: {
queryId: string;
user: LoginAsInteractiveUserResponse;
}) => {
return supertest
.post(`/internal/content_management/favorites/${type}/${queryId}/unfavorite`)
.set(user.headers)
.set('kbn-xsrf', 'true')
.expect(200);
},
list: ({
user,
space,
}: {
user: LoginAsInteractiveUserResponse;
space?: string;
favoriteType?: string;
}) => {
return supertest
.get(`${space ? `/s/${space}` : ''}/internal/content_management/favorites/${type}`)
.set(user.headers)
.set('kbn-xsrf', 'true')
.expect(200);
},
};
it('fails to favorite if metadata is not valid', async () => {
await api
.favorite({
queryId: 'fav1',
metadata: { foo: 'bar' },
user: interactiveUser,
})
.expect(400);
await api
.favorite({
queryId: 'fav1',
metadata: {},
user: interactiveUser,
})
.expect(400);
});
it('can favorite a query', async () => {
let response = await api.list({ user: interactiveUser });
expect(response.body.favoriteIds).to.eql([]);
response = await api.favorite({
queryId: 'fav1',
user: interactiveUser,
metadata: metadata1,
});
expect(response.body.favoriteIds).to.eql(['fav1']);
response = await api.list({ user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav1']);
expect(response.body.favoriteMetadata).to.eql({ fav1: metadata1 });
response = await api.favorite({
queryId: 'fav2',
user: interactiveUser,
metadata: metadata2,
});
expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']);
response = await api.list({ user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav1', 'fav2']);
expect(response.body.favoriteMetadata).to.eql({
fav1: metadata1,
fav2: metadata2,
});
response = await api.unfavorite({ queryId: 'fav1', user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav2']);
response = await api.list({ user: interactiveUser });
expect(response.body.favoriteIds).to.eql(['fav2']);
expect(response.body.favoriteMetadata).to.eql({
fav2: metadata2,
});
response = await api.unfavorite({ queryId: 'fav2', user: interactiveUser });
expect(response.body.favoriteIds).to.eql([]);
response = await api.list({ user: interactiveUser });
expect(response.body.favoriteIds).to.eql([]);
expect(response.body.favoriteMetadata).to.eql({});
});
});
});

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const monacoEditor = getService('monacoEditor');
const { common, discover, header, unifiedFieldList, security } = getPageObjects([
'common',
'discover',
'header',
'unifiedFieldList',
'security',
]);
const testSubjects = getService('testSubjects');
const esql = getService('esql');
const securityService = getService('security');
const browser = getService('browser');
const user = 'discover_read_user';
const role = 'discover_read_role';
describe('Discover ES|QL starred queries', () => {
before('initialize tests', async () => {
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional');
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json'
);
await security.forceLogout();
await securityService.role.create(role, {
elasticsearch: {
indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }],
},
kibana: [
{
feature: {
discover: ['read'],
},
spaces: ['*'],
},
],
});
await securityService.user.create(user, {
password: 'changeme',
roles: [role],
full_name: user,
});
await security.login(user, 'changeme', {
expectSpaceSelector: false,
});
});
after('clean up archives', async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional');
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json'
);
await security.forceLogout();
await securityService.user.delete(user);
await securityService.role.delete(role);
});
it('should star a query from the editor query history', async () => {
await common.navigateToApp('discover');
await discover.selectTextBaseLang();
await header.waitUntilLoadingHasFinished();
await discover.waitUntilSearchingHasFinished();
await unifiedFieldList.waitUntilSidebarHasLoaded();
await testSubjects.click('ESQLEditor-toggle-query-history-button');
const historyItem = await esql.getHistoryItem(0);
await testSubjects.moveMouseTo('~ESQLFavoriteButton');
const button = await historyItem.findByTestSubject('ESQLFavoriteButton');
await button.click();
await header.waitUntilLoadingHasFinished();
await testSubjects.click('starred-queries-tab');
const starredItems = await esql.getStarredItems();
await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', starredItems);
});
it('should persist the starred query after a browser refresh', async () => {
await browser.refresh();
await header.waitUntilLoadingHasFinished();
await discover.waitUntilSearchingHasFinished();
await unifiedFieldList.waitUntilSidebarHasLoaded();
await testSubjects.click('ESQLEditor-toggle-query-history-button');
await testSubjects.click('starred-queries-tab');
const starredItems = await esql.getStarredItems();
await esql.isQueryPresentInTable('FROM logstash-* | LIMIT 10', starredItems);
});
it('should select a query from the starred and submit it', async () => {
await common.navigateToApp('discover');
await discover.selectTextBaseLang();
await header.waitUntilLoadingHasFinished();
await discover.waitUntilSearchingHasFinished();
await unifiedFieldList.waitUntilSidebarHasLoaded();
await testSubjects.click('ESQLEditor-toggle-query-history-button');
await testSubjects.click('starred-queries-tab');
await esql.clickStarredItem(0);
await header.waitUntilLoadingHasFinished();
const editorValue = await monacoEditor.getCodeEditorValue();
expect(editorValue).to.eql(`FROM logstash-* | LIMIT 10`);
});
it('should delete a query from the starred queries tab', async () => {
await common.navigateToApp('discover');
await discover.selectTextBaseLang();
await header.waitUntilLoadingHasFinished();
await discover.waitUntilSearchingHasFinished();
await unifiedFieldList.waitUntilSidebarHasLoaded();
await testSubjects.click('ESQLEditor-toggle-query-history-button');
await testSubjects.click('starred-queries-tab');
const starredItem = await esql.getStarredItem(0);
const button = await starredItem.findByTestSubject('ESQLFavoriteButton');
await button.click();
await testSubjects.click('esqlEditor-discard-starred-query-discard-btn');
await header.waitUntilLoadingHasFinished();
const starredItems = await esql.getStarredItems();
expect(starredItems[0][0]).to.be('No items found');
});
});
}

View file

@ -20,5 +20,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./value_suggestions'));
loadTestFile(require.resolve('./value_suggestions_non_timebased'));
loadTestFile(require.resolve('./saved_search_embeddable'));
loadTestFile(require.resolve('./esql_starred'));
});
}

View file

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

View file

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