Skip to content

Commit

Permalink
[ES|QL] Starred queries in the editor (#198362)
Browse files Browse the repository at this point in the history
## Summary

close #194165
close elastic/kibana-team#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](#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 <[email protected]>
Co-authored-by: Stratoula Kalafateli <[email protected]>
Co-authored-by: Stratoula Kalafateli <[email protected]>
  • Loading branch information
4 people authored Nov 18, 2024
1 parent 974293f commit 4597237
Show file tree
Hide file tree
Showing 56 changed files with 1,954 additions and 279 deletions.
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @kbn/content-management-favorites-common

Shared client & server code for the favorites packages.
11 changes: 11 additions & 0 deletions packages/content-management/favorites/favorites_common/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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'],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/content-management-favorites-common",
"owner": "@elastic/appex-sharedux"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": []
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
);
},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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`);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,55 @@ 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
* Response for get favorites API
*/
export interface GetFavoritesResponse {
favoriteIds: string[];
favoriteMetadata?: Record<string, object>;
}

export interface AddFavoriteResponse {
favoriteIds: string[];
}

export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; logger: Logger }) {
export interface RemoveFavoriteResponse {
favoriteIds: string[];
}

export function registerFavoritesRoutes({
core,
logger,
favoritesRegistry,
}: {
core: CoreSetup;
logger: Logger;
favoritesRegistry: FavoritesRegistry;
}) {
const typeSchema = schema.string({
validate: (type) => {
if (!favoritesRegistry.hasType(type)) {
return `Unknown favorite type: ${type}`;
}
},
});

const metadataSchema = schema.maybe(
schema.object(
{
// validated later by the registry depending on the type
},
{
unknowns: 'allow',
}
)
);

const router = core.http.createRouter();

const getSavedObjectClient = (coreRequestHandlerContext: CoreRequestHandlerContext) => {
Expand All @@ -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
Expand All @@ -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
}
}
);

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

Expand Down Expand Up @@ -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,
});
}
);
Expand Down
Loading

0 comments on commit 4597237

Please sign in to comment.