[Dashboard] Interactive Managed Dashboard Popover vs badge (#189404)

## Summary

Closes #https://github.com/elastic/kibana/issues/179152

This PR makes the managed badge into an interactive popover that allows
users to duplicate the managed dashboard. The user is automatically
redirected to the new dashboard where they can edit it and make changes.



### Checklist
- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
This commit is contained in:
Rachel Shen 2024-08-21 09:41:36 -06:00 committed by GitHub
parent 22031dfb05
commit 5bd3f902f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 175 additions and 44 deletions

View file

@ -9,9 +9,10 @@ import { i18n } from '@kbn/i18n';
import type { EuiToolTipProps } from '@elastic/eui';
import type { TopNavMenuBadgeProps } from '@kbn/navigation-plugin/public';
export const getManagedContentBadge: (tooltipText: string) => TopNavMenuBadgeProps = (
tooltipText
) => ({
export const getManagedContentBadge: (
tooltipText: string,
disableTooltipProps?: boolean
) => TopNavMenuBadgeProps = (tooltipText, enableTooltipProps = true) => ({
'data-test-subj': 'managedContentBadge',
badgeText: i18n.translate('managedContentBadge.text', {
defaultMessage: 'Managed',
@ -21,8 +22,10 @@ export const getManagedContentBadge: (tooltipText: string) => TopNavMenuBadgePro
}),
color: 'primary',
iconType: 'glasses',
toolTipProps: {
content: tooltipText,
position: 'bottom',
} as EuiToolTipProps,
toolTipProps: enableTooltipProps
? ({
content: tooltipText,
position: 'bottom',
} as EuiToolTipProps)
: undefined,
});

View file

@ -26,9 +26,13 @@ export const dashboardReadonlyBadge = {
};
export const dashboardManagedBadge = {
getTooltip: () =>
i18n.translate('dashboard.badge.managed.tooltip', {
defaultMessage: 'Elastic manages this dashboard. Clone it to make changes.',
getDuplicateButtonAriaLabel: () =>
i18n.translate('dashboard.managedContentPopoverFooterText', {
defaultMessage: 'Click here to duplicate this dashboard',
}),
getBadgeAriaLabel: () =>
i18n.translate('dashboard.managedContentBadge.ariaLabel', {
defaultMessage: 'Elastic manages this dashboard. Duplicate it to make changes.',
}),
};

View file

@ -18,19 +18,18 @@ import { topNavStrings } from '../_dashboard_app_strings';
import { ShowShareModal } from './share/show_share_modal';
import { pluginServices } from '../../services/plugin_services';
import { CHANGE_CHECK_DEBOUNCE } from '../../dashboard_constants';
import { DashboardRedirect } from '../../dashboard_container/types';
import { SaveDashboardReturn } from '../../services/dashboard_content_management/types';
import { confirmDiscardUnsavedChanges } from '../../dashboard_listing/confirm_overlays';
import { SaveDashboardReturn } from '../../services/dashboard_content_management/types';
export const useDashboardMenuItems = ({
redirectTo,
isLabsShown,
setIsLabsShown,
maybeRedirect,
showResetChange,
}: {
redirectTo: DashboardRedirect;
isLabsShown: boolean;
setIsLabsShown: Dispatch<SetStateAction<boolean>>;
maybeRedirect: (result?: SaveDashboardReturn) => void;
showResetChange?: boolean;
}) => {
const [isSaveInProgress, setIsSaveInProgress] = useState(false);
@ -78,22 +77,6 @@ export const useDashboardMenuItems = ({
[dashboardTitle, hasUnsavedChanges, lastSavedId, dashboard]
);
const maybeRedirect = useCallback(
(result?: SaveDashboardReturn) => {
if (!result) return;
const { redirectRequired, id } = result;
if (redirectRequired) {
redirectTo({
id,
editMode: true,
useReplace: true,
destination: 'dashboard',
});
}
},
[redirectTo]
);
/**
* Save the dashboard without any UI or popups.
*/

View file

@ -261,6 +261,7 @@ export async function runInteractiveSave(this: DashboardContainer, interactionMo
this.saveNotification$.next();
resolve(saveResult);
return saveResult;
} catch (error) {
reject(error);

View file

@ -440,7 +440,6 @@ export class DashboardContainer
// ------------------------------------------------------------------------------------------------------
// Dashboard API
// ------------------------------------------------------------------------------------------------------
public runInteractiveSave = runInteractiveSave;
public runQuickSave = runQuickSave;

View file

@ -0,0 +1,63 @@
/*
* 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 { render } from '@testing-library/react';
import { buildMockDashboard } from '../mocks';
import { InternalDashboardTopNav } from './internal_dashboard_top_nav';
import { setMockedPresentationUtilServices } from '@kbn/presentation-util-plugin/public/mocks';
import { pluginServices } from '../services/plugin_services';
import { DashboardAPIContext } from '../dashboard_app/dashboard_app';
import { TopNavMenuProps } from '@kbn/navigation-plugin/public';
describe('Internal dashboard top nav', () => {
const mockTopNav = (badges: TopNavMenuProps['badges'] | undefined[]) => {
if (badges) {
return badges?.map((badge, index) => (
<div key={index} className="badge">
{badge?.badgeText}
</div>
));
} else {
return <></>;
}
};
beforeEach(() => {
setMockedPresentationUtilServices();
pluginServices.getServices().data.query.filterManager.getFilters = jest
.fn()
.mockReturnValue([]);
// topNavMenu is mocked as a jest.fn() so we want to mock it with a component
// @ts-ignore type issue with the mockTopNav for this test suite
pluginServices.getServices().navigation.TopNavMenu = ({ badges }: TopNavMenuProps) =>
mockTopNav(badges);
});
it('should not render the managed badge by default', async () => {
const component = render(
<DashboardAPIContext.Provider value={buildMockDashboard()}>
<InternalDashboardTopNav redirectTo={jest.fn()} />
</DashboardAPIContext.Provider>
);
expect(component.queryByText('Managed')).toBeNull();
});
it('should render the managed badge when the dashboard is managed', async () => {
const container = buildMockDashboard();
container.dispatch.setManaged(true);
const component = render(
<DashboardAPIContext.Provider value={container}>
<InternalDashboardTopNav redirectTo={jest.fn()} />
</DashboardAPIContext.Provider>
);
expect(component.getByText('Managed')).toBeInTheDocument();
});
});

View file

@ -7,20 +7,28 @@
*/
import UseUnmount from 'react-use/lib/useUnmount';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
withSuspense,
LazyLabsFlyout,
getContextProvider as getPresentationUtilContextProvider,
} from '@kbn/presentation-util-plugin/public';
import { getManagedContentBadge } from '@kbn/managed-content-badge';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { TopNavMenuProps } from '@kbn/navigation-plugin/public';
import { EuiHorizontalRule, EuiIcon, EuiToolTipProps } from '@elastic/eui';
import type { EuiBreadcrumb } from '@elastic/eui';
import { TopNavMenuBadgeProps, TopNavMenuProps } from '@kbn/navigation-plugin/public';
import {
EuiBreadcrumb,
EuiHorizontalRule,
EuiIcon,
EuiToolTipProps,
EuiPopover,
EuiBadge,
EuiLink,
} from '@elastic/eui';
import { MountPoint } from '@kbn/core/public';
import { getManagedContentBadge } from '@kbn/managed-content-badge';
import { FormattedMessage } from '@kbn/i18n-react';
import {
getDashboardTitle,
leaveConfirmStrings,
@ -38,6 +46,7 @@ import { useDashboardMountContext } from '../dashboard_app/hooks/dashboard_mount
import { getFullEditPath, LEGACY_DASHBOARD_APP_ID } from '../dashboard_constants';
import './_dashboard_top_nav.scss';
import { DashboardRedirect } from '../dashboard_container/types';
import { SaveDashboardReturn } from '../services/dashboard_content_management/types';
export interface InternalDashboardTopNavProps {
customLeadingBreadCrumbs?: EuiBreadcrumb[];
@ -90,7 +99,6 @@ export function InternalDashboardTopNav({
const dashboard = useDashboardAPI();
const PresentationUtilContextProvider = getPresentationUtilContextProvider();
const hasRunMigrations = dashboard.select(
(state) => state.componentState.hasRunClientsideMigrations
);
@ -107,6 +115,7 @@ export function InternalDashboardTopNav({
// store data views in state & subscribe to dashboard data view changes.
const [allDataViews, setAllDataViews] = useState<DataView[]>([]);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
useEffect(() => {
setAllDataViews(dashboard.getAllDataViews());
const subscription = dashboard.onDataViewsUpdate$.subscribe((dataViews) =>
@ -274,10 +283,26 @@ export function InternalDashboardTopNav({
viewMode,
]);
const maybeRedirect = useCallback(
(result?: SaveDashboardReturn) => {
if (!result) return;
const { redirectRequired, id } = result;
if (redirectRequired) {
redirectTo({
id,
editMode: true,
useReplace: true,
destination: 'dashboard',
});
}
},
[redirectTo]
);
const { viewModeTopNavConfig, editModeTopNavConfig } = useDashboardMenuItems({
redirectTo,
isLabsShown,
setIsLabsShown,
maybeRedirect,
showResetChange,
});
@ -313,10 +338,63 @@ export function InternalDashboardTopNav({
});
}
if (showWriteControls && managed) {
allBadges.push(getManagedContentBadge(dashboardManagedBadge.getTooltip()));
const badgeProps = {
...getManagedContentBadge(dashboardManagedBadge.getBadgeAriaLabel()),
onClick: () => setIsPopoverOpen(!isPopoverOpen),
onClickAriaLabel: dashboardManagedBadge.getBadgeAriaLabel(),
iconOnClick: () => setIsPopoverOpen(!isPopoverOpen),
iconOnClickAriaLabel: dashboardManagedBadge.getBadgeAriaLabel(),
} as TopNavMenuBadgeProps;
allBadges.push({
renderCustomBadge: ({ badgeText }) => {
const badgeButton = <EuiBadge {...badgeProps}>{badgeText}</EuiBadge>;
return (
<EuiPopover
button={badgeButton}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
panelStyle={{ maxWidth: 250 }}
>
<FormattedMessage
id="dashboard.managedContentPopoverButton"
defaultMessage="Elastic manages this dashboard. {Duplicate} it to make changes."
values={{
Duplicate: (
<EuiLink
id="dashboardManagedContentPopoverButton"
onClick={() => {
dashboard
.runInteractiveSave(viewMode)
.then((result) => maybeRedirect(result));
}}
aria-label={dashboardManagedBadge.getDuplicateButtonAriaLabel()}
>
<FormattedMessage
id="dashboard.managedContentPopoverButtonText"
defaultMessage="Duplicate"
/>
</EuiLink>
),
}}
/>
</EuiPopover>
);
},
badgeText: badgeProps.badgeText,
});
}
return allBadges;
}, [hasUnsavedChanges, viewMode, hasRunMigrations, showWriteControls, managed]);
}, [
hasUnsavedChanges,
viewMode,
hasRunMigrations,
showWriteControls,
managed,
isPopoverOpen,
dashboard,
maybeRedirect,
]);
return (
<div className="dashboardTopNav">

View file

@ -73,7 +73,6 @@
"@kbn/presentation-panel-plugin",
"@kbn/content-management-table-list-view-common",
"@kbn/shared-ux-utility",
"@kbn/managed-content-badge",
"@kbn/core-test-helpers-model-versions",
"@kbn/deeplinks-analytics",
"@kbn/core-user-profile-browser-mocks",
@ -84,6 +83,7 @@
"@kbn/lens-embeddable-utils",
"@kbn/lens-plugin",
"@kbn/recently-accessed",
"@kbn/managed-content-badge",
"@kbn/content-management-favorites-public",
"@kbn/core-http-browser-mocks",
],

View file

@ -38,3 +38,6 @@ export const mockedReduxEmbeddablePackage: ReduxToolsPackage = {
};
export * from './__stories__/fixtures/flights';
export const setMockedPresentationUtilServices = () => {
pluginServices.setRegistry(stubRegistry.start({}));
};

View file

@ -1307,7 +1307,6 @@
"dashboard.appLeaveConfirmModal.cancelButtonLabel": "Annuler",
"dashboard.appLeaveConfirmModal.unsavedChangesSubtitle": "Quitter Dashboard sans enregistrer ?",
"dashboard.appLeaveConfirmModal.unsavedChangesTitle": "Modifications non enregistrées",
"dashboard.badge.managed.tooltip": "Elastic gère ce tableau de bord. Clonez-le pour effectuer des modifications.",
"dashboard.badge.readOnly.text": "Lecture seule",
"dashboard.badge.readOnly.tooltip": "Impossible d'enregistrer les tableaux de bord",
"dashboard.createConfirmModal.cancelButtonLabel": "Annuler",

View file

@ -1307,7 +1307,6 @@
"dashboard.appLeaveConfirmModal.cancelButtonLabel": "キャンセル",
"dashboard.appLeaveConfirmModal.unsavedChangesSubtitle": "作業を保存せずにダッシュボードから移動しますか?",
"dashboard.appLeaveConfirmModal.unsavedChangesTitle": "保存されていない変更",
"dashboard.badge.managed.tooltip": "Elasticはこのダッシュボードを管理します。変更するには、複製してください。",
"dashboard.badge.readOnly.text": "読み取り専用",
"dashboard.badge.readOnly.tooltip": "ダッシュボードを保存できません",
"dashboard.createConfirmModal.cancelButtonLabel": "キャンセル",

View file

@ -1307,7 +1307,6 @@
"dashboard.appLeaveConfirmModal.cancelButtonLabel": "取消",
"dashboard.appLeaveConfirmModal.unsavedChangesSubtitle": "离开有未保存工作的仪表板?",
"dashboard.appLeaveConfirmModal.unsavedChangesTitle": "未保存的更改",
"dashboard.badge.managed.tooltip": "Elastic 将管理此仪表板。进行克隆以做出更改。",
"dashboard.badge.readOnly.text": "只读",
"dashboard.badge.readOnly.tooltip": "无法保存仪表板",
"dashboard.createConfirmModal.cancelButtonLabel": "取消",