Allow to "star" (favorite) a dashboard from the listing table (#189285)

## Summary

close https://github.com/elastic/kibana-team/issues/949

- Allows to "star" (favorite) a dashboard from the listing table 

![Screenshot 2024-07-26 at 15 17
41](https://github.com/user-attachments/assets/18f8e3d6-3c83-4d62-8a70-811b05ecd99b)
![Screenshot 2024-07-26 at 15 17
45](https://github.com/user-attachments/assets/45462395-1db1-4858-a2d8-3f681bb2072b)

- Favorites are isolated per user (user profile id) and per space




### Implementation Details

Please refer to and comment on the README.md 🙏
https://github.com/elastic/kibana/pull/189285/files#diff-307fab4354532049891c828da893b4efcf0df9391b1f3018d8d016a2288c5d4c


### TODO


- Telemetry: I will add telemetry in a separate PR
This commit is contained in:
Anton Dosov 2024-08-13 15:10:18 +02:00 committed by GitHub
parent bec63eca4c
commit b8fc60b30e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 1511 additions and 39 deletions

2
.github/CODEOWNERS vendored
View file

@ -98,6 +98,8 @@ packages/kbn-config-schema @elastic/kibana-core
src/plugins/console @elastic/kibana-management
packages/content-management/content_editor @elastic/appex-sharedux
examples/content_management_examples @elastic/appex-sharedux
packages/content-management/favorites/favorites_public @elastic/appex-sharedux
packages/content-management/favorites/favorites_server @elastic/appex-sharedux
src/plugins/content_management @elastic/appex-sharedux
packages/content-management/tabbed_table_list_view @elastic/appex-sharedux
packages/content-management/table_list_view @elastic/appex-sharedux

View file

@ -220,6 +220,8 @@
"@kbn/console-plugin": "link:src/plugins/console",
"@kbn/content-management-content-editor": "link:packages/content-management/content_editor",
"@kbn/content-management-examples-plugin": "link:examples/content_management_examples",
"@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",
"@kbn/content-management-tabbed-table-list-view": "link:packages/content-management/tabbed_table_list_view",
"@kbn/content-management-table-list-view": "link:packages/content-management/table_list_view",

View file

@ -0,0 +1,76 @@
---
id: sharedUX/Favorites
slug: /shared-ux/favorites
title: Favorites Service
description: A service and a set of components and hooks for implementing content favorites
tags: ['shared-ux', 'component']
date: 2024-07-26
---
## Description
The Favorites service provides a way to add favorites feature to your content. It includes a service for managing the list of favorites and a set of components for displaying and interacting with the list.
- The favorites are isolated per user, per space.
- The service provides an API for adding, removing, and listing favorites.
- The service provides a set of react-query hooks for interacting with the favorites list
- The components include a button for toggling the favorite state of an object
- The service relies on ambiguous object ids to identify the objects being favorite. This allows the service to be used with any type of content, not just saved objects.
## API
```tsx
// client side
import {
FavoritesClient,
FavoritesContextProvider,
useFavorites,
FavoriteButton,
} from '@kbn/content-management-favorites-public';
const favoriteObjectType = 'dashboard';
const favoritesClient = new FavoritesClient('dashboard', {
http: core.http,
});
// wrap your content with the favorites context provider
const myApp = () => {
<FavoritesContextProvider favoritesClient={favoritesClient}>
<App />
</FavoritesContextProvider>;
};
const App = () => {
// get the favorites list
const favoritesQuery = useFavorites();
// display favorite state and toggle button for an object
return <FavoriteButton id={'some-object-id'} />;
};
```
## Implementation Details
Internally the favorites list is backed by a saved object. A saved object of type "favorites" is created for each user (user profile id) and space (space id) and object type (e.g. dashboard) combination when a user for the first time favorites an object. The saved object contains a list of favorite objects of the type.
```
{
"_index": ".kibana_8.16.0_001",
"_id": "spaceid:favorites:object_type:u_profile_id",
"_source": {
"favorites": {
"userId": "u_profile_id",
"type: "dashboard",
"favoriteIds": [
"dashboard_id_1",
"dashboard_id_2",
]
},
"type": "favorites",
"references": [],
"namespace": "spaceid",
}
},
```
The service doesn't track the favorite object itself, only the object id. When the object is deleted, the favorite isn't removed from the list automatically.

View file

@ -0,0 +1,4 @@
# @kbn/content-management-favorites-public
Client-side code for the favorites feature
Meant be used in conjunction with the `@kbn/content-management-favorites-server` package.

View file

@ -0,0 +1,19 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export { type FavoritesClientPublic, FavoritesClient } from './src/favorites_client';
export { FavoritesContextProvider } from './src/favorites_context';
export { useFavorites } from './src/favorites_query';
export {
FavoriteButton,
type FavoriteButtonProps,
cssFavoriteHoverWithinEuiTableRow,
} from './src/components/favorite_button';
export { FavoritesEmptyState } from './src/components/favorites_empty_state';

View file

@ -0,0 +1,13 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/packages/content-management/favorites/favorites_public'],
};

View file

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

View file

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 108 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 117 KiB

View file

@ -0,0 +1,94 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import { EuiButtonIcon, euiCanAnimate, EuiThemeComputed } from '@elastic/eui';
import { css } from '@emotion/react';
import { useFavorites, useRemoveFavorite, useAddFavorite } from '../favorites_query';
export interface FavoriteButtonProps {
id: string;
className?: string;
}
export const FavoriteButton = ({ id, className }: FavoriteButtonProps) => {
const { data } = useFavorites();
const removeFavorite = useRemoveFavorite();
const addFavorite = useAddFavorite();
if (!data) return null;
const isFavorite = data.favoriteIds.includes(id);
if (isFavorite) {
const title = i18n.translate('contentManagement.favorites.unfavoriteButtonLabel', {
defaultMessage: 'Remove from Starred',
});
return (
<EuiButtonIcon
isLoading={removeFavorite.isLoading}
title={title}
aria-label={title}
iconType={'starFilled'}
onClick={() => {
removeFavorite.mutate({ id });
}}
className={classNames(className, 'cm-favorite-button', {
'cm-favorite-button--active': !removeFavorite.isLoading,
})}
data-test-subj="unfavoriteButton"
/>
);
} else {
const title = i18n.translate('contentManagement.favorites.favoriteButtonLabel', {
defaultMessage: 'Add to Starred',
});
return (
<EuiButtonIcon
isLoading={addFavorite.isLoading}
title={title}
aria-label={title}
iconType={'starEmpty'}
onClick={() => {
addFavorite.mutate({ id });
}}
className={classNames(className, 'cm-favorite-button', {
'cm-favorite-button--empty': !addFavorite.isLoading,
})}
data-test-subj="favoriteButton"
/>
);
}
};
/**
* CSS to apply to euiTable to show the favorite button on hover or when active
* @param euiTheme
*/
export const cssFavoriteHoverWithinEuiTableRow = (euiTheme: EuiThemeComputed) => css`
@media (hover: hover) {
.euiTableRow .cm-favorite-button--empty {
visibility: hidden;
opacity: 0;
${euiCanAnimate} {
transition: opacity ${euiTheme.animation.fast} ${euiTheme.animation.resistance};
}
}
.euiTableRow:hover,
.euiTableRow:focus-within {
.cm-favorite-button--empty {
visibility: visible;
opacity: 1;
}
}
}
`;

View file

@ -0,0 +1,88 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { EuiEmptyPrompt, useEuiTheme, EuiImage, EuiMarkdownFormat } from '@elastic/eui';
import { css } from '@emotion/react';
import React from 'react';
import emptyFavoritesDark from './empty_favorites_dark.svg';
import emptyFavoritesLight from './empty_favorites_light.svg';
export const FavoritesEmptyState = ({
emptyStateType = 'noItems',
entityNamePlural = i18n.translate('contentManagement.favorites.defaultEntityNamePlural', {
defaultMessage: 'items',
}),
entityName = i18n.translate('contentManagement.favorites.defaultEntityName', {
defaultMessage: 'item',
}),
}: {
emptyStateType: 'noItems' | 'noMatchingItems';
entityNamePlural?: string;
entityName?: string;
}) => {
const title =
emptyStateType === 'noItems' ? (
<FormattedMessage
id="contentManagement.favorites.noFavoritesMessageHeading"
defaultMessage="You havent starred any {entityNamePlural}"
values={{ entityNamePlural }}
/>
) : (
<FormattedMessage
id="contentManagement.favorites.noMatchingFavoritesMessageHeading"
defaultMessage="No starred {entityNamePlural} match your search"
values={{ entityNamePlural }}
/>
);
return (
<EuiEmptyPrompt
css={css`
.euiEmptyPrompt__icon {
min-inline-size: 25%; /* reduce the min size of the container to fit more title in a single line* /
}
`}
layout="horizontal"
color="transparent"
icon={<NoFavoritesIllustration />}
hasBorder={false}
title={<h2>{title}</h2>}
body={
<EuiMarkdownFormat>
{i18n.translate('contentManagement.favorites.noFavoritesMessageBody', {
defaultMessage:
"Keep track of your most important {entityNamePlural} by adding them to your **Starred** list. Click the **{starIcon}** **star icon** next to a {entityName} name and it'll appear in this tab.",
values: { entityNamePlural, entityName, starIcon: `` },
})}
</EuiMarkdownFormat>
}
/>
);
};
const NoFavoritesIllustration = () => {
const { colorMode } = useEuiTheme();
const src = colorMode === 'DARK' ? emptyFavoritesDark : emptyFavoritesLight;
return (
<EuiImage
style={{
width: 300,
height: 220,
objectFit: 'contain',
}} /* we use fixed width to prevent layout shift */
src={src}
alt={i18n.translate('contentManagement.favorites.noFavoritesIllustrationAlt', {
defaultMessage: 'No starred items illustrations',
})}
/>
);
};

View file

@ -0,0 +1,42 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import type { HttpStart } from '@kbn/core-http-browser';
import type { GetFavoritesResponse } from '@kbn/content-management-favorites-server';
export interface FavoritesClientPublic {
getFavorites(): Promise<GetFavoritesResponse>;
addFavorite({ id }: { id: string }): Promise<GetFavoritesResponse>;
removeFavorite({ id }: { id: string }): Promise<GetFavoritesResponse>;
getFavoriteType(): string;
}
export class FavoritesClient implements FavoritesClientPublic {
constructor(private favoriteObjectType: string, private deps: { http: HttpStart }) {}
public async getFavorites(): Promise<GetFavoritesResponse> {
return this.deps.http.get(`/internal/content_management/favorites/${this.favoriteObjectType}`);
}
public async addFavorite({ id }: { id: string }): Promise<GetFavoritesResponse> {
return this.deps.http.post(
`/internal/content_management/favorites/${this.favoriteObjectType}/${id}/favorite`
);
}
public async removeFavorite({ id }: { id: string }): Promise<GetFavoritesResponse> {
return this.deps.http.post(
`/internal/content_management/favorites/${this.favoriteObjectType}/${id}/unfavorite`
);
}
public getFavoriteType() {
return this.favoriteObjectType;
}
}

View file

@ -0,0 +1,40 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import type { FavoritesClientPublic } from './favorites_client';
interface FavoritesContextValue {
favoritesClient?: FavoritesClientPublic;
notifyError?: (title: JSX.Element, text?: string) => void;
}
const FavoritesContext = React.createContext<FavoritesContextValue | null>(null);
export const FavoritesContextProvider: React.FC<FavoritesContextValue> = ({
favoritesClient,
notifyError,
children,
}) => {
return (
<FavoritesContext.Provider value={{ favoritesClient, notifyError }}>
{children}
</FavoritesContext.Provider>
);
};
export const useFavoritesContext = () => {
const context = React.useContext(FavoritesContext);
return context;
};
export const useFavoritesClient = () => {
const context = useFavoritesContext();
return context?.favoritesClient;
};

View file

@ -0,0 +1,102 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useFavoritesClient, useFavoritesContext } from './favorites_context';
const favoritesKeys = {
all: ['favorites'] as const,
byType: (type: string) => [...favoritesKeys.all, type] as const,
};
export const useFavorites = ({ enabled = true }: { enabled?: boolean } = { enabled: true }) => {
const favoritesClient = useFavoritesClient();
if (!favoritesClient && enabled) {
throw new Error(
`useFavorites: favoritesClient is not available. Make sure you have wrapped your component with FavoritesContextProvider`
);
}
return useQuery(
favoritesKeys.byType(favoritesClient?.getFavoriteType() ?? 'never'),
() => favoritesClient!.getFavorites(),
{ enabled }
);
};
export const useAddFavorite = () => {
const favoritesContext = useFavoritesContext();
if (!favoritesContext) {
throw new Error(
`useAddFavorite: favoritesContext is not available. Make sure you have wrapped your component with FavoritesContextProvider`
);
}
const favoritesClient = favoritesContext.favoritesClient;
const notifyError = favoritesContext.notifyError;
const queryClient = useQueryClient();
return useMutation(
({ id }: { id: string }) => {
return favoritesClient!.addFavorite({ id });
},
{
onSuccess: (data) => {
queryClient.setQueryData(favoritesKeys.byType(favoritesClient!.getFavoriteType()), data);
},
onError: (error: Error) => {
notifyError?.(
<>
{i18n.translate('contentManagement.favorites.addFavoriteError', {
defaultMessage: 'Error adding to Starred',
})}
</>,
error?.message
);
},
}
);
};
export const useRemoveFavorite = () => {
const favoritesContext = useFavoritesContext();
if (!favoritesContext) {
throw new Error(
`useAddFavorite: favoritesContext is not available. Make sure you have wrapped your component with FavoritesContextProvider`
);
}
const favoritesClient = favoritesContext.favoritesClient;
const notifyError = favoritesContext.notifyError;
const queryClient = useQueryClient();
return useMutation(
({ id }: { id: string }) => {
return favoritesClient!.removeFavorite({ id });
},
{
onSuccess: (data) => {
queryClient.setQueryData(favoritesKeys.byType(favoritesClient!.getFavoriteType()), data);
},
onError: (error: Error) => {
notifyError?.(
<>
{i18n.translate('contentManagement.favorites.removeFavoriteError', {
defaultMessage: 'Error removing from Starred',
})}
</>,
error?.message
);
},
}
);
};

View file

@ -0,0 +1,26 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@emotion/react/types/css-prop",
"@kbn/ambient-ui-types"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/i18n",
"@kbn/core-http-browser",
"@kbn/content-management-favorites-server",
"@kbn/i18n-react",
]
}

View file

@ -0,0 +1,4 @@
# @kbn/content-management-favorites-server
Server-side code for the favorites feature.
Meant be used in conjunction with the `@kbn/content-management-favorites-public` package.

View file

@ -0,0 +1,9 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
export { registerFavorites, type GetFavoritesResponse } from './src';

View file

@ -0,0 +1,13 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../..',
roots: ['<rootDir>/packages/content-management/favorites/favorites_server'],
};

View file

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

View file

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

View file

@ -0,0 +1,146 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import {
CoreRequestHandlerContext,
CoreSetup,
Logger,
SECURITY_EXTENSION_ID,
} from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { FavoritesService } 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')]);
/**
* @public
* Response for get favorites API
*/
export interface GetFavoritesResponse {
favoriteIds: string[];
}
export function registerFavoritesRoutes({ core, logger }: { core: CoreSetup; logger: Logger }) {
const router = core.http.createRouter();
const getSavedObjectClient = (coreRequestHandlerContext: CoreRequestHandlerContext) => {
// We need to exclude security extension to access the `favorite` type which not every user has access to
// and give access only to the current user's favorites through this API
return coreRequestHandlerContext.savedObjects.getClient({
includedHiddenTypes: [favoritesSavedObjectType.name],
excludedExtensions: [SECURITY_EXTENSION_ID],
});
};
router.post(
{
path: '/internal/content_management/favorites/{type}/{id}/favorite',
validate: {
params: schema.object({
id: schema.string(),
type: typeSchema,
}),
},
// we don't protect the route with any access tags as
// we only give access to the current user's favorites ids
},
async (requestHandlerContext, request, response) => {
const coreRequestHandlerContext = await requestHandlerContext.core;
const userId = coreRequestHandlerContext.security.authc.getCurrentUser()?.profile_uid;
if (!userId) {
return response.forbidden();
}
const type = request.params.type;
const favorites = new FavoritesService(type, userId, {
savedObjectClient: getSavedObjectClient(coreRequestHandlerContext),
logger,
});
const favoriteIds: GetFavoritesResponse = await favorites.addFavorite({
id: request.params.id,
});
return response.ok({ body: favoriteIds });
}
);
router.post(
{
path: '/internal/content_management/favorites/{type}/{id}/unfavorite',
validate: {
params: schema.object({
id: schema.string(),
type: typeSchema,
}),
},
// we don't protect the route with any access tags as
// we only give access to the current user's favorites ids
},
async (requestHandlerContext, request, response) => {
const coreRequestHandlerContext = await requestHandlerContext.core;
const userId = coreRequestHandlerContext.security.authc.getCurrentUser()?.profile_uid;
if (!userId) {
return response.forbidden();
}
const type = request.params.type;
const favorites = new FavoritesService(type, userId, {
savedObjectClient: getSavedObjectClient(coreRequestHandlerContext),
logger,
});
const favoriteIds: GetFavoritesResponse = await favorites.removeFavorite({
id: request.params.id,
});
return response.ok({ body: favoriteIds });
}
);
router.get(
{
path: '/internal/content_management/favorites/{type}',
validate: {
params: schema.object({
type: typeSchema,
}),
},
// we don't protect the route with any access tags as
// we only give access to the current user's favorites ids
},
async (requestHandlerContext, request, response) => {
const coreRequestHandlerContext = await requestHandlerContext.core;
const userId = coreRequestHandlerContext.security.authc.getCurrentUser()?.profile_uid;
if (!userId) {
return response.forbidden();
}
const type = request.params.type;
const favorites = new FavoritesService(type, userId, {
savedObjectClient: getSavedObjectClient(coreRequestHandlerContext),
logger,
});
const getFavoritesResponse: GetFavoritesResponse = await favorites.getFavorites();
return response.ok({
body: getFavoritesResponse,
});
}
);
}

