mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
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   - 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:
parent
bec63eca4c
commit
b8fc60b30e
57 changed files with 1511 additions and 39 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
76
packages/content-management/favorites/README.mdx
Normal file
76
packages/content-management/favorites/README.mdx
Normal 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.
|
|
@ -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.
|
|
@ -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';
|
|
@ -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'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/content-management-favorites-public",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
|
@ -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 |
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
|
@ -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 haven’t 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',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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",
|
||||
]
|
||||
}
|
|
@ -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.
|
|
@ -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';
|
|
@ -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'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-server",
|
||||
"id": "@kbn/content-management-favorites-server",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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}`;
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
|
@ -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",
|
||||
]
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -72,6 +72,7 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) =>
|
|||
getTagManagementUrl: () => '',
|
||||
getTagIdsFromReferences: () => [],
|
||||
isTaggingEnabled: () => true,
|
||||
isFavoritesEnabled: () => false,
|
||||
...params,
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 */}
|
||||
|
|
|
@ -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/**/*"
|
||||
|
|
|
@ -436,6 +436,7 @@
|
|||
"updated_by",
|
||||
"version"
|
||||
],
|
||||
"favorites": [],
|
||||
"file": [
|
||||
"FileKind",
|
||||
"Meta",
|
||||
|
|
|
@ -1482,6 +1482,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"dynamic": false,
|
||||
"properties": {}
|
||||
},
|
||||
"file": {
|
||||
"dynamic": false,
|
||||
"properties": {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -60,6 +60,7 @@ const previouslyRegisteredTypes = [
|
|||
'event_loop_delays_daily',
|
||||
'exception-list',
|
||||
'exception-list-agnostic',
|
||||
'favorites',
|
||||
'file',
|
||||
'fileShare',
|
||||
'file-upload-telemetry',
|
||||
|
|
|
@ -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}');
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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() });
|
||||
};
|
|
@ -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 });
|
||||
};
|
|
@ -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;
|
|
@ -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);
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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"],
|
||||
|
|
168
x-pack/test/api_integration/apis/content_management/favorites.ts
Normal file
168
x-pack/test/api_integration/apis/content_management/favorites.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
118
x-pack/test/functional/apps/dashboard/group1/favorite.ts
Normal file
118
x-pack/test/functional/apps/dashboard/group1/favorite.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue