Skip to content

Commit

Permalink
Allow to "star" (favorite) a dashboard from the listing table (#189285)
Browse files Browse the repository at this point in the history
## Summary

close elastic/kibana-team#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
  • Loading branch information
Dosant authored Aug 13, 2024
1 parent bec63ec commit b8fc60b
Show file tree
Hide file tree
Showing 57 changed files with 1,511 additions and 39 deletions.
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions packages/content-management/favorites/README.mdx
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions packages/content-management/favorites/favorites_public/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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'],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "shared-browser",
"id": "@kbn/content-management-favorites-public",
"owner": "@elastic/appex-sharedux"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
`;
Original file line number Diff line number Diff line change
@@ -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',
})}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit b8fc60b

Please sign in to comment.