View file

@ -0,0 +1,45 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import type { SavedObjectsType } from '@kbn/core/server';
export interface FavoritesSavedObjectAttributes {
userId: string;
type: string;
favoriteIds: string[];
}
const schemaV1 = schema.object({
userId: schema.string(),
type: schema.string(), // object type, e.g. dashboard
favoriteIds: schema.arrayOf(schema.string()),
});
export const favoritesSavedObjectType: SavedObjectsType = {
name: 'favorites',
hidden: true,
namespaceType: 'single',
mappings: {
dynamic: false,
properties: {},
},
modelVersions: {
1: {
changes: [],
schemas: {
// The forward compatible schema should allow any future versions of
// this SO to be converted to this version, since we are using
// @kbn/config-schema we opt-in to unknowns to allow the schema to
// successfully "downgrade" future SOs to this version.
forwardCompatibility: schemaV1.extends({}, { unknowns: 'ignore' }),
create: schemaV1,
},
},
},
};

View file

@ -0,0 +1,124 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import type { SavedObject, SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { Logger, SavedObjectsErrorHelpers } from '@kbn/core/server';
import { favoritesSavedObjectType, FavoritesSavedObjectAttributes } from './favorites_saved_object';
export class FavoritesService {
constructor(
private readonly type: string,
private readonly userId: string,
private readonly deps: {
savedObjectClient: SavedObjectsClientContract;
logger: Logger;
}
) {
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');
}
}
public async getFavorites(): Promise<{ favoriteIds: string[] }> {
const favoritesSavedObject = await this.getFavoritesSavedObject();
const favoriteIds = favoritesSavedObject?.attributes?.favoriteIds ?? [];
return { favoriteIds };
}
public async addFavorite({ id }: { id: string }): Promise<{ favoriteIds: string[] }> {
let favoritesSavedObject = await this.getFavoritesSavedObject();
if (!favoritesSavedObject) {
favoritesSavedObject = await this.deps.savedObjectClient.create(
favoritesSavedObjectType.name,
{
userId: this.userId,
type: this.type,
favoriteIds: [id],
},
{
id: this.getFavoriteSavedObjectId(),
}
);
return { favoriteIds: favoritesSavedObject.attributes.favoriteIds };
} else {
const newFavoriteIds = [
...(favoritesSavedObject.attributes.favoriteIds ?? []).filter(
(favoriteId) => favoriteId !== id
),
id,
];
await this.deps.savedObjectClient.update(
favoritesSavedObjectType.name,
favoritesSavedObject.id,
{
favoriteIds: newFavoriteIds,
},
{
version: favoritesSavedObject.version,
}
);
return { favoriteIds: newFavoriteIds };
}
}
public async removeFavorite({ id }: { id: string }): Promise<{ favoriteIds: string[] }> {
const favoritesSavedObject = await this.getFavoritesSavedObject();
if (!favoritesSavedObject) {
return { favoriteIds: [] };
}
const newFavoriteIds = (favoritesSavedObject.attributes.favoriteIds ?? []).filter(
(favoriteId) => favoriteId !== id
);
await this.deps.savedObjectClient.update(
favoritesSavedObjectType.name,
favoritesSavedObject.id,
{
favoriteIds: newFavoriteIds,
},
{
version: favoritesSavedObject.version,
}
);
return {
favoriteIds: newFavoriteIds,
};
}
private async getFavoritesSavedObject(): Promise<SavedObject<FavoritesSavedObjectAttributes> | null> {
try {
const favoritesSavedObject =
await this.deps.savedObjectClient.get<FavoritesSavedObjectAttributes>(
favoritesSavedObjectType.name,
this.getFavoriteSavedObjectId()
);
return favoritesSavedObject;
} catch (e) {
if (SavedObjectsErrorHelpers.isNotFoundError(e)) {
return null;
}
throw e;
}
}
private getFavoriteSavedObjectId() {
return `${this.type}:${this.userId}`;
}
}

View file

@ -0,0 +1,25 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import type { CoreSetup, Logger } from '@kbn/core/server';
import { registerFavoritesRoutes } from './favorites_routes';
import { favoritesSavedObjectType } from './favorites_saved_object';
export type { GetFavoritesResponse } from './favorites_routes';
/**
* @public
* Registers the favorites feature enabling favorites saved object type and api routes.
*
* @param logger
* @param core
*/
export function registerFavorites({ logger, core }: { core: CoreSetup; logger: Logger }) {
core.savedObjects.registerType(favoritesSavedObjectType);
registerFavoritesRoutes({ core, logger });
}

View file

@ -0,0 +1,21 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/config-schema",
"@kbn/core-saved-objects-api-server",
]
}

View file

@ -27,6 +27,7 @@ export const getMockServices = (overrides?: Partial<Services & UserProfilesServi
getTagManagementUrl: () => '',
getTagIdsFromReferences: () => [],
isTaggingEnabled: () => true,
isFavoritesEnabled: () => false,
bulkGetUserProfiles: async () => [],
getUserProfile: async () => ({ uid: '', enabled: true, data: {}, user: { username: '' } }),
...overrides,

View file

@ -7,9 +7,11 @@
*/
import React, { useCallback, useMemo } from 'react';
import { EuiText, EuiLink, EuiSpacer, EuiHighlight } from '@elastic/eui';
import { EuiText, EuiLink, EuiSpacer, EuiHighlight, useEuiTheme } from '@elastic/eui';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { FavoriteButton } from '@kbn/content-management-favorites-public';
import { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common';
import { css } from '@emotion/react';
import type { Tag } from '../types';
import { useServices } from '../services';
@ -25,6 +27,7 @@ interface Props<T extends UserContentCommonSchema> extends InheritedProps<T> {
item: T;
searchTerm?: string;
onClickTag: (tag: Tag, isCtrlKey: boolean) => void;
isFavoritesEnabled?: boolean;
}
/**
@ -41,7 +44,9 @@ export function ItemDetails<T extends UserContentCommonSchema>({
getDetailViewLink,
getOnClickTitle,
onClickTag,
isFavoritesEnabled,
}: Props<T>) {
const { euiTheme } = useEuiTheme();
const {
references,
attributes: { title, description },
@ -90,9 +95,19 @@ export function ItemDetails<T extends UserContentCommonSchema>({
{title}
</EuiHighlight>
</EuiLink>
{isFavoritesEnabled && (
<FavoriteButton
id={item.id}
css={css`
margin-top: -${euiTheme.size.xs}; // trying to nicer align the star with the title
margin-left: ${euiTheme.size.xxs};
`}
/>
)}
</RedirectAppLinks>
);
}, [
euiTheme,
getDetailViewLink,
getOnClickTitle,
id,
@ -101,6 +116,7 @@ export function ItemDetails<T extends UserContentCommonSchema>({
redirectAppLinksCoreStart,
searchTerm,
title,
isFavoritesEnabled,
]);
const hasTags = itemHasTags(references);

View file

@ -0,0 +1,52 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiTab, EuiTabs, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
interface TabbedTableFilterProps {
onSelectedTabChanged: (tabId: 'all' | 'favorite') => void;
selectedTabId: 'all' | 'favorite';
}
export const TabbedTableFilter = (props: TabbedTableFilterProps) => {
return (
<>
<EuiTabs
data-test-subj="tabbedTableFilter"
style={{
marginTop:
'-8px' /* TODO: the default EuiTable betweenChildren spacing is too big, needs eui change */,
}}
>
<EuiTab
onClick={() => props.onSelectedTabChanged('all')}
isSelected={props.selectedTabId === 'all'}
data-test-subj="allTab"
>
<FormattedMessage
id="contentManagement.tableList.tabsFilter.allTabLabel"
defaultMessage="All"
/>
</EuiTab>
<EuiTab
onClick={() => props.onSelectedTabChanged('favorite')}
isSelected={props.selectedTabId === 'favorite'}
data-test-subj="favoriteTab"
>
<FormattedMessage
id="contentManagement.tableList.tabsFilter.favoriteTabLabel"
defaultMessage="Starred"
/>
</EuiTab>
</EuiTabs>
<EuiSpacer size={'s'} />
</>
);
};

View file

@ -18,9 +18,15 @@ import {
Query,
Search,
type EuiTableSelectionType,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common';
import {
cssFavoriteHoverWithinEuiTableRow,
useFavorites,
FavoritesEmptyState,
} from '@kbn/content-management-favorites-public';
import { useServices } from '../services';
import type { Action } from '../actions';
@ -39,6 +45,7 @@ import {
UserFilterContextProvider,
NULL_USER as USER_FILTER_NULL_USER,
} from './user_filter_panel';
import { TabbedTableFilter } from './tabbed_filter';
type State<T extends UserContentCommonSchema> = Pick<
TableListViewState<T>,
@ -68,6 +75,7 @@ interface Props<T extends UserContentCommonSchema> extends State<T>, TagManageme
onTableSearchChange: (arg: { query: Query | null; queryText: string }) => void;
clearTagSelection: () => void;
createdByEnabled: boolean;
favoritesEnabled: boolean;
}
export function Table<T extends UserContentCommonSchema>({
@ -97,7 +105,9 @@ export function Table<T extends UserContentCommonSchema>({
addOrRemoveIncludeTagFilter,
clearTagSelection,
createdByEnabled,
favoritesEnabled,
}: Props<T>) {
const euiTheme = useEuiTheme();
const { getTagList, isTaggingEnabled } = useServices();
const renderToolsLeft = useCallback(() => {
@ -221,7 +231,15 @@ export function Table<T extends UserContentCommonSchema>({
};
}, [onTableSearchChange, renderCreateButton, renderToolsLeft, searchFilters, searchQuery.query]);
const noItemsMessage = (
const hasQueryOrFilters = Boolean(searchQuery.text || tableFilter.createdBy.length > 0);
const noItemsMessage = tableFilter.favorites ? (
<FavoritesEmptyState
emptyStateType={hasQueryOrFilters ? 'noMatchingItems' : 'noItems'}
entityName={entityName}
entityNamePlural={entityNamePlural}
/>
) : (
<FormattedMessage
id="contentManagement.tableList.listing.noMatchedItemsMessage"
defaultMessage="No {entityNamePlural} matched your search."
@ -229,17 +247,29 @@ export function Table<T extends UserContentCommonSchema>({
/>
);
const { data: favorites, isError: favoritesError } = useFavorites({ enabled: favoritesEnabled });
const visibleItems = React.useMemo(() => {
let filteredItems = items;
if (tableFilter?.createdBy?.length > 0) {
return items.filter((item) => {
filteredItems = items.filter((item) => {
if (item.createdBy) return tableFilter.createdBy.includes(item.createdBy);
else if (item.managed) return false;
else return tableFilter.createdBy.includes(USER_FILTER_NULL_USER);
});
}
return items;
}, [items, tableFilter]);
if (tableFilter?.favorites && !favoritesError) {
if (!favorites) {
filteredItems = [];
} else {
filteredItems = filteredItems.filter((item) => favorites.favoriteIds.includes(item.id));
}
}
return filteredItems;
}, [items, tableFilter, favorites, favoritesError]);
const { allUsers, showNoUserOption } = useMemo(() => {
if (!createdByEnabled) return { allUsers: [], showNoUserOption: false };
@ -262,6 +292,16 @@ export function Table<T extends UserContentCommonSchema>({
? true // by passing "true" we disable the EuiInMemoryTable sorting and handle it ourselves, but sorting is still enabled
: { sort: tableSort };
const favoritesFilter =
favoritesEnabled && !favoritesError ? (
<TabbedTableFilter
selectedTabId={tableFilter.favorites ? 'favorite' : 'all'}
onSelectedTabChanged={(newTab) => {
onFilterChange({ favorites: newTab === 'favorite' });
}}
/>
) : undefined;
return (
<UserFilterContextProvider
enabled={createdByEnabled}
@ -297,6 +337,8 @@ export function Table<T extends UserContentCommonSchema>({
data-test-subj="itemsInMemTable"
rowHeader="attributes.title"
tableCaption={tableCaption}
css={cssFavoriteHoverWithinEuiTableRow(euiTheme.euiTheme)}
childrenBetween={favoritesFilter}
/>
</TagFilterContextProvider>
</UserFilterContextProvider>

View file

@ -72,6 +72,7 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) =>
getTagManagementUrl: () => '',
getTagIdsFromReferences: () => [],
isTaggingEnabled: () => true,
isFavoritesEnabled: () => false,
...params,
};

View file

@ -24,6 +24,10 @@ import type { FormattedRelative } from '@kbn/i18n-react';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { RedirectAppLinksKibanaProvider } from '@kbn/shared-ux-link-redirect-app';
import { UserProfilesKibanaProvider } from '@kbn/content-management-user-profiles';
import {
FavoritesClientPublic,
FavoritesContextProvider,
} from '@kbn/content-management-favorites-public';
import { TAG_MANAGEMENT_APP_URL } from './constants';
import type { Tag } from './types';
@ -63,6 +67,8 @@ export interface Services {
TagList: FC<TagListProps>;
/** Predicate to indicate if tagging features is enabled */
isTaggingEnabled: () => boolean;
/** Predicate to indicate if favorites features is enabled */
isFavoritesEnabled: () => boolean;
/** Predicate function to indicate if some of the saved object references are tags */
itemHasTags: (references: SavedObjectsReference[]) => boolean;
/** Handler to return the url to navigate to the kibana tags management */
@ -163,6 +169,11 @@ export interface TableListViewKibanaDependencies {
};
/** The <FormattedRelative /> component from the @kbn/i18n-react package */
FormattedRelative: typeof FormattedRelative;
/**
* The favorites client to enable the favorites feature.
*/
favorites?: FavoritesClientPublic;
}
/**
@ -229,29 +240,37 @@ export const TableListViewKibanaProvider: FC<
<RedirectAppLinksKibanaProvider coreStart={core}>
<UserProfilesKibanaProvider core={core}>
<ContentEditorKibanaProvider core={core} savedObjectsTagging={savedObjectsTagging}>
<TableListViewProvider
canEditAdvancedSettings={Boolean(application.capabilities.advancedSettings?.save)}
getListingLimitSettingsUrl={() =>
application.getUrlForApp('management', {
path: `/kibana/settings?query=savedObjects:listingLimit`,
})
}
<FavoritesContextProvider
favoritesClient={services.favorites}
notifyError={(title, text) => {
notifications.toasts.addDanger({ title: toMountPoint(title, startServices), text });
}}
searchQueryParser={searchQueryParser}
DateFormatterComp={(props) => <FormattedRelative {...props} />}
currentAppId$={application.currentAppId$}
navigateToUrl={application.navigateToUrl}
isTaggingEnabled={() => Boolean(savedObjectsTagging)}
getTagList={getTagList}
TagList={TagList}
itemHasTags={itemHasTags}
getTagIdsFromReferences={getTagIdsFromReferences}
getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)}
>
{children}
</TableListViewProvider>
<TableListViewProvider
canEditAdvancedSettings={Boolean(application.capabilities.advancedSettings?.save)}
getListingLimitSettingsUrl={() =>
application.getUrlForApp('management', {
path: `/kibana/settings?query=savedObjects:listingLimit`,
})
}
notifyError={(title, text) => {
notifications.toasts.addDanger({ title: toMountPoint(title, startServices), text });
}}
searchQueryParser={searchQueryParser}
DateFormatterComp={(props) => <FormattedRelative {...props} />}
currentAppId$={application.currentAppId$}
navigateToUrl={application.navigateToUrl}
isTaggingEnabled={() => Boolean(savedObjectsTagging)}
isFavoritesEnabled={() => Boolean(services.favorites)}
getTagList={getTagList}
TagList={TagList}
itemHasTags={itemHasTags}
getTagIdsFromReferences={getTagIdsFromReferences}
getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)}
>
{children}
</TableListViewProvider>
</FavoritesContextProvider>
</ContentEditorKibanaProvider>
</UserProfilesKibanaProvider>
</RedirectAppLinksKibanaProvider>

View file

@ -157,6 +157,7 @@ export interface State<T extends UserContentCommonSchema = UserContentCommonSche
sortColumnChanged: boolean;
tableFilter: {
createdBy: string[];
favorites: boolean;
};
}
@ -168,6 +169,7 @@ export interface URLState {
};
filter?: {
createdBy?: string[];
favorites?: boolean;
};
[key: string]: unknown;
@ -179,6 +181,7 @@ interface URLQueryParams {
sort?: string;
sortdir?: string;
created_by?: string[];
favorites?: 'true';
[key: string]: unknown;
}
@ -236,6 +239,12 @@ const urlStateDeserializer = (params: URLQueryParams): URLState => {
stateFromURL.filter = { createdBy: [] };
}
if (sanitizedParams.favorites === 'true') {
stateFromURL.filter.favorites = true;
} else {
stateFromURL.filter.favorites = false;
}
return stateFromURL;
};
@ -248,7 +257,7 @@ const urlStateDeserializer = (params: URLQueryParams): URLState => {
const urlStateSerializer = (updated: {
s?: string;
sort?: { field: 'title' | 'updatedAt'; direction: Direction };
filter?: { createdBy?: string[] };
filter?: { createdBy?: string[]; favorites?: boolean };
}) => {
const updatedQueryParams: Partial<URLQueryParams> = {};
@ -271,6 +280,10 @@ const urlStateSerializer = (updated: {
updatedQueryParams.created_by = updated.filter.createdBy;
}
if (updated?.filter && 'favorites' in updated.filter) {
updatedQueryParams.favorites = updated.filter.favorites ? 'true' : undefined;
}
return updatedQueryParams;
};
@ -354,6 +367,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
notifyError,
DateFormatterComp,
getTagList,
isFavoritesEnabled,
} = useServices();
const openContentEditor = useOpenContentEditor();
@ -400,6 +414,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
sortColumnChanged: !initialSort.isDefault,
tableFilter: {
createdBy: [],
favorites: false,
},
};
}, [initialPageSize, entityName, recentlyAccessed]);
@ -589,6 +604,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
}
}}
searchTerm={searchQuery.text}
isFavoritesEnabled={isFavoritesEnabled()}
/>
);
},
@ -721,6 +737,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
tableItemsRowActions,
inspectItem,
entityName,
isFavoritesEnabled,
]);
const itemsById = useMemo(() => {
@ -1041,6 +1058,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
data: {
filter: {
createdBy: filter.createdBy ?? [],
favorites: filter.favorites ?? false,
},
},
});
@ -1150,6 +1168,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
addOrRemoveExcludeTagFilter={addOrRemoveExcludeTagFilter}
clearTagSelection={clearTagSelection}
createdByEnabled={createdByEnabled}
favoritesEnabled={isFavoritesEnabled()}
/>
{/* Delete modal */}

View file

@ -35,7 +35,8 @@
"@kbn/core-user-profile-browser",
"@kbn/react-kibana-mount",
"@kbn/content-management-user-profiles",
"@kbn/recently-accessed"
"@kbn/recently-accessed",
"@kbn/content-management-favorites-public"
],
"exclude": [
"target/**/*"

View file

@ -436,6 +436,7 @@
"updated_by",
"version"
],
"favorites": [],
"file": [
"FileKind",
"Meta",

View file

@ -1482,6 +1482,10 @@
}
}
},
"favorites": {
"dynamic": false,
"properties": {}
},
"file": {
"dynamic": false,
"properties": {

View file

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

View file

@ -60,6 +60,7 @@ const previouslyRegisteredTypes = [
'event_loop_delays_daily',
'exception-list',
'exception-list-agnostic',
'favorites',
'file',
'fileShare',
'file-upload-telemetry',

View file

@ -96,11 +96,10 @@ describe('ContentManagementPlugin', () => {
});
describe('RPC', () => {
test('should create a single POST HTTP route on the router', () => {
test('should create a rpc POST HTTP route on the router', () => {
const { plugin, coreSetup, router } = setup();
plugin.setup(coreSetup);
expect(router.post).toBeCalledTimes(1);
const [routeConfig]: Parameters<IRouter['post']> = (router.post as jest.Mock).mock.calls[0];
expect(routeConfig.path).toBe('/api/content_management/rpc/{name}');

View file

@ -13,6 +13,7 @@ import type {
PluginInitializerContext,
Logger,
} from '@kbn/core/server';
import { registerFavorites } from '@kbn/content-management-favorites-server';
import { Core } from './core';
import { initRpcRoutes, registerProcedures, RpcService } from './rpc';
import type { Context as RpcContext } from './rpc';
@ -74,6 +75,8 @@ export class ContentManagementPlugin
contentRegistry,
});
registerFavorites({ core, logger: this.logger });
return {
...coreApi,
};

View file

@ -16,6 +16,7 @@
"@kbn/core-saved-objects-api-server",
"@kbn/saved-objects-settings",
"@kbn/core-http-server",
"@kbn/content-management-favorites-server",
],
"exclude": [
"target/**/*",

View file

@ -45,6 +45,7 @@ export const DashboardListing = ({
savedObjectsTagging,
coreContext: { executionContext },
userProfile,
dashboardFavorites,
} = pluginServices.getServices();
useExecutionContext(executionContext, {
@ -84,6 +85,7 @@ export const DashboardListing = ({
},
savedObjectsTagging: savedObjectsTaggingFakePlugin,
FormattedRelative,
favorites: dashboardFavorites,
}}
>
<TableListView<DashboardSavedObjectUserContent> {...tableListViewTableProps}>

View file

@ -228,7 +228,7 @@ export class DashboardPlugin
// We also don't want to store the table list view state.
// The question is: what _do_ we want to save here? :)
const tableListUrlState = ['s', 'title', 'sort', 'sortdir', 'created_by'];
const tableListUrlState = ['s', 'title', 'sort', 'sortdir', 'created_by', 'favorites'];
return replaceUrlHashQuery(newNavLink, (query) => {
[SEARCH_SESSION_ID, ...tableListUrlState].forEach((param) => {
delete query[param];

View file

@ -0,0 +1,18 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { PluginServiceFactory } from '@kbn/presentation-util-plugin/public';
import { FavoritesClient } from '@kbn/content-management-favorites-public';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { DashboardFavoritesService } from './types';
export type DashboardFavoritesServiceFactory = PluginServiceFactory<DashboardFavoritesService>;
export const dashboardFavoritesServiceFactory: DashboardFavoritesServiceFactory = () => {
return new FavoritesClient('dashboard', { http: httpServiceMock.createStartContract() });
};

View file

@ -0,0 +1,23 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { FavoritesClient } from '@kbn/content-management-favorites-public';
import { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public';
import { DashboardStartDependencies } from '../../plugin';
import { DashboardFavoritesService } from './types';
export type DashboardFavoritesServiceFactory = KibanaPluginServiceFactory<
DashboardFavoritesService,
DashboardStartDependencies
>;
export const dashboardFavoritesServiceFactory: DashboardFavoritesServiceFactory = ({
coreStart,
}) => {
return new FavoritesClient('dashboard', { http: coreStart.http });
};

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 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 or the Server
* Side Public License, v 1.
*/
import type { FavoritesClientPublic } from '@kbn/content-management-favorites-public';
export type DashboardFavoritesService = FavoritesClientPublic;

View file

@ -48,6 +48,7 @@ import { observabilityAIAssistantServiceStubFactory } from './observability_ai_a
import { noDataPageServiceFactory } from './no_data_page/no_data_page_service.stub';
import { uiActionsServiceFactory } from './ui_actions/ui_actions_service.stub';
import { dashboardRecentlyAccessedServiceFactory } from './dashboard_recently_accessed/dashboard_recently_accessed.stub';
import { dashboardFavoritesServiceFactory } from './dashboard_favorites/dashboard_favorites_service.stub';
export const providers: PluginServiceProviders<DashboardServices> = {
dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory),
@ -84,6 +85,7 @@ export const providers: PluginServiceProviders<DashboardServices> = {
userProfile: new PluginServiceProvider(userProfileServiceFactory),
observabilityAIAssistant: new PluginServiceProvider(observabilityAIAssistantServiceStubFactory),
dashboardRecentlyAccessed: new PluginServiceProvider(dashboardRecentlyAccessedServiceFactory),
dashboardFavorites: new PluginServiceProvider(dashboardFavoritesServiceFactory),
};
export const registry = new PluginServiceRegistry<DashboardServices>(providers);

View file

@ -49,6 +49,7 @@ import { uiActionsServiceFactory } from './ui_actions/ui_actions_service';
import { observabilityAIAssistantServiceFactory } from './observability_ai_assistant/observability_ai_assistant_service';
import { userProfileServiceFactory } from './user_profile/user_profile_service';
import { dashboardRecentlyAccessedFactory } from './dashboard_recently_accessed/dashboard_recently_accessed';
import { dashboardFavoritesServiceFactory } from './dashboard_favorites/dashboard_favorites_service';
const providers: PluginServiceProviders<DashboardServices, DashboardPluginServiceParams> = {
dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory, [
@ -98,6 +99,7 @@ const providers: PluginServiceProviders<DashboardServices, DashboardPluginServic
observabilityAIAssistant: new PluginServiceProvider(observabilityAIAssistantServiceFactory),
userProfile: new PluginServiceProvider(userProfileServiceFactory),
dashboardRecentlyAccessed: new PluginServiceProvider(dashboardRecentlyAccessedFactory, ['http']),
dashboardFavorites: new PluginServiceProvider(dashboardFavoritesServiceFactory),
};
export const pluginServices = new PluginServices<DashboardServices>();

View file

@ -44,6 +44,7 @@ import { DashboardUiActionsService } from './ui_actions/types';
import { ObservabilityAIAssistantService } from './observability_ai_assistant/types';
import { DashboardUserProfileService } from './user_profile/types';
import { DashboardRecentlyAccessedService } from './dashboard_recently_accessed/types';
import { DashboardFavoritesService } from './dashboard_favorites/types';
export type DashboardPluginServiceParams = KibanaPluginServiceParams<DashboardStartDependencies> & {
initContext: PluginInitializerContext; // need a custom type so that initContext is a required parameter for initializerContext
@ -84,4 +85,5 @@ export interface DashboardServices {
observabilityAIAssistant: ObservabilityAIAssistantService; // TODO: make this optional in follow up
userProfile: DashboardUserProfileService;
dashboardRecentlyAccessed: DashboardRecentlyAccessedService;
dashboardFavorites: DashboardFavoritesService;
}

View file

@ -84,6 +84,8 @@
"@kbn/lens-embeddable-utils",
"@kbn/lens-plugin",
"@kbn/recently-accessed",
"@kbn/content-management-favorites-public",
"@kbn/core-http-browser-mocks",
],
"exclude": ["target/**/*"]
}

View file

@ -190,6 +190,10 @@
"@kbn/content-management-content-editor/*": ["packages/content-management/content_editor/*"],
"@kbn/content-management-examples-plugin": ["examples/content_management_examples"],
"@kbn/content-management-examples-plugin/*": ["examples/content_management_examples/*"],
"@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"],
"@kbn/content-management-favorites-server/*": ["packages/content-management/favorites/favorites_server/*"],
"@kbn/content-management-plugin": ["src/plugins/content_management"],
"@kbn/content-management-plugin/*": ["src/plugins/content_management/*"],
"@kbn/content-management-tabbed-table-list-view": ["packages/content-management/tabbed_table_list_view"],

View file

@ -0,0 +1,168 @@
/*
* 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';
import {
cleanupInteractiveUser,
loginAsInteractiveUser,
LoginAsInteractiveUserResponse,
setupInteractiveUser,
} from './helpers';
export default function ({ getService }: FtrProviderContext) {
describe('favorites', function () {
describe('for not interactive user', function () {
const supertest = getService('supertest');
it('favorites require interactive user', async () => {
const { status: status1 } = await supertest
.get('/internal/content_management/favorites/dashboard')
.set('kbn-xsrf', 'true');
expect(status1).to.be(403);
const { status: status2 } = await supertest
.post('/internal/content_management/favorites/dashboard/fav1/favorite')
.set('kbn-xsrf', 'true');
expect(status2).to.be(403);
const { status: status3 } = await supertest
.post('/internal/content_management/favorites/dashboard/fav1/unfavorite')
.set('kbn-xsrf', 'true');
expect(status3).to.be(403);
});
});
describe('for interactive user', function () {
const supertest = getService('supertestWithoutAuth');
let interactiveUser: LoginAsInteractiveUserResponse;
before(async () => {
await getService('esArchiver').emptyKibanaIndex();
await setupInteractiveUser({ getService });
interactiveUser = await loginAsInteractiveUser({ getService });
});
after(async () => {
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']);
});
});
});
}

View file

@ -21,25 +21,49 @@ export const sampleDashboard = {
version: 2,
};
const role = 'content_manager_dashboard';
const users = ['content_manager_dashboard_1', 'content_manager_dashboard_2'] as const;
const roleEditor = 'content_manager_dashboard';
const roleReader = 'content_reader_dashboard';
const usersEditor = ['content_manager_dashboard_1', 'content_manager_dashboard_2'] as const;
const usersReader = ['content_reader_dashboard_1', 'content_reader_dashboard_2'] as const;
export async function setupInteractiveUser({ getService }: Pick<FtrProviderContext, 'getService'>) {
const security = getService('security');
await security.role.create(role, {
const spaces = getService('spaces');
await spaces.create({ id: 'custom', name: 'Custom Space' });
await security.role.create(roleEditor, {
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [
{
spaces: ['default'],
spaces: ['default', 'custom'],
base: [],
feature: { dashboard: ['all'] },
},
],
});
await security.role.create(roleReader, {
elasticsearch: { cluster: [], indices: [], run_as: [] },
kibana: [
{
spaces: ['default', 'custom'],
base: [],
feature: { dashboard: ['read'] },
},
],
});
for (const user of users) {
for (const user of usersEditor) {
await security.user.create(user, {
password: user,
roles: [role],
roles: [roleEditor],
full_name: user.toUpperCase(),
email: `${user}@elastic.co`,
});
}
for (const user of usersReader) {
await security.user.create(user, {
password: user,
roles: [roleReader],
full_name: user.toUpperCase(),
email: `${user}@elastic.co`,
});
@ -49,11 +73,17 @@ export async function setupInteractiveUser({ getService }: Pick<FtrProviderConte
export async function cleanupInteractiveUser({
getService,
}: Pick<FtrProviderContext, 'getService'>) {
await getService('spaces').delete('custom');
const security = getService('security');
for (const user of users) {
for (const user of usersEditor) {
await security.user.delete(user);
}
await security.role.delete(role);
for (const user of usersReader) {
await security.user.delete(user);
}
await security.role.delete(roleEditor);
await security.role.delete(roleReader);
}
export interface LoginAsInteractiveUserResponse {
@ -64,9 +94,9 @@ export interface LoginAsInteractiveUserResponse {
}
export async function loginAsInteractiveUser({
getService,
username = users[0],
username = usersEditor[0],
}: Pick<FtrProviderContext, 'getService'> & {
username?: (typeof users)[number];
username?: (typeof usersEditor)[number] | (typeof usersReader)[number];
}): Promise<LoginAsInteractiveUserResponse> {
const supertest = getService('supertestWithoutAuth');

View file

@ -11,5 +11,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) {
describe('content management', function () {
loadTestFile(require.resolve('./created_by'));
loadTestFile(require.resolve('./updated_by'));
loadTestFile(require.resolve('./favorites'));
});
}

View file

@ -0,0 +1,118 @@
/*
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getPageObjects, getService }: FtrProviderContext) {
const PageObjects = getPageObjects([
'common',
'dashboard',
'visualize',
'lens',
'timePicker',
'security',
]);
const esArchiver = getService('esArchiver');
const listingTable = getService('listingTable');
const kibanaServer = getService('kibanaServer');
const security = getService('security');
const spaces = getService('spaces');
const testSubjects = getService('testSubjects');
const browser = getService('browser');
describe('favorites', function () {
const USERNAME = 'dashboard_read_user';
const ROLE = 'dashboard_read_role';
const customSpace = 'custom_space';
before(async () => {
await esArchiver.emptyKibanaIndex();
await spaces.create({
id: customSpace,
name: customSpace,
disabledFeatures: [],
});
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/dashboard/feature_controls/custom_space',
{ space: customSpace }
);
// ensure we're logged out so we can login as the appropriate users
await PageObjects.security.forceLogout();
await security.role.create(ROLE, {
elasticsearch: {
indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }],
},
kibana: [
{
feature: {
dashboard: ['read'],
},
spaces: ['*'],
},
],
});
await security.user.create(USERNAME, {
password: 'changeme',
roles: [ROLE],
full_name: USERNAME,
});
await PageObjects.security.login(USERNAME, 'changeme', {
expectSpaceSelector: true,
});
await PageObjects.dashboard.gotoDashboardListingURL({
args: {
basePath: '/s/custom_space',
ensureCurrentUrl: false,
shouldLoginIfPrompted: false,
},
});
});
after(async () => {
// logout, so the other tests don't accidentally run as the custom users we're testing below
await PageObjects.security.forceLogout();
await spaces.delete(customSpace);
await security.user.delete(USERNAME);
await security.role.delete(ROLE);
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/dashboard/feature_controls/custom_space',
{ space: customSpace }
);
});
it('can favorite and unfavorite a dashboard', async () => {
await testSubjects.exists('tabbedTableFilter');
await listingTable.expectItemsCount('dashboard', 1);
await testSubjects.click('favoriteTab');
await listingTable.expectItemsCount('dashboard', 0, 1000);
await testSubjects.click('allTab');
await listingTable.expectItemsCount('dashboard', 1);
await testSubjects.moveMouseTo('~dashboardListingTitleLink-A-Dashboard');
await testSubjects.click('favoriteButton');
await listingTable.expectItemsCount('dashboard', 1);
await testSubjects.click('favoriteTab');
await listingTable.expectItemsCount('dashboard', 1);
await browser.refresh(); // make sure the favorite state is persisted and filter state is preserved
await testSubjects.exists('tabbedTableFilter');
await listingTable.expectItemsCount('dashboard', 1);
await testSubjects.click('unfavoriteButton');
await listingTable.expectItemsCount('dashboard', 0, 1000);
await testSubjects.click('allTab');
await listingTable.expectItemsCount('dashboard', 1);
});
});
}

View file

@ -12,5 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./feature_controls'));
loadTestFile(require.resolve('./preserve_url'));
loadTestFile(require.resolve('./created_by'));
loadTestFile(require.resolve('./favorite'));
});
}

View file

@ -3660,6 +3660,14 @@
version "0.0.0"
uid ""
"@kbn/content-management-favorites-public@link:packages/content-management/favorites/favorites_public":
version "0.0.0"
uid ""
"@kbn/content-management-favorites-server@link:packages/content-management/favorites/favorites_server":
version "0.0.0"
uid ""
"@kbn/content-management-plugin@link:src/plugins/content_management":
version "0.0.0"
uid ""