mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
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)    
This commit is contained in:
parent
95b3f6e14d
commit
79dfa2e764
22 changed files with 474 additions and 162 deletions
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 }
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
`}
|
||||
/>
|
||||
|
|
|
@ -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>;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>> = ({
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
.kbnBody {
|
||||
.dshTitleBreadcrumbs__updateIcon {
|
||||
margin-left: $euiSizeXS;
|
||||
margin-top: calc(-1 * $euiSizeXS / 2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue