Favorite a dashboard from within (#201596)

- Adds "stardust" effect suggested by @andreadelrio here
https://github.com/elastic/kibana/issues/200315#issuecomment-2599109888
both to Dashboard And ESQL star


https://github.com/user-attachments/assets/96babced-7ffd-446b-a94a-e9681c627e44


https://github.com/user-attachments/assets/44273f8b-6ff6-4753-9ccf-d62a0feca12d


- Adds favorite button to dashboard page next to the breadcrumb (should
look good for both old and new nav)

![Screenshot 2025-02-10 at 15 31
15](https://github.com/user-attachments/assets/6639d97d-34d3-459f-acc1-4b726f76d6a2)
![Screenshot 2025-02-10 at 15 31
21](https://github.com/user-attachments/assets/669c248c-af64-4189-95d9-84ed91ec58a4)
![Screenshot 2025-02-10 at 15 32
42](https://github.com/user-attachments/assets/433a634c-c050-4e7b-a612-8ce3bc5ebc26)
![Screenshot 2025-02-10 at 15 32
46](https://github.com/user-attachments/assets/eb205f38-9d7a-47d4-90c3-de04d2930c69)
This commit is contained in:
Anton Dosov 2025-02-17 13:53:03 +01:00 committed by GitHub
parent 95b3f6e14d
commit 79dfa2e764
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 474 additions and 162 deletions

View file

@ -161,7 +161,7 @@ describe('EsqlStarredQueriesService', () => {
await service.addStarredQuery(query);
const buttonWithTooltip = service.renderStarredButton(query);
const button = buttonWithTooltip.props.children;
const button = buttonWithTooltip.props.children.props.children;
expect(button.props.title).toEqual('Remove ES|QL query from Starred');
expect(button.props.iconType).toEqual('starFilled');
});
@ -176,7 +176,7 @@ describe('EsqlStarredQueriesService', () => {
await service.addStarredQuery(query);
const buttonWithTooltip = service.renderStarredButton(query);
const button = buttonWithTooltip.props.children;
const button = buttonWithTooltip.props.children.props.children;
expect(button.props.title).toEqual('Remove ES|QL query from Starred');
button.props.onClick();
@ -194,7 +194,7 @@ describe('EsqlStarredQueriesService', () => {
await service.addStarredQuery(query);
const buttonWithTooltip = service.renderStarredButton(query);
const button = buttonWithTooltip.props.children;
const button = buttonWithTooltip.props.children.props.children;
button.props.onClick();
expect(service.discardModalVisibility$.value).toEqual(false);

View file

@ -14,7 +14,7 @@ import type { CoreStart } from '@kbn/core/public';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import { EuiButtonIcon } from '@elastic/eui';
import { FavoritesClient } from '@kbn/content-management-favorites-public';
import { FavoritesClient, StardustWrapper } from '@kbn/content-management-favorites-public';
import { FAVORITES_LIMIT as ESQL_STARRED_QUERIES_LIMIT } from '@kbn/content-management-favorites-common';
import { type QueryHistoryItem, getTrimmedQuery } from '../history_local_storage';
import { TooltipWrapper } from './tooltip_wrapper';
@ -68,6 +68,7 @@ export class EsqlStarredQueriesService {
private client: FavoritesClient<StarredQueryMetadata>;
private starredQueries: StarredQueryItem[] = [];
private queryToEdit: string = '';
private queryToAdd: string = '';
private storage: Storage;
queries$: BehaviorSubject<StarredQueryItem[]>;
discardModalVisibility$: BehaviorSubject<boolean> = new BehaviorSubject(false);
@ -203,43 +204,48 @@ export class EsqlStarredQueriesService {
)}
condition={!isStarred && this.checkIfStarredQueriesLimitReached()}
>
<EuiButtonIcon
title={
isStarred
? i18n.translate('esqlEditor.query.querieshistory.removeFavoriteTitle', {
defaultMessage: 'Remove ES|QL query from Starred',
})
: i18n.translate('esqlEditor.query.querieshistory.addFavoriteTitle', {
defaultMessage: 'Add ES|QL query to Starred',
})
}
className={!isStarred ? 'cm-favorite-button--empty' : ''}
aria-label={
isStarred
? i18n.translate('esqlEditor.query.querieshistory.removeFavoriteTitle', {
defaultMessage: 'Remove ES|QL query from Starred',
})
: i18n.translate('esqlEditor.query.querieshistory.addFavoriteTitle', {
defaultMessage: 'Add ES|QL query to Starred',
})
}
iconType={isStarred ? 'starFilled' : 'starEmpty'}
disabled={!isStarred && this.checkIfStarredQueriesLimitReached()}
onClick={async () => {
this.queryToEdit = trimmedQueryString;
if (isStarred) {
// show the discard modal only if the user has not dismissed it
if (!this.storage.get(STARRED_QUERIES_DISCARD_KEY)) {
this.discardModalVisibility$.next(true);
} else {
await this.removeStarredQuery(item.queryString);
}
} else {
await this.addStarredQuery(item);
{/* show startdust effect only after starring the query and not on the initial load */}
<StardustWrapper active={isStarred && trimmedQueryString === this.queryToAdd}>
<EuiButtonIcon
title={
isStarred
? i18n.translate('esqlEditor.query.querieshistory.removeFavoriteTitle', {
defaultMessage: 'Remove ES|QL query from Starred',
})
: i18n.translate('esqlEditor.query.querieshistory.addFavoriteTitle', {
defaultMessage: 'Add ES|QL query to Starred',
})
}
}}
data-test-subj="ESQLFavoriteButton"
/>
className={!isStarred ? 'cm-favorite-button--empty' : ''}
aria-label={
isStarred
? i18n.translate('esqlEditor.query.querieshistory.removeFavoriteTitle', {
defaultMessage: 'Remove ES|QL query from Starred',
})
: i18n.translate('esqlEditor.query.querieshistory.addFavoriteTitle', {
defaultMessage: 'Add ES|QL query to Starred',
})
}
iconType={isStarred ? 'starFilled' : 'starEmpty'}
disabled={!isStarred && this.checkIfStarredQueriesLimitReached()}
onClick={async () => {
this.queryToEdit = trimmedQueryString;
if (isStarred) {
// show the discard modal only if the user has not dismissed it
if (!this.storage.get(STARRED_QUERIES_DISCARD_KEY)) {
this.discardModalVisibility$.next(true);
} else {
await this.removeStarredQuery(item.queryString);
}
} else {
this.queryToAdd = trimmedQueryString;
await this.addStarredQuery(item);
this.queryToAdd = '';
}
}}
data-test-subj="ESQLFavoriteButton"
/>
</StardustWrapper>
</TooltipWrapper>
);
}

View file

@ -13,6 +13,7 @@ import {
UserProfilesProvider,
useUserProfilesServices,
} from '@kbn/content-management-user-profiles';
import { QueryClientProvider, useQueryClient } from '@tanstack/react-query';
import type { EuiComboBoxProps } from '@elastic/eui';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
@ -138,18 +139,21 @@ export const ContentEditorKibanaProvider: FC<
}, [savedObjectsTagging?.ui.components.TagList]);
const userProfilesServices = useUserProfilesServices();
const queryClient = useQueryClient();
const openFlyout = useCallback(
(node: ReactNode, options: OverlayFlyoutOpenOptions) => {
return coreOpenFlyout(
toMountPoint(
<UserProfilesProvider {...userProfilesServices}>{node}</UserProfilesProvider>,
<QueryClientProvider client={queryClient}>
<UserProfilesProvider {...userProfilesServices}>{node}</UserProfilesProvider>
</QueryClientProvider>,
startServices
),
options
);
},
[coreOpenFlyout, startServices, userProfilesServices]
[coreOpenFlyout, startServices, userProfilesServices, queryClient]
);
return (

View file

@ -11,7 +11,7 @@ import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfilesProvider } from '@kbn/content-management-user-profiles';
import { I18nProvider } from '@kbn/i18n-react';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { ActivityView as ActivityViewComponent, ActivityViewProps } from './activity_view';
const mockGetUserProfile = jest.fn(async (uid: string) => ({
@ -21,12 +21,15 @@ const mockGetUserProfile = jest.fn(async (uid: string) => ({
user: { username: uid, full_name: uid.toLocaleUpperCase() },
}));
const queryClient = new QueryClient();
const ActivityView = (props: ActivityViewProps) => {
return (
<I18nProvider>
<UserProfilesProvider bulkGetUserProfiles={jest.fn()} getUserProfile={mockGetUserProfile}>
<ActivityViewComponent {...props} />
</UserProfilesProvider>
<QueryClientProvider client={queryClient}>
<UserProfilesProvider bulkGetUserProfiles={jest.fn()} getUserProfile={mockGetUserProfile}>
<ActivityViewComponent {...props} />
</UserProfilesProvider>
</QueryClientProvider>
</I18nProvider>
);
};

View file

@ -16,5 +16,5 @@ export {
type FavoriteButtonProps,
cssFavoriteHoverWithinEuiTableRow,
} from './src/components/favorite_button';
export { StardustWrapper } from './src/components/stardust_wrapper';
export { FavoritesEmptyState } from './src/components/favorites_empty_state';

View file

@ -12,8 +12,9 @@ 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';
import { useAddFavorite, useFavorites, useRemoveFavorite } from '../favorites_query';
import { useFavoritesClient } from '../favorites_context';
import { StardustWrapper } from './stardust_wrapper';
export interface FavoriteButtonProps {
id: string;
@ -31,49 +32,45 @@ export const FavoriteButton = ({ id, className }: FavoriteButtonProps) => {
if (!data) return null;
const isFavorite = data.favoriteIds.includes(id);
const isFavoriteOptimistic = isFavorite || addFavorite.isLoading;
if (isFavorite) {
const title = i18n.translate('contentManagement.favorites.unfavoriteButtonLabel', {
defaultMessage: 'Remove from Starred',
});
const title = isFavoriteOptimistic
? i18n.translate('contentManagement.favorites.unfavoriteButtonLabel', {
defaultMessage: 'Remove from Starred',
})
: i18n.translate('contentManagement.favorites.favoriteButtonLabel', {
defaultMessage: 'Add to Starred',
});
return (
return (
<StardustWrapper
className={className}
active={(isFavorite && addFavorite.isSuccess) || addFavorite.isLoading}
>
<EuiButtonIcon
isLoading={removeFavorite.isLoading}
title={title}
aria-label={title}
iconType={'starFilled'}
iconType={isFavoriteOptimistic ? 'starFilled' : 'starEmpty'}
onClick={() => {
favoritesClient?.reportRemoveFavoriteClick();
removeFavorite.mutate({ id });
if (addFavorite.isLoading || removeFavorite.isLoading) return;
if (isFavorite) {
favoritesClient?.reportRemoveFavoriteClick();
removeFavorite.mutate({ id });
} else {
favoritesClient?.reportAddFavoriteClick();
addFavorite.mutate({ id });
}
}}
className={classNames(className, 'cm-favorite-button', {
'cm-favorite-button--active': !removeFavorite.isLoading,
className={classNames('cm-favorite-button', {
'cm-favorite-button--active': isFavorite && !removeFavorite.isLoading,
'cm-favorite-button--empty': !isFavorite && !addFavorite.isLoading,
})}
data-test-subj="unfavoriteButton"
data-test-subj={isFavorite ? 'unfavoriteButton' : 'favoriteButton'}
/>
);
} 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={() => {
favoritesClient?.reportAddFavoriteClick();
addFavorite.mutate({ id });
}}
className={classNames(className, 'cm-favorite-button', {
'cm-favorite-button--empty': !addFavorite.isLoading,
})}
data-test-subj="favoriteButton"
/>
);
}
</StardustWrapper>
);
};
/**

View file

@ -0,0 +1,137 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { PropsWithChildren } from 'react';
import classNames from 'classnames';
import { euiCanAnimate, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
const buttonSize = 24; // 24px is the default size for EuiButtonIcon xs, TODO: calculate from theme?
const stardustRadius = 8;
const stardustSize = buttonSize + stardustRadius; // should be larger than the button size to nicely overlap
const stardustOffset = (stardustSize - buttonSize) / 2;
const stardustContainerStyles = css`
@keyframes popping {
0% {
transform: scale(0, 0);
}
40% {
transform: scale(0, 0);
}
75% {
transform: scale(1.3, 1.3);
}
100% {
transform: scale(1, 1);
}
}
@keyframes sparkles-width {
0% {
stroke-width: 0;
}
15% {
stroke-width: 8;
}
100% {
stroke-width: 0;
}
}
@keyframes sparkles-size {
0% {
transform: scale(0.2, 0.2);
}
5% {
transform: scale(0.2, 0.2);
}
85% {
transform: scale(2, 2);
}
}
${euiCanAnimate} {
&.stardust-active {
svg {
animation: popping 0.5s 1;
}
.stardust {
animation: sparkles-size 0.65s 1;
circle {
animation: sparkles-width 0.65s 1;
}
}
}
}
.stardust {
pointer-events: none;
position: absolute;
top: -${stardustOffset}px;
left: -${stardustOffset}px;
}
circle {
stroke-dashoffset: 8;
stroke-dasharray: 1 9;
stroke-width: 0;
}
`;
/* Disable the animation on the button icon to not overcrowd the stardust effect */
const euiButtonIconStylesDisableAnimation = css`
${euiCanAnimate} {
button.euiButtonIcon {
&:active,
&:hover,
&:focus {
animation: none;
transform: none;
background-color: transparent;
}
}
}
`;
export const StardustWrapper = ({
active,
className,
children,
}: PropsWithChildren<{ className?: string; active: boolean }>) => {
const { euiTheme } = useEuiTheme();
return (
<div
css={css`
display: inline-block;
vertical-align: middle;
position: relative;
${stardustContainerStyles}
${euiButtonIconStylesDisableAnimation}
`}
className={classNames(className, {
'stardust-active': active,
})}
>
{children}
<svg height={stardustSize} width={stardustSize} className="stardust">
<circle
cx={stardustSize / 2}
cy={stardustSize / 2}
r={stardustRadius}
stroke={euiTheme.colors.primary}
fill="transparent"
/>
</svg>
</div>
);
};

View file

@ -31,7 +31,7 @@ export const useFavorites = ({ enabled = true }: { enabled?: boolean } = { enabl
return useQuery(
favoritesKeys.byType(favoritesClient?.getFavoriteType() ?? 'never'),
() => favoritesClient!.getFavorites(),
{ enabled }
{ enabled, staleTime: 5 * 60 * 1000 }
);
};

View file

@ -12,6 +12,7 @@ import type { ComponentType } from 'react';
import { from } from 'rxjs';
import { ContentEditorProvider } from '@kbn/content-management-content-editor';
import { UserProfilesProvider, UserProfilesServices } from '@kbn/content-management-user-profiles';
import { MaybeQueryClientProvider } from '../query_client';
import { TagList } from '../mocks';
import { TableListViewProvider, Services } from '../services';
@ -46,13 +47,15 @@ export function WithServices<P>(
return (props: P) => {
const services = getMockServices(overrides);
return (
<UserProfilesProvider {...services}>
<ContentEditorProvider openFlyout={jest.fn()} notifyError={() => undefined}>
<TableListViewProvider {...services}>
<Comp {...(props as any)} />
</TableListViewProvider>
</ContentEditorProvider>
</UserProfilesProvider>
<MaybeQueryClientProvider>
<UserProfilesProvider {...services}>
<ContentEditorProvider openFlyout={jest.fn()} notifyError={() => undefined}>
<TableListViewProvider {...services}>
<Comp {...(props as any)} />
</TableListViewProvider>
</ContentEditorProvider>
</UserProfilesProvider>
</MaybeQueryClientProvider>
);
};
}

View file

@ -100,7 +100,8 @@ export function ItemDetails<T extends UserContentCommonSchema>({
<FavoriteButton
id={item.id}
css={css`
margin-top: -${euiTheme.size.xs}; // trying to nicer align the star with the title
margin-top: -${euiTheme.size.m}; // trying to nicer align the star with the title
margin-bottom: -${euiTheme.size.s};
margin-left: ${euiTheme.size.xxs};
`}
/>

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { defaultContext, QueryClient, QueryClientProvider } from '@tanstack/react-query';
const defaultTableQueryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
/**
* Attempts to reuse existing query client otherwise fallbacks to a default one.
*/
export function MaybeQueryClientProvider({ children }: React.PropsWithChildren) {
const client = React.useContext(defaultContext);
if (client) {
return <>{children}</>;
}
return <QueryClientProvider client={defaultTableQueryClient}>{children}</QueryClientProvider>;
}

View file

@ -33,6 +33,7 @@ import {
FavoritesClientPublic,
FavoritesContextProvider,
} from '@kbn/content-management-favorites-public';
import { MaybeQueryClientProvider } from './query_client';
import { TAG_MANAGEMENT_APP_URL } from './constants';
import type { Tag } from './types';
@ -258,50 +259,55 @@ export const TableListViewKibanaProvider: FC<
return (
<RedirectAppLinksKibanaProvider coreStart={core}>
<UserProfilesKibanaProvider core={core}>
<ContentEditorKibanaProvider core={core} savedObjectsTagging={savedObjectsTagging}>
<ContentInsightsProvider
contentInsightsClient={services.contentInsightsClient}
isKibanaVersioningEnabled={services.isKibanaVersioningEnabled}
>
<FavoritesContextProvider
favoritesClient={services.favorites}
notifyError={(title, text) => {
notifications.toasts.addDanger({ title: toMountPoint(title, startServices), text });
}}
<MaybeQueryClientProvider>
<UserProfilesKibanaProvider core={core}>
<ContentEditorKibanaProvider core={core} savedObjectsTagging={savedObjectsTagging}>
<ContentInsightsProvider
contentInsightsClient={services.contentInsightsClient}
isKibanaVersioningEnabled={services.isKibanaVersioningEnabled}
>
<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)}
isFavoritesEnabled={async () => services.favorites?.isAvailable() ?? false}
getTagList={getTagList}
TagList={TagList}
itemHasTags={itemHasTags}
getTagIdsFromReferences={getTagIdsFromReferences}
getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)}
isKibanaVersioningEnabled={services.isKibanaVersioningEnabled ?? false}
>
{children}
</TableListViewProvider>
</FavoritesContextProvider>
</ContentInsightsProvider>
</ContentEditorKibanaProvider>
</UserProfilesKibanaProvider>
<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={async () => services.favorites?.isAvailable() ?? false}
getTagList={getTagList}
TagList={TagList}
itemHasTags={itemHasTags}
getTagIdsFromReferences={getTagIdsFromReferences}
getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)}
isKibanaVersioningEnabled={services.isKibanaVersioningEnabled ?? false}
>
{children}
</TableListViewProvider>
</FavoritesContextProvider>
</ContentInsightsProvider>
</ContentEditorKibanaProvider>
</UserProfilesKibanaProvider>
</MaybeQueryClientProvider>
</RedirectAppLinksKibanaProvider>
);
};

View file

@ -32,6 +32,7 @@ export const useUserProfiles = (uids: string[], opts?: { enabled?: boolean }) =>
const query = useQuery({
queryKey: userProfileKeys.bulkGet(uids),
queryFn: () => bulkGetUserProfiles(uids),
staleTime: Infinity,
enabled: opts?.enabled ?? true,
});
return query;

View file

@ -7,16 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { UserProfileServiceStart } from '@kbn/core-user-profile-browser';
import React, { FC, PropsWithChildren, useCallback, useContext, useMemo } from 'react';
import type { UserProfile } from '@kbn/user-profile-components';
import { createBatcher } from './utils/batcher';
export const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30 * 60 * 1000 } },
});
export interface UserProfilesKibanaDependencies {
core: {
userProfile: {
@ -36,11 +31,7 @@ export const UserProfilesProvider: FC<PropsWithChildren<UserProfilesServices>> =
children,
...services
}) => {
return (
<QueryClientProvider client={queryClient}>
<UserProfilesContext.Provider value={services}>{children}</UserProfilesContext.Provider>
</QueryClientProvider>
);
return <UserProfilesContext.Provider value={services}>{children}</UserProfilesContext.Provider>;
};
export const UserProfilesKibanaProvider: FC<PropsWithChildren<UserProfilesKibanaDependencies>> = ({

View file

@ -10,13 +10,13 @@
import React, { FC } from 'react';
import { mount, ReactWrapper } from 'enzyme';
import type { MountPoint, UnmountCallback } from '@kbn/core-mount-utils-browser';
import { MountPointPortal } from './mount_point_portal';
import { MountPointPortal, MountPointPortalProps } from './mount_point_portal';
import { act } from 'react-dom/test-utils';
describe('MountPointPortal', () => {
let portalTarget: HTMLElement;
let mountPoint: MountPoint;
let setMountPoint: jest.Mock<(mountPoint: MountPoint<HTMLElement>) => void>;
let setMountPoint: MountPointPortalProps['setMountPoint'];
let dom: ReactWrapper;
const refresh = () => {
@ -38,7 +38,9 @@ describe('MountPointPortal', () => {
beforeEach(() => {
portalTarget = document.createElement('div');
document.body.append(portalTarget);
setMountPoint = jest.fn().mockImplementation((mp) => (mountPoint = mp));
setMountPoint = jest.fn().mockImplementation((mp) => {
mountPoint = mp;
});
});
afterEach(() => {
@ -143,6 +145,35 @@ describe('MountPointPortal', () => {
expect(portalTarget.innerHTML).toBe('');
});
it('calls cleanup function when the component is unmounted', async () => {
const cleanup = jest.fn();
dom = mount(
<MountPointPortal
setMountPoint={(mp) => {
mountPoint = mp!;
return cleanup;
}}
>
<span>portal content</span>
</MountPointPortal>
);
act(() => {
mountPoint(portalTarget);
});
await refresh();
expect(portalTarget.innerHTML).toBe('<span>portal content</span>');
dom.unmount();
await refresh();
expect(portalTarget.innerHTML).toBe('');
expect(cleanup).toHaveBeenCalledTimes(1);
});
it('updates the content of the portal element when the content of MountPointPortal changes', async () => {
const Wrapper: FC<{
setMount: (mountPoint: MountPoint<HTMLElement> | undefined) => void;

View file

@ -14,9 +14,11 @@ import type { MountPoint } from '@kbn/core-mount-utils-browser';
import { useIfMounted } from './utils';
export interface MountPointPortalProps {
setMountPoint: (mountPoint: MountPoint<HTMLElement> | undefined) => void;
setMountPoint: SetMountPointFn;
children: React.ReactNode;
}
type SetMountPointFn = (mountPoint: MountPoint | undefined) => UnsetMountPointFn | void;
type UnsetMountPointFn = () => void;
/**
* Utility component to portal a part of a react application into the provided `MountPoint`.
@ -31,7 +33,7 @@ export const MountPointPortal: FC<PropsWithChildren<MountPointPortalProps>> = ({
const ifMounted = useIfMounted();
useEffect(() => {
setMountPoint((element) => {
const unsetMountPoint = setMountPoint((element) => {
ifMounted(() => {
el.current = element;
setShouldRender(true);
@ -52,6 +54,9 @@ export const MountPointPortal: FC<PropsWithChildren<MountPointPortalProps>> = ({
setShouldRender(false);
el.current = undefined;
});
if (unsetMountPoint) {
unsetMountPoint();
}
setMountPoint(undefined);
};
}, [setMountPoint, ifMounted]);

View file

@ -14,6 +14,7 @@ import { TableListView } from '@kbn/content-management-table-list-view';
import { TableListViewKibanaProvider } from '@kbn/content-management-table-list-view-table';
import { FormattedRelative, I18nProvider } from '@kbn/i18n-react';
import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
import { QueryClientProvider } from '@tanstack/react-query';
import { DASHBOARD_APP_ID } from '../plugin_constants';
import { DASHBOARD_CONTENT_ID } from '../utils/telemetry_constants';
@ -23,6 +24,7 @@ import {
serverlessService,
usageCollectionService,
} from '../services/kibana_services';
import { dashboardQueryClient } from '../services/dashboard_query_client';
import { DashboardUnsavedListing } from './dashboard_unsaved_listing';
import { useDashboardListingTable } from './hooks/use_dashboard_listing_table';
import { DashboardListingProps, DashboardSavedObjectUserContent } from './types';
@ -61,27 +63,29 @@ export const DashboardListing = ({
return (
<I18nProvider>
<TableListViewKibanaProvider
{...{
core: coreServices,
savedObjectsTagging: savedObjectsTaggingService?.getTaggingApi(),
FormattedRelative,
favorites: dashboardFavoritesClient,
contentInsightsClient,
isKibanaVersioningEnabled: !serverlessService,
}}
>
<TableListView<DashboardSavedObjectUserContent> {...tableListViewTableProps}>
<>
{children}
<DashboardUnsavedListing
goToDashboard={goToDashboard}
unsavedDashboardIds={unsavedDashboardIds}
refreshUnsavedDashboards={refreshUnsavedDashboards}
/>
</>
</TableListView>
</TableListViewKibanaProvider>
<QueryClientProvider client={dashboardQueryClient}>
<TableListViewKibanaProvider
{...{
core: coreServices,
savedObjectsTagging: savedObjectsTaggingService?.getTaggingApi(),
FormattedRelative,
favorites: dashboardFavoritesClient,
contentInsightsClient,
isKibanaVersioningEnabled: !serverlessService,
}}
>
<TableListView<DashboardSavedObjectUserContent> {...tableListViewTableProps}>
<>
{children}
<DashboardUnsavedListing
goToDashboard={goToDashboard}
unsavedDashboardIds={unsavedDashboardIds}
refreshUnsavedDashboards={refreshUnsavedDashboards}
/>
</>
</TableListView>
</TableListViewKibanaProvider>
</QueryClientProvider>
</I18nProvider>
);
};

View file

@ -1,6 +1,7 @@
.kbnBody {
.dshTitleBreadcrumbs__updateIcon {
margin-left: $euiSizeXS;
margin-top: calc(-1 * $euiSizeXS / 2);
cursor: pointer;
}

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useMemo } from 'react';
import {
FavoriteButton,
FavoritesClient,
FavoritesContextProvider,
} from '@kbn/content-management-favorites-public';
import { QueryClientProvider } from '@tanstack/react-query';
import { DASHBOARD_APP_ID } from '../plugin_constants';
import { DASHBOARD_CONTENT_ID } from '../utils/telemetry_constants';
import { coreServices, usageCollectionService } from '../services/kibana_services';
import { dashboardQueryClient } from '../services/dashboard_query_client';
export const DashboardFavoriteButton = ({ dashboardId }: { dashboardId?: string }) => {
const dashboardFavoritesClient = useMemo(() => {
return new FavoritesClient(DASHBOARD_APP_ID, DASHBOARD_CONTENT_ID, {
http: coreServices.http,
userProfile: coreServices.userProfile,
usageCollection: usageCollectionService,
});
}, []);
return (
<QueryClientProvider client={dashboardQueryClient}>
<FavoritesContextProvider favoritesClient={dashboardFavoritesClient}>
{dashboardId && <FavoriteButton id={dashboardId} />}
</FavoritesContextProvider>
</QueryClientProvider>
);
};

View file

@ -26,6 +26,7 @@ import { getManagedContentBadge } from '@kbn/managed-content-badge';
import { TopNavMenuBadgeProps, TopNavMenuProps } from '@kbn/navigation-plugin/public';
import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing';
import { LazyLabsFlyout, withSuspense } from '@kbn/presentation-util-plugin/public';
import { MountPointPortal } from '@kbn/react-kibana-mount';
import { UI_SETTINGS } from '../../common';
import { useDashboardApi } from '../dashboard_api/use_dashboard_api';
@ -33,6 +34,7 @@ import {
dashboardManagedBadge,
getDashboardBreadcrumb,
getDashboardTitle,
topNavStrings,
unsavedChangesBadgeStrings,
} from '../dashboard_app/_dashboard_app_strings';
import { useDashboardMountContext } from '../dashboard_app/hooks/dashboard_mount_context';
@ -53,6 +55,7 @@ import {
import { getDashboardCapabilities } from '../utils/get_dashboard_capabilities';
import './_dashboard_top_nav.scss';
import { getFullEditPath } from '../utils/urls';
import { DashboardFavoriteButton } from './dashboard_favorite_button';
export interface InternalDashboardTopNavProps {
customLeadingBreadCrumbs?: EuiBreadcrumb[];
@ -152,6 +155,9 @@ export function InternalDashboardTopNav({
<>
{dashboardTitle}
<EuiIcon
tabIndex={0}
role="button"
aria-label={topNavStrings.settings.description}
size="s"
type="pencil"
className="dshTitleBreadcrumbs__updateIcon"
@ -323,6 +329,18 @@ export function InternalDashboardTopNav({
return allBadges;
}, [hasUnsavedChanges, viewMode, isPopoverOpen, dashboardApi, maybeRedirect]);
const setFavoriteButtonMountPoint = useCallback(
(mountPoint: MountPoint<HTMLElement> | undefined) => {
if (mountPoint) {
return coreServices.chrome.setBreadcrumbsAppendExtension({
content: mountPoint,
order: 0,
});
}
},
[]
);
return (
<div className="dashboardTopNav">
<h1
@ -366,6 +384,9 @@ export function InternalDashboardTopNav({
) : null}
{viewMode === 'edit' ? <DashboardEditingToolbar isDisabled={!!focusedPanelId} /> : null}
{showBorderBottom && <EuiHorizontalRule margin="none" />}
<MountPointPortal setMountPoint={setFavoriteButtonMountPoint}>
<DashboardFavoriteButton dashboardId={lastSavedId} />
</MountPointPortal>
</div>
);
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { QueryClient } from '@tanstack/react-query';
export const dashboardQueryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

View file

@ -107,5 +107,22 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await testSubjects.click('allTab');
await listingTable.expectItemsCount('dashboard', 1);
});
it('can favorite a dashboard from the dashboard view', async () => {
await listingTable.clickItemLink('dashboard', 'A-Dashboard');
await dashboard.waitForRenderComplete();
await testSubjects.click('favoriteButton');
await testSubjects.exists('unfavoriteButton');
await dashboard.gotoDashboardListingURL({
args: {
basePath: '/s/custom_space',
ensureCurrentUrl: false,
shouldLoginIfPrompted: false,
},
});
await testSubjects.exists('tabbedTableFilter');
await listingTable.expectItemsCount('dashboard', 1);
});
});
}