[SecuritySolution] Security Solution Dashboard edit mode (#159486)

## Summary

issue: https://github.com/elastic/kibana/issues/152955

Test environment:
https://p.elstc.co/paste/9-b8FqRA#EkVP6KV1UAFOu1cWFwX1laj63P9wc5eQtnT7bCPyEuX

Known issues:
https://github.com/elastic/kibana/pull/159486#issuecomment-1740604651



23d30613-2dc3-423c-ada1-b52cd2f409ee



1. Reuse Kibana Dashboard's tool bar



<img width="2543" alt="Screenshot 2023-09-26 at 15 51 30"
src="b0279665-578a-45f9-b416-675e152b7dbd">




2. Dashboard with a `Managed` tag does `not` have the edit tool bar
under the title.

<img width="2558" alt="Screenshot 2023-09-29 at 10 11 34"
src="7c0774c1-2bf2-478b-a30e-59ff8609b584">


### Checklist

Delete any items that are not applicable to this PR.

- [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)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Sergi Massaneda <sergi.massaneda@gmail.com>
Co-authored-by: Devon Thomson <devon.thomson@elastic.co>
This commit is contained in:
Angela Chuang 2023-10-02 14:37:40 +01:00 committed by GitHub
parent 4c07f9c999
commit 8fd6dbed55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 854 additions and 490 deletions

View file

@ -31,15 +31,15 @@ import {
} from './url/search_sessions_integration'; } from './url/search_sessions_integration';
import { DashboardAPI, DashboardRenderer } from '..'; import { DashboardAPI, DashboardRenderer } from '..';
import { type DashboardEmbedSettings } from './types'; import { type DashboardEmbedSettings } from './types';
import { DASHBOARD_APP_ID } from '../dashboard_constants';
import { pluginServices } from '../services/plugin_services'; import { pluginServices } from '../services/plugin_services';
import { DashboardTopNav } from './top_nav/dashboard_top_nav';
import { AwaitingDashboardAPI } from '../dashboard_container'; import { AwaitingDashboardAPI } from '../dashboard_container';
import { DashboardRedirect } from '../dashboard_container/types'; import { DashboardRedirect } from '../dashboard_container/types';
import { useDashboardMountContext } from './hooks/dashboard_mount_context'; import { useDashboardMountContext } from './hooks/dashboard_mount_context';
import { createDashboardEditUrl, DASHBOARD_APP_ID } from '../dashboard_constants';
import { useDashboardOutcomeValidation } from './hooks/use_dashboard_outcome_validation'; import { useDashboardOutcomeValidation } from './hooks/use_dashboard_outcome_validation';
import { loadDashboardHistoryLocationState } from './locator/load_dashboard_history_location_state'; import { loadDashboardHistoryLocationState } from './locator/load_dashboard_history_location_state';
import type { DashboardCreationOptions } from '../dashboard_container/embeddable/dashboard_container_factory'; import type { DashboardCreationOptions } from '../dashboard_container/embeddable/dashboard_container_factory';
import { DashboardTopNav } from '../dashboard_top_nav';
export interface DashboardAppProps { export interface DashboardAppProps {
history: History; history: History;
@ -160,6 +160,10 @@ export function DashboardApp({
getInitialInput, getInitialInput,
validateLoadedSavedObject: validateOutcome, validateLoadedSavedObject: validateOutcome,
isEmbeddedExternally: Boolean(embedSettings), // embed settings are only sent if the dashboard URL has `embed=true` isEmbeddedExternally: Boolean(embedSettings), // embed settings are only sent if the dashboard URL has `embed=true`
getEmbeddableAppContext: (dashboardId) => ({
currentAppId: DASHBOARD_APP_ID,
getCurrentPath: () => `#${createDashboardEditUrl(dashboardId)}`,
}),
}); });
}, [ }, [
history, history,
@ -192,9 +196,11 @@ export function DashboardApp({
{!showNoDataPage && ( {!showNoDataPage && (
<> <>
{dashboardAPI && ( {dashboardAPI && (
<DashboardAPIContext.Provider value={dashboardAPI}> <DashboardTopNav
<DashboardTopNav redirectTo={redirectTo} embedSettings={embedSettings} /> redirectTo={redirectTo}
</DashboardAPIContext.Provider> embedSettings={embedSettings}
dashboardContainer={dashboardAPI}
/>
)} )}
{getLegacyConflictWarning?.()} {getLegacyConflictWarning?.()}

View file

@ -5,7 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server * in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { METRIC_TYPE } from '@kbn/analytics'; import { METRIC_TYPE } from '@kbn/analytics';
@ -21,7 +20,7 @@ import { EditorMenu } from './editor_menu';
import { useDashboardAPI } from '../dashboard_app'; import { useDashboardAPI } from '../dashboard_app';
import { pluginServices } from '../../services/plugin_services'; import { pluginServices } from '../../services/plugin_services';
import { ControlsToolbarButton } from './controls_toolbar_button'; import { ControlsToolbarButton } from './controls_toolbar_button';
import { DASHBOARD_APP_ID, DASHBOARD_UI_METRIC_ID } from '../../dashboard_constants'; import { DASHBOARD_UI_METRIC_ID } from '../../dashboard_constants';
import { dashboardReplacePanelActionStrings } from '../../dashboard_actions/_dashboard_actions_strings'; import { dashboardReplacePanelActionStrings } from '../../dashboard_actions/_dashboard_actions_strings';
export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }) { export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }) {
@ -70,12 +69,13 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }
stateTransferService.navigateToEditor(appId, { stateTransferService.navigateToEditor(appId, {
path, path,
state: { state: {
originatingApp: DASHBOARD_APP_ID, originatingApp: dashboard.getAppContext()?.currentAppId,
originatingPath: dashboard.getAppContext()?.getCurrentPath?.(),
searchSessionId: search.session.getSessionId(), searchSessionId: search.session.getSessionId(),
}, },
}); });
}, },
[stateTransferService, search.session, trackUiMetric] [stateTransferService, dashboard, search.session, trackUiMetric]
); );
const createNewEmbeddable = useCallback( const createNewEmbeddable = useCallback(

View file

@ -26,10 +26,12 @@ export const useDashboardMenuItems = ({
redirectTo, redirectTo,
isLabsShown, isLabsShown,
setIsLabsShown, setIsLabsShown,
showResetChange,
}: { }: {
redirectTo: DashboardRedirect; redirectTo: DashboardRedirect;
isLabsShown: boolean; isLabsShown: boolean;
setIsLabsShown: Dispatch<SetStateAction<boolean>>; setIsLabsShown: Dispatch<SetStateAction<boolean>>;
showResetChange?: boolean;
}) => { }) => {
const [isSaveInProgress, setIsSaveInProgress] = useState(false); const [isSaveInProgress, setIsSaveInProgress] = useState(false);
@ -276,32 +278,56 @@ export const useDashboardMenuItems = ({
const shareMenuItem = share ? [menuItems.share] : []; const shareMenuItem = share ? [menuItems.share] : [];
const cloneMenuItem = showWriteControls ? [menuItems.clone] : []; const cloneMenuItem = showWriteControls ? [menuItems.clone] : [];
const editMenuItem = showWriteControls && !managed ? [menuItems.edit] : []; const editMenuItem = showWriteControls && !managed ? [menuItems.edit] : [];
const mayberesetChangesMenuItem = showResetChange ? [resetChangesMenuItem] : [];
return [ return [
...labsMenuItem, ...labsMenuItem,
menuItems.fullScreen, menuItems.fullScreen,
...shareMenuItem, ...shareMenuItem,
...cloneMenuItem, ...cloneMenuItem,
resetChangesMenuItem, ...mayberesetChangesMenuItem,
...editMenuItem, ...editMenuItem,
]; ];
}, [isLabsEnabled, menuItems, share, showWriteControls, managed, resetChangesMenuItem]); }, [
isLabsEnabled,
menuItems,
share,
showWriteControls,
managed,
showResetChange,
resetChangesMenuItem,
]);
const editModeTopNavConfig = useMemo(() => { const editModeTopNavConfig = useMemo(() => {
const labsMenuItem = isLabsEnabled ? [menuItems.labs] : []; const labsMenuItem = isLabsEnabled ? [menuItems.labs] : [];
const shareMenuItem = share ? [menuItems.share] : []; const shareMenuItem = share ? [menuItems.share] : [];
const editModeItems: TopNavMenuData[] = []; const editModeItems: TopNavMenuData[] = [];
if (lastSavedId) { if (lastSavedId) {
editModeItems.push( editModeItems.push(menuItems.saveAs, menuItems.switchToViewMode);
menuItems.saveAs,
menuItems.switchToViewMode, if (showResetChange) {
resetChangesMenuItem, editModeItems.push(resetChangesMenuItem);
menuItems.quickSave }
);
editModeItems.push(menuItems.quickSave);
} else { } else {
editModeItems.push(menuItems.switchToViewMode, menuItems.saveAs); editModeItems.push(menuItems.switchToViewMode, menuItems.saveAs);
} }
return [...labsMenuItem, menuItems.settings, ...shareMenuItem, ...editModeItems]; return [...labsMenuItem, menuItems.settings, ...shareMenuItem, ...editModeItems];
}, [lastSavedId, menuItems, share, resetChangesMenuItem, isLabsEnabled]); }, [
isLabsEnabled,
menuItems.labs,
menuItems.share,
menuItems.settings,
menuItems.saveAs,
menuItems.switchToViewMode,
menuItems.quickSave,
share,
lastSavedId,
showResetChange,
resetChangesMenuItem,
]);
return { viewModeTopNavConfig, editModeTopNavConfig }; return { viewModeTopNavConfig, editModeTopNavConfig };
}; };

View file

@ -24,7 +24,7 @@ import { ViewMode } from '@kbn/embeddable-plugin/public';
import { pluginServices } from '../../../services/plugin_services'; import { pluginServices } from '../../../services/plugin_services';
import { emptyScreenStrings } from '../../_dashboard_container_strings'; import { emptyScreenStrings } from '../../_dashboard_container_strings';
import { useDashboardContainer } from '../../embeddable/dashboard_container'; import { useDashboardContainer } from '../../embeddable/dashboard_container';
import { DASHBOARD_UI_METRIC_ID, DASHBOARD_APP_ID } from '../../../dashboard_constants'; import { DASHBOARD_UI_METRIC_ID } from '../../../dashboard_constants';
export function DashboardEmptyScreen() { export function DashboardEmptyScreen() {
const { const {
@ -44,6 +44,14 @@ export function DashboardEmptyScreen() {
[getVisTypeAliases] [getVisTypeAliases]
); );
const dashboardContainer = useDashboardContainer();
const isDarkTheme = useObservable(theme$)?.darkMode;
const isEditMode =
dashboardContainer.select((state) => state.explicitInput.viewMode) === ViewMode.EDIT;
const embeddableAppContext = dashboardContainer.getAppContext();
const originatingPath = embeddableAppContext?.getCurrentPath?.() ?? '';
const originatingApp = embeddableAppContext?.currentAppId;
const goToLens = useCallback(() => { const goToLens = useCallback(() => {
if (!lensAlias || !lensAlias.aliasPath) return; if (!lensAlias || !lensAlias.aliasPath) return;
const trackUiMetric = usageCollection.reportUiCounter?.bind( const trackUiMetric = usageCollection.reportUiCounter?.bind(
@ -57,16 +65,19 @@ export function DashboardEmptyScreen() {
getStateTransfer().navigateToEditor(lensAlias.aliasApp, { getStateTransfer().navigateToEditor(lensAlias.aliasApp, {
path: lensAlias.aliasPath, path: lensAlias.aliasPath,
state: { state: {
originatingApp: DASHBOARD_APP_ID, originatingApp,
originatingPath,
searchSessionId: search.session.getSessionId(), searchSessionId: search.session.getSessionId(),
}, },
}); });
}, [getStateTransfer, lensAlias, search.session, usageCollection]); }, [
getStateTransfer,
const dashboardContainer = useDashboardContainer(); lensAlias,
const isDarkTheme = useObservable(theme$)?.darkMode; originatingApp,
const isEditMode = originatingPath,
dashboardContainer.select((state) => state.explicitInput.viewMode) === ViewMode.EDIT; search.session,
usageCollection,
]);
// TODO replace these SVGs with versions from EuiIllustration as soon as it becomes available. // TODO replace these SVGs with versions from EuiIllustration as soon as it becomes available.
const imageUrl = basePath.prepend( const imageUrl = basePath.prepend(

View file

@ -53,7 +53,7 @@ import { DASHBOARD_CONTAINER_TYPE } from '../..';
import { placePanel } from '../component/panel_placement'; import { placePanel } from '../component/panel_placement';
import { pluginServices } from '../../services/plugin_services'; import { pluginServices } from '../../services/plugin_services';
import { initializeDashboard } from './create/create_dashboard'; import { initializeDashboard } from './create/create_dashboard';
import { DASHBOARD_LOADED_EVENT } from '../../dashboard_constants'; import { DASHBOARD_APP_ID, DASHBOARD_LOADED_EVENT } from '../../dashboard_constants';
import { DashboardCreationOptions } from './dashboard_container_factory'; import { DashboardCreationOptions } from './dashboard_container_factory';
import { DashboardAnalyticsService } from '../../services/analytics/types'; import { DashboardAnalyticsService } from '../../services/analytics/types';
import { DashboardViewport } from '../component/viewport/dashboard_viewport'; import { DashboardViewport } from '../component/viewport/dashboard_viewport';
@ -107,7 +107,6 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
public controlGroup?: ControlGroupContainer; public controlGroup?: ControlGroupContainer;
public searchSessionId?: string; public searchSessionId?: string;
// cleanup // cleanup
public stopSyncingWithUnifiedSearch?: () => void; public stopSyncingWithUnifiedSearch?: () => void;
private cleanupStateTools: () => void; private cleanupStateTools: () => void;
@ -185,6 +184,16 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
this.select = reduxTools.select; this.select = reduxTools.select;
} }
public getAppContext() {
const embeddableAppContext = this.creationOptions?.getEmbeddableAppContext?.(
this.getDashboardSavedObjectId()
);
return {
...embeddableAppContext,
currentAppId: embeddableAppContext?.currentAppId ?? DASHBOARD_APP_ID,
};
}
public getDashboardSavedObjectId() { public getDashboardSavedObjectId() {
return this.getState().componentState.lastSavedId; return this.getState().componentState.lastSavedId;
} }

View file

@ -16,6 +16,7 @@ import {
EmbeddableFactory, EmbeddableFactory,
EmbeddableFactoryDefinition, EmbeddableFactoryDefinition,
EmbeddablePackageState, EmbeddablePackageState,
EmbeddableAppContext,
} from '@kbn/embeddable-plugin/public'; } from '@kbn/embeddable-plugin/public';
import { SearchSessionInfoProvider } from '@kbn/data-plugin/public'; import { SearchSessionInfoProvider } from '@kbn/data-plugin/public';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public'; import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
@ -58,6 +59,8 @@ export interface DashboardCreationOptions {
validateLoadedSavedObject?: (result: LoadDashboardReturn) => 'valid' | 'invalid' | 'redirected'; validateLoadedSavedObject?: (result: LoadDashboardReturn) => 'valid' | 'invalid' | 'redirected';
isEmbeddedExternally?: boolean; isEmbeddedExternally?: boolean;
getEmbeddableAppContext?: (dashboardId?: string) => EmbeddableAppContext;
} }
export class DashboardContainerFactoryDefinition export class DashboardContainerFactoryDefinition

View file

@ -0,0 +1,27 @@
/*
* 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 { DashboardAPIContext } from '../dashboard_app/dashboard_app';
import { DashboardContainer } from '../dashboard_container';
import {
InternalDashboardTopNav,
InternalDashboardTopNavProps,
} from './internal_dashboard_top_nav';
export interface DashboardTopNavProps extends InternalDashboardTopNavProps {
dashboardContainer: DashboardContainer;
}
export const DashboardTopNavWithContext = (props: DashboardTopNavProps) => (
<DashboardAPIContext.Provider value={props.dashboardContainer}>
<InternalDashboardTopNav {...props} />
</DashboardAPIContext.Provider>
);
// eslint-disable-next-line import/no-default-export
export default DashboardTopNavWithContext;

View file

@ -0,0 +1,29 @@
/*
* 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, { Suspense } from 'react';
import { servicesReady } from '../plugin';
import { DashboardTopNavProps } from './dashboard_top_nav_with_context';
const LazyDashboardTopNav = React.lazy(() =>
(async () => {
const modulePromise = import('./dashboard_top_nav_with_context');
const [module] = await Promise.all([modulePromise, servicesReady]);
return {
default: module.DashboardTopNavWithContext,
};
})().then((module) => module)
);
export const DashboardTopNav = (props: DashboardTopNavProps) => {
return (
<Suspense fallback={<div />}>
<LazyDashboardTopNav {...props} />
</Suspense>
);
};

View file

@ -18,36 +18,49 @@ import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public';
import { TopNavMenuProps } from '@kbn/navigation-plugin/public'; import { TopNavMenuProps } from '@kbn/navigation-plugin/public';
import { EuiHorizontalRule, EuiIcon, EuiToolTipProps } from '@elastic/eui'; import { EuiHorizontalRule, EuiIcon, EuiToolTipProps } from '@elastic/eui';
import { EuiBreadcrumbProps } from '@elastic/eui/src/components/breadcrumbs/breadcrumb';
import { MountPoint } from '@kbn/core/public';
import { import {
getDashboardTitle, getDashboardTitle,
leaveConfirmStrings, leaveConfirmStrings,
getDashboardBreadcrumb, getDashboardBreadcrumb,
unsavedChangesBadgeStrings, unsavedChangesBadgeStrings,
dashboardManagedBadge, dashboardManagedBadge,
} from '../_dashboard_app_strings'; } from '../dashboard_app/_dashboard_app_strings';
import { UI_SETTINGS } from '../../../common'; import { UI_SETTINGS } from '../../common';
import { useDashboardAPI } from '../dashboard_app'; import { useDashboardAPI } from '../dashboard_app/dashboard_app';
import { DashboardEmbedSettings } from '../types'; import { pluginServices } from '../services/plugin_services';
import { pluginServices } from '../../services/plugin_services'; import { useDashboardMenuItems } from '../dashboard_app/top_nav/use_dashboard_menu_items';
import { useDashboardMenuItems } from './use_dashboard_menu_items'; import { DashboardEmbedSettings } from '../dashboard_app/types';
import { DashboardRedirect } from '../../dashboard_container/types'; import { DashboardEditingToolbar } from '../dashboard_app/top_nav/dashboard_editing_toolbar';
import { DashboardEditingToolbar } from './dashboard_editing_toolbar'; import { useDashboardMountContext } from '../dashboard_app/hooks/dashboard_mount_context';
import { useDashboardMountContext } from '../hooks/dashboard_mount_context'; import { getFullEditPath, LEGACY_DASHBOARD_APP_ID } from '../dashboard_constants';
import { getFullEditPath, LEGACY_DASHBOARD_APP_ID } from '../../dashboard_constants';
import './_dashboard_top_nav.scss'; import './_dashboard_top_nav.scss';
export interface DashboardTopNavProps { import { DashboardRedirect } from '../dashboard_container/types';
export interface InternalDashboardTopNavProps {
customLeadingBreadCrumbs?: EuiBreadcrumbProps[];
embedSettings?: DashboardEmbedSettings; embedSettings?: DashboardEmbedSettings;
forceHideUnifiedSearch?: boolean;
redirectTo: DashboardRedirect; redirectTo: DashboardRedirect;
setCustomHeaderActionMenu?: (menuMount: MountPoint<HTMLElement> | undefined) => void;
showBorderBottom?: boolean;
showResetChange?: boolean;
} }
const LabsFlyout = withSuspense(LazyLabsFlyout, null); const LabsFlyout = withSuspense(LazyLabsFlyout, null);
export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavProps) { export function InternalDashboardTopNav({
customLeadingBreadCrumbs = [],
embedSettings,
forceHideUnifiedSearch,
redirectTo,
setCustomHeaderActionMenu,
showBorderBottom = true,
showResetChange = true,
}: InternalDashboardTopNavProps) {
const [isChromeVisible, setIsChromeVisible] = useState(false); const [isChromeVisible, setIsChromeVisible] = useState(false);
const [isLabsShown, setIsLabsShown] = useState(false); const [isLabsShown, setIsLabsShown] = useState(false);
const dashboardTitleRef = useRef<HTMLHeadingElement>(null); const dashboardTitleRef = useRef<HTMLHeadingElement>(null);
/** /**
@ -168,19 +181,33 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
// set only the dashboardTitleBreadcrumbs because the main breadcrumbs automatically come as part of the navigation config // set only the dashboardTitleBreadcrumbs because the main breadcrumbs automatically come as part of the navigation config
serverless.setBreadcrumbs(dashboardTitleBreadcrumbs); serverless.setBreadcrumbs(dashboardTitleBreadcrumbs);
} else { } else {
// non-serverless regular breadcrumbs /**
setBreadcrumbs([ * non-serverless regular breadcrumbs
{ * Dashboard embedded in other plugins (e.g. SecuritySolution)
text: getDashboardBreadcrumb(), * will have custom leading breadcrumbs for back to their app.
'data-test-subj': 'dashboardListingBreadcrumb', **/
onClick: () => { setBreadcrumbs(
redirectTo({ destination: 'listing' }); customLeadingBreadCrumbs.concat([
{
text: getDashboardBreadcrumb(),
'data-test-subj': 'dashboardListingBreadcrumb',
onClick: () => {
redirectTo({ destination: 'listing' });
},
}, },
}, ...dashboardTitleBreadcrumbs,
...dashboardTitleBreadcrumbs, ])
]); );
} }
}, [setBreadcrumbs, redirectTo, dashboardTitle, dashboard, viewMode, serverless]); }, [
setBreadcrumbs,
redirectTo,
dashboardTitle,
dashboard,
viewMode,
serverless,
customLeadingBreadCrumbs,
]);
/** /**
* Build app leave handler whenever hasUnsavedChanges changes * Build app leave handler whenever hasUnsavedChanges changes
@ -205,12 +232,6 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
}; };
}, [onAppLeave, getStateTransfer, hasUnsavedChanges, viewMode]); }, [onAppLeave, getStateTransfer, hasUnsavedChanges, viewMode]);
const { viewModeTopNavConfig, editModeTopNavConfig } = useDashboardMenuItems({
redirectTo,
isLabsShown,
setIsLabsShown,
});
const visibilityProps = useMemo(() => { const visibilityProps = useMemo(() => {
const shouldShowNavBarComponent = (forceShow: boolean): boolean => const shouldShowNavBarComponent = (forceShow: boolean): boolean =>
(forceShow || isChromeVisible) && !fullScreenMode; (forceShow || isChromeVisible) && !fullScreenMode;
@ -218,14 +239,17 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
!forceHide && (filterManager.getFilters().length > 0 || !fullScreenMode); !forceHide && (filterManager.getFilters().length > 0 || !fullScreenMode);
const showTopNavMenu = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowTopNavMenu)); const showTopNavMenu = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowTopNavMenu));
const showQueryInput = shouldShowNavBarComponent( const showQueryInput = Boolean(forceHideUnifiedSearch)
Boolean(embedSettings?.forceShowQueryInput || viewMode === ViewMode.PRINT) ? false
); : shouldShowNavBarComponent(
const showDatePicker = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker)); Boolean(embedSettings?.forceShowQueryInput || viewMode === ViewMode.PRINT)
);
const showDatePicker = Boolean(forceHideUnifiedSearch)
? false
: shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker));
const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar));
const showQueryBar = showQueryInput || showDatePicker || showFilterBar; const showQueryBar = showQueryInput || showDatePicker || showFilterBar;
const showSearchBar = showQueryBar || showFilterBar; const showSearchBar = showQueryBar || showFilterBar;
return { return {
showTopNavMenu, showTopNavMenu,
showSearchBar, showSearchBar,
@ -233,7 +257,21 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
showQueryInput, showQueryInput,
showDatePicker, showDatePicker,
}; };
}, [embedSettings, filterManager, fullScreenMode, isChromeVisible, viewMode]); }, [
embedSettings,
filterManager,
forceHideUnifiedSearch,
fullScreenMode,
isChromeVisible,
viewMode,
]);
const { viewModeTopNavConfig, editModeTopNavConfig } = useDashboardMenuItems({
redirectTo,
isLabsShown,
setIsLabsShown,
showResetChange,
});
UseUnmount(() => { UseUnmount(() => {
dashboard.clearOverlays(); dashboard.clearOverlays();
@ -301,7 +339,11 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
saveQueryMenuVisibility={allowSaveQuery ? 'allowed_by_app_privilege' : 'globally_managed'} saveQueryMenuVisibility={allowSaveQuery ? 'allowed_by_app_privilege' : 'globally_managed'}
appName={LEGACY_DASHBOARD_APP_ID} appName={LEGACY_DASHBOARD_APP_ID}
visible={viewMode !== ViewMode.PRINT} visible={viewMode !== ViewMode.PRINT}
setMenuMountPoint={embedSettings || fullScreenMode ? undefined : setHeaderActionMenu} setMenuMountPoint={
embedSettings || fullScreenMode
? setCustomHeaderActionMenu ?? undefined
: setHeaderActionMenu
}
className={fullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined} className={fullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined}
config={ config={
visibilityProps.showTopNavMenu visibilityProps.showTopNavMenu
@ -327,7 +369,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
{viewMode === ViewMode.EDIT ? ( {viewMode === ViewMode.EDIT ? (
<DashboardEditingToolbar isDisabled={!!focusedPanelId} /> <DashboardEditingToolbar isDisabled={!!focusedPanelId} />
) : null} ) : null}
<EuiHorizontalRule margin="none" /> {showBorderBottom && <EuiHorizontalRule margin="none" />}
</div> </div>
); );
} }

View file

@ -25,7 +25,7 @@ export {
export type { DashboardSetup, DashboardStart, DashboardFeatureFlagConfig } from './plugin'; export type { DashboardSetup, DashboardStart, DashboardFeatureFlagConfig } from './plugin';
export { DashboardListingTable } from './dashboard_listing'; export { DashboardListingTable } from './dashboard_listing';
export { DashboardTopNav } from './dashboard_top_nav';
export { export {
type DashboardAppLocator, type DashboardAppLocator,
type DashboardAppLocatorParams, type DashboardAppLocatorParams,

View file

@ -49,14 +49,7 @@ const getEventStatus = (output: EmbeddableOutput): EmbeddablePhase => {
}; };
export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => { export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => {
const { const { hideHeader, showShadow, embeddable, hideInspector, onPanelStatusChange } = panelProps;
hideHeader,
showShadow,
embeddable,
hideInspector,
containerContext,
onPanelStatusChange,
} = panelProps;
const [node, setNode] = useState<ReactNode | undefined>(); const [node, setNode] = useState<ReactNode | undefined>();
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []); const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
@ -74,8 +67,7 @@ export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => {
const editPanel = new EditPanelAction( const editPanel = new EditPanelAction(
embeddableStart.getEmbeddableFactory, embeddableStart.getEmbeddableFactory,
core.application, core.application,
stateTransfer, stateTransfer
containerContext?.getCurrentPath
); );
const actions: PanelUniversalActions = { const actions: PanelUniversalActions = {
@ -91,7 +83,7 @@ export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => {
}; };
if (!hideInspector) actions.inspectPanel = new InspectPanelAction(inspector); if (!hideInspector) actions.inspectPanel = new InspectPanelAction(inspector);
return actions; return actions;
}, [containerContext?.getCurrentPath, hideInspector]); }, [hideInspector]);
/** /**
* Track panel status changes * Track panel status changes

View file

@ -46,12 +46,7 @@ test('is compatible when edit url is available, in edit mode and editable', asyn
test('redirects to app using state transfer', async () => { test('redirects to app using state transfer', async () => {
applicationMock.currentAppId$ = of('superCoolCurrentApp'); applicationMock.currentAppId$ = of('superCoolCurrentApp');
const testPath = '/test-path'; const testPath = '/test-path';
const action = new EditPanelAction( const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock);
getFactory,
applicationMock,
stateTransferMock,
() => testPath
);
const embeddable = new EditableEmbeddable( const embeddable = new EditableEmbeddable(
{ {
id: '123', id: '123',
@ -62,6 +57,9 @@ test('redirects to app using state transfer', async () => {
true true
); );
embeddable.getOutput = jest.fn(() => ({ editApp: 'ultraVisualize', editPath: '/123' })); embeddable.getOutput = jest.fn(() => ({ editApp: 'ultraVisualize', editPath: '/123' }));
embeddable.getAppContext = jest.fn().mockReturnValue({
getCurrentPath: () => testPath,
});
await action.execute({ embeddable }); await action.execute({ embeddable });
expect(stateTransferMock.navigateToEditor).toHaveBeenCalledWith('ultraVisualize', { expect(stateTransferMock.navigateToEditor).toHaveBeenCalledWith('ultraVisualize', {
path: '/123', path: '/123',

View file

@ -45,8 +45,7 @@ export class EditPanelAction implements Action<ActionContext> {
constructor( constructor(
private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'], private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'],
private readonly application: ApplicationStart, private readonly application: ApplicationStart,
private readonly stateTransfer?: EmbeddableStateTransfer, private readonly stateTransfer?: EmbeddableStateTransfer
private readonly getOriginatingPath?: () => string
) { ) {
if (this.application?.currentAppId$) { if (this.application?.currentAppId$) {
this.application.currentAppId$ this.application.currentAppId$
@ -139,7 +138,7 @@ export class EditPanelAction implements Action<ActionContext> {
if (app && path) { if (app && path) {
if (this.currentAppId) { if (this.currentAppId) {
const originatingPath = this.getOriginatingPath?.(); const originatingPath = embeddable.getAppContext()?.getCurrentPath?.();
const state: EmbeddableEditorState = { const state: EmbeddableEditorState = {
originatingApp: this.currentAppId, originatingApp: this.currentAppId,

View file

@ -19,11 +19,12 @@ import {
import { EmbeddableError } from '../lib/embeddables/i_embeddable'; import { EmbeddableError } from '../lib/embeddables/i_embeddable';
import { EmbeddableContext, EmbeddableInput, EmbeddableOutput, IEmbeddable } from '..'; import { EmbeddableContext, EmbeddableInput, EmbeddableOutput, IEmbeddable } from '..';
export interface EmbeddableContainerContext { export interface EmbeddableAppContext {
/** /**
* Current app's path including query and hash starting from {appId} * Current app's path including query and hash starting from {appId}
*/ */
getCurrentPath?: () => string; getCurrentPath?: () => string;
currentAppId?: string;
} }
/** /**
@ -53,7 +54,6 @@ export interface EmbeddablePanelProps {
hideHeader?: boolean; hideHeader?: boolean;
hideInspector?: boolean; hideInspector?: boolean;
showNotifications?: boolean; showNotifications?: boolean;
containerContext?: EmbeddableContainerContext;
actionPredicate?: (actionId: string) => boolean; actionPredicate?: (actionId: string) => boolean;
onPanelStatusChange?: (info: EmbeddablePhaseEvent) => void; onPanelStatusChange?: (info: EmbeddablePhaseEvent) => void;
getActions?: UiActionsService['getTriggerCompatibleActions']; getActions?: UiActionsService['getTriggerCompatibleActions'];

View file

@ -100,7 +100,7 @@ export {
export type { export type {
EmbeddablePhase, EmbeddablePhase,
EmbeddablePhaseEvent, EmbeddablePhaseEvent,
EmbeddableContainerContext, EmbeddableAppContext,
} from './embeddable_panel/types'; } from './embeddable_panel/types';
export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service'; export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service';

View file

@ -17,6 +17,7 @@ import { IContainer } from '../containers';
import { EmbeddableError, EmbeddableOutput, IEmbeddable } from './i_embeddable'; import { EmbeddableError, EmbeddableOutput, IEmbeddable } from './i_embeddable';
import { EmbeddableInput, ViewMode } from '../../../common/types'; import { EmbeddableInput, ViewMode } from '../../../common/types';
import { genericEmbeddableInputIsEqual, omitGenericEmbeddableInput } from './diff_embeddable_input'; import { genericEmbeddableInputIsEqual, omitGenericEmbeddableInput } from './diff_embeddable_input';
import { EmbeddableAppContext } from '../../embeddable_panel/types';
function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) { function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) {
if (input.hidePanelTitles) return ''; if (input.hidePanelTitles) return '';
@ -102,6 +103,10 @@ export abstract class Embeddable<
.subscribe((title) => this.renderComplete.setTitle(title)); .subscribe((title) => this.renderComplete.setTitle(title));
} }
public getAppContext(): EmbeddableAppContext | undefined {
return this.parent?.getAppContext();
}
public reportsEmbeddableLoad() { public reportsEmbeddableLoad() {
return false; return false;
} }

View file

@ -11,6 +11,7 @@ import { ErrorLike } from '@kbn/expressions-plugin/common';
import { Adapters } from '../types'; import { Adapters } from '../types';
import { IContainer } from '../containers/i_container'; import { IContainer } from '../containers/i_container';
import { EmbeddableInput } from '../../../common/types'; import { EmbeddableInput } from '../../../common/types';
import { EmbeddableAppContext } from '../../embeddable_panel/types';
export type EmbeddableError = ErrorLike; export type EmbeddableError = ErrorLike;
export type { EmbeddableInput }; export type { EmbeddableInput };
@ -181,6 +182,11 @@ export interface IEmbeddable<
*/ */
getRoot(): IEmbeddable | IContainer; getRoot(): IEmbeddable | IContainer;
/**
* Returns the context of this embeddable's container, or undefined.
*/
getAppContext(): EmbeddableAppContext | undefined;
/** /**
* Renders the embeddable at the given node. * Renders the embeddable at the given node.
* @param domNode * @param domNode

View file

@ -17,13 +17,13 @@ import {
isErrorEmbeddable, isErrorEmbeddable,
EmbeddablePanel, EmbeddablePanel,
} from '@kbn/embeddable-plugin/public'; } from '@kbn/embeddable-plugin/public';
import type { EmbeddableContainerContext } from '@kbn/embeddable-plugin/public'; import type { EmbeddableAppContext } from '@kbn/embeddable-plugin/public';
import { StartDeps } from '../../plugin'; import { StartDeps } from '../../plugin';
import { EmbeddableExpression } from '../../expression_types/embeddable'; import { EmbeddableExpression } from '../../expression_types/embeddable';
import { RendererStrings } from '../../../i18n'; import { RendererStrings } from '../../../i18n';
import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { embeddableInputToExpression } from './embeddable_input_to_expression';
import { RendererFactory, EmbeddableInput } from '../../../types'; import { RendererFactory, EmbeddableInput } from '../../../types';
import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../common/lib'; import { CANVAS_APP, CANVAS_EMBEDDABLE_CLASSNAME } from '../../../common/lib';
const { embeddable: strings } = RendererStrings; const { embeddable: strings } = RendererStrings;
@ -41,18 +41,19 @@ const renderEmbeddableFactory = (core: CoreStart, plugins: StartDeps) => {
return null; return null;
} }
const embeddableContainerContext: EmbeddableContainerContext = { const canvasAppContext: EmbeddableAppContext = {
getCurrentPath: () => { getCurrentPath: () => {
const urlToApp = core.application.getUrlForApp(currentAppId); const urlToApp = core.application.getUrlForApp(currentAppId);
const inAppPath = window.location.pathname.replace(urlToApp, ''); const inAppPath = window.location.pathname.replace(urlToApp, '');
return inAppPath + window.location.search + window.location.hash; return inAppPath + window.location.search + window.location.hash;
}, },
currentAppId: CANVAS_APP,
}; };
return ( embeddable.getAppContext = () => canvasAppContext;
<EmbeddablePanel embeddable={embeddable} containerContext={embeddableContainerContext} />
); return <EmbeddablePanel embeddable={embeddable} />;
}; };
return (embeddableObject: IEmbeddable) => { return (embeddableObject: IEmbeddable) => {

View file

@ -794,18 +794,14 @@ export class Embeddable
* Used for the Edit in Lens link inside the inline editing flyout. * Used for the Edit in Lens link inside the inline editing flyout.
*/ */
private async navigateToLensEditor() { private async navigateToLensEditor() {
const executionContext = this.getExecutionContext(); const appContext = this.getAppContext();
/** /**
* The origininating app variable is very important for the Save and Return button * The origininating app variable is very important for the Save and Return button
* of the editor to work properly. * of the editor to work properly.
* The best way to get it dynamically is from the execution context but for the dashboard
* it needs to be pluralized
*/ */
const transferState = { const transferState = {
originatingApp: originatingApp: appContext?.currentAppId ?? 'dashboards',
executionContext?.type === 'dashboard' originatingPath: appContext?.getCurrentPath?.(),
? 'dashboards'
: executionContext?.type ?? 'dashboards',
valueInput: this.getExplicitInput(), valueInput: this.getExplicitInput(),
embeddableId: this.id, embeddableId: this.id,
searchSessionId: this.getInput().searchSessionId, searchSessionId: this.getInput().searchSessionId,
@ -818,6 +814,7 @@ export class Embeddable
await transfer.navigateToEditor(APP_ID, { await transfer.navigateToEditor(APP_ID, {
path: this.output.editPath, path: this.output.editPath,
state: transferState, state: transferState,
skipAppLeave: true,
}); });
} }
} }

View file

@ -38,7 +38,6 @@ interface StartAppComponent {
children: React.ReactNode; children: React.ReactNode;
history: History; history: History;
onAppLeave: (handler: AppLeaveHandler) => void; onAppLeave: (handler: AppLeaveHandler) => void;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
store: Store<State, Action>; store: Store<State, Action>;
theme$: AppMountParameters['theme$']; theme$: AppMountParameters['theme$'];
} }
@ -46,7 +45,6 @@ interface StartAppComponent {
const StartAppComponent: FC<StartAppComponent> = ({ const StartAppComponent: FC<StartAppComponent> = ({
children, children,
history, history,
setHeaderActionMenu,
onAppLeave, onAppLeave,
store, store,
theme$, theme$,
@ -79,11 +77,7 @@ const StartAppComponent: FC<StartAppComponent> = ({
> >
<UpsellingProvider upsellingService={upselling}> <UpsellingProvider upsellingService={upselling}>
<DiscoverInTimelineContextProvider> <DiscoverInTimelineContextProvider>
<PageRouter <PageRouter history={history} onAppLeave={onAppLeave}>
history={history}
onAppLeave={onAppLeave}
setHeaderActionMenu={setHeaderActionMenu}
>
{children} {children}
</PageRouter> </PageRouter>
</DiscoverInTimelineContextProvider> </DiscoverInTimelineContextProvider>
@ -113,7 +107,6 @@ interface SecurityAppComponentProps {
history: History; history: History;
onAppLeave: (handler: AppLeaveHandler) => void; onAppLeave: (handler: AppLeaveHandler) => void;
services: StartServices; services: StartServices;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
store: Store<State, Action>; store: Store<State, Action>;
theme$: AppMountParameters['theme$']; theme$: AppMountParameters['theme$'];
} }
@ -123,7 +116,6 @@ const SecurityAppComponent: React.FC<SecurityAppComponentProps> = ({
history, history,
onAppLeave, onAppLeave,
services, services,
setHeaderActionMenu,
store, store,
theme$, theme$,
}) => { }) => {
@ -137,13 +129,7 @@ const SecurityAppComponent: React.FC<SecurityAppComponentProps> = ({
}} }}
> >
<CloudProvider> <CloudProvider>
<StartApp <StartApp history={history} onAppLeave={onAppLeave} store={store} theme$={theme$}>
history={history}
onAppLeave={onAppLeave}
setHeaderActionMenu={setHeaderActionMenu}
store={store}
theme$={theme$}
>
{children} {children}
</StartApp> </StartApp>
</CloudProvider> </CloudProvider>

View file

@ -49,7 +49,6 @@ jest.mock('react-reverse-portal', () => ({
})); }));
describe('global header', () => { describe('global header', () => {
const mockSetHeaderActionMenu = jest.fn();
const state = { const state = {
...mockGlobalState, ...mockGlobalState,
timeline: { timeline: {
@ -75,7 +74,7 @@ describe('global header', () => {
]); ]);
const { getByText } = render( const { getByText } = render(
<TestProviders store={store}> <TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} /> <GlobalHeader />
</TestProviders> </TestProviders>
); );
expect(getByText('Add integrations')).toBeInTheDocument(); expect(getByText('Add integrations')).toBeInTheDocument();
@ -87,7 +86,7 @@ describe('global header', () => {
]); ]);
const { queryByTestId } = render( const { queryByTestId } = render(
<TestProviders store={store}> <TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} /> <GlobalHeader />
</TestProviders> </TestProviders>
); );
const link = queryByTestId('add-data'); const link = queryByTestId('add-data');
@ -98,7 +97,7 @@ describe('global header', () => {
(useLocation as jest.Mock).mockReturnValue({ pathname: THREAT_INTELLIGENCE_PATH }); (useLocation as jest.Mock).mockReturnValue({ pathname: THREAT_INTELLIGENCE_PATH });
const { queryByTestId } = render( const { queryByTestId } = render(
<TestProviders store={store}> <TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} /> <GlobalHeader />
</TestProviders> </TestProviders>
); );
const link = queryByTestId('add-data'); const link = queryByTestId('add-data');
@ -118,7 +117,7 @@ describe('global header', () => {
); );
const { queryByTestId } = render( const { queryByTestId } = render(
<TestProviders store={store}> <TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} /> <GlobalHeader />
</TestProviders> </TestProviders>
); );
const link = queryByTestId('add-data'); const link = queryByTestId('add-data');
@ -130,7 +129,7 @@ describe('global header', () => {
const { getByTestId } = render( const { getByTestId } = render(
<TestProviders store={store}> <TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} /> <GlobalHeader />
</TestProviders> </TestProviders>
); );
expect(getByTestId('sourcerer-trigger')).toBeInTheDocument(); expect(getByTestId('sourcerer-trigger')).toBeInTheDocument();
@ -141,7 +140,7 @@ describe('global header', () => {
const { getByTestId } = render( const { getByTestId } = render(
<TestProviders store={store}> <TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} /> <GlobalHeader />
</TestProviders> </TestProviders>
); );
expect(getByTestId('sourcerer-trigger')).toBeInTheDocument(); expect(getByTestId('sourcerer-trigger')).toBeInTheDocument();
@ -166,7 +165,7 @@ describe('global header', () => {
const { queryByTestId } = render( const { queryByTestId } = render(
<TestProviders store={mockStore}> <TestProviders store={mockStore}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} /> <GlobalHeader />
</TestProviders> </TestProviders>
); );
@ -180,7 +179,7 @@ describe('global header', () => {
const { findByTestId } = render( const { findByTestId } = render(
<TestProviders store={store}> <TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} /> <GlobalHeader />
</TestProviders> </TestProviders>
); );

View file

@ -15,11 +15,10 @@ import { useLocation } from 'react-router-dom';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import type { AppMountParameters } from '@kbn/core/public'; import { toMountPoint } from '@kbn/react-kibana-mount';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { MlPopover } from '../../../common/components/ml_popover/ml_popover'; import { MlPopover } from '../../../common/components/ml_popover/ml_popover';
import { useKibana } from '../../../common/lib/kibana'; import { useKibana } from '../../../common/lib/kibana';
import { isDetectionsPath } from '../../../helpers'; import { isDetectionsPath, isDashboardViewPath } from '../../../helpers';
import { Sourcerer } from '../../../common/components/sourcerer'; import { Sourcerer } from '../../../common/components/sourcerer';
import { TimelineId } from '../../../../common/types/timeline'; import { TimelineId } from '../../../../common/types/timeline';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
@ -37,63 +36,69 @@ const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.globalHeader.butt
* This component uses the reverse portal to add the Add Data, ML job settings, and AI Assistant buttons on the * This component uses the reverse portal to add the Add Data, ML job settings, and AI Assistant buttons on the
* right hand side of the Kibana global header * right hand side of the Kibana global header
*/ */
export const GlobalHeader = React.memo( export const GlobalHeader = React.memo(() => {
({ setHeaderActionMenu }: { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'] }) => { const portalNode = useMemo(() => createHtmlPortalNode(), []);
const portalNode = useMemo(() => createHtmlPortalNode(), []); const { theme, setHeaderActionMenu, i18n: kibanaServiceI18n } = useKibana().services;
const { theme } = useKibana().services; const { pathname } = useLocation();
const { pathname } = useLocation();
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const showTimeline = useShallowEqualSelector( const showTimeline = useShallowEqualSelector(
(state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show
); );
const sourcererScope = getScopeFromPath(pathname); const sourcererScope = getScopeFromPath(pathname);
const showSourcerer = showSourcererByPath(pathname); const showSourcerer = showSourcererByPath(pathname);
const dashboardViewPath = isDashboardViewPath(pathname);
const { href, onClick } = useAddIntegrationsUrl(); const { href, onClick } = useAddIntegrationsUrl();
useEffect(() => { useEffect(() => {
setHeaderActionMenu((element) => { setHeaderActionMenu((element) => {
const mount = toMountPoint(<OutPortal node={portalNode} />, { theme$: theme.theme$ }); const mount = toMountPoint(<OutPortal node={portalNode} />, {
return mount(element); theme,
i18n: kibanaServiceI18n,
}); });
return mount(element);
});
return () => { return () => {
portalNode.unmount(); /* Dashboard mounts an edit toolbar, it should be restored when leaving dashboard editing page */
setHeaderActionMenu(undefined); if (dashboardViewPath) {
}; return;
}, [portalNode, setHeaderActionMenu, theme.theme$]); }
portalNode.unmount();
return ( setHeaderActionMenu(undefined);
<InPortal node={portalNode}> };
<EuiHeaderSection side="right"> }, [portalNode, setHeaderActionMenu, theme, kibanaServiceI18n, dashboardViewPath]);
{isDetectionsPath(pathname) && (
<EuiHeaderSectionItem>
<MlPopover />
</EuiHeaderSectionItem>
)}
return (
<InPortal node={portalNode}>
<EuiHeaderSection side="right">
{isDetectionsPath(pathname) && (
<EuiHeaderSectionItem> <EuiHeaderSectionItem>
<EuiHeaderLinks> <MlPopover />
<EuiHeaderLink
color="primary"
data-test-subj="add-data"
href={href}
iconType="indexOpen"
onClick={onClick}
>
{BUTTON_ADD_DATA}
</EuiHeaderLink>
{showSourcerer && !showTimeline && (
<Sourcerer scope={sourcererScope} data-test-subj="sourcerer" />
)}
<AssistantHeaderLink />
</EuiHeaderLinks>
</EuiHeaderSectionItem> </EuiHeaderSectionItem>
</EuiHeaderSection> )}
</InPortal>
); <EuiHeaderSectionItem>
} <EuiHeaderLinks>
); <EuiHeaderLink
color="primary"
data-test-subj="add-data"
href={href}
iconType="indexOpen"
onClick={onClick}
>
{BUTTON_ADD_DATA}
</EuiHeaderLink>
{showSourcerer && !showTimeline && (
<Sourcerer scope={sourcererScope} data-test-subj="sourcerer" />
)}
<AssistantHeaderLink />
</EuiHeaderLinks>
</EuiHeaderSectionItem>
</EuiHeaderSection>
</InPortal>
);
});
GlobalHeader.displayName = 'GlobalHeader'; GlobalHeader.displayName = 'GlobalHeader';

View file

@ -106,6 +106,7 @@ jest.mock('../../timelines/store/timeline', () => ({
const mockedFilterManager = new FilterManager(coreMock.createStart().uiSettings); const mockedFilterManager = new FilterManager(coreMock.createStart().uiSettings);
const mockGetSavedQuery = jest.fn(); const mockGetSavedQuery = jest.fn();
const mockSetHeaderActionMenu = jest.fn();
const dummyFilter: Filter = { const dummyFilter: Filter = {
meta: { meta: {
@ -198,6 +199,7 @@ jest.mock('../../common/lib/kibana', () => {
savedQueries: { getSavedQuery: mockGetSavedQuery }, savedQueries: { getSavedQuery: mockGetSavedQuery },
}, },
}, },
setHeaderActionMenu: mockSetHeaderActionMenu,
}, },
}), }),
KibanaServices: { KibanaServices: {
@ -226,7 +228,7 @@ describe('HomePage', () => {
it('calls useInitializeUrlParam for appQuery, filters and savedQuery', () => { it('calls useInitializeUrlParam for appQuery, filters and savedQuery', () => {
render( render(
<TestProviders> <TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -252,7 +254,7 @@ describe('HomePage', () => {
render( render(
<TestProviders> <TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -294,7 +296,7 @@ describe('HomePage', () => {
render( render(
<TestProviders> <TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -326,7 +328,7 @@ describe('HomePage', () => {
render( render(
<TestProviders> <TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -361,7 +363,7 @@ describe('HomePage', () => {
render( render(
<TestProviders store={mockStore}> <TestProviders store={mockStore}>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -378,7 +380,7 @@ describe('HomePage', () => {
render( render(
<TestProviders> <TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -420,7 +422,7 @@ describe('HomePage', () => {
render( render(
<TestProviders> <TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -465,7 +467,7 @@ describe('HomePage', () => {
render( render(
<TestProviders> <TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -515,7 +517,7 @@ describe('HomePage', () => {
const TestComponent = () => ( const TestComponent = () => (
<TestProviders store={mockStore}> <TestProviders store={mockStore}>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -572,7 +574,7 @@ describe('HomePage', () => {
const TestComponent = () => ( const TestComponent = () => (
<TestProviders store={mockStore}> <TestProviders store={mockStore}>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -612,7 +614,7 @@ describe('HomePage', () => {
render( render(
<TestProviders> <TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -637,7 +639,7 @@ describe('HomePage', () => {
const TestComponent = () => ( const TestComponent = () => (
<TestProviders store={store}> <TestProviders store={store}>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>
@ -669,7 +671,7 @@ describe('HomePage', () => {
const TestComponent = () => ( const TestComponent = () => (
<TestProviders store={store}> <TestProviders store={store}>
<HomePage setHeaderActionMenu={jest.fn()}> <HomePage>
<span /> <span />
</HomePage> </HomePage>
</TestProviders> </TestProviders>

View file

@ -8,7 +8,6 @@
import React from 'react'; import React from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import type { AppMountParameters } from '@kbn/core/public';
import { DragDropContextWrapper } from '../../common/components/drag_and_drop/drag_drop_context_wrapper'; import { DragDropContextWrapper } from '../../common/components/drag_and_drop/drag_drop_context_wrapper';
import { SecuritySolutionAppWrapper } from '../../common/components/page'; import { SecuritySolutionAppWrapper } from '../../common/components/page';
@ -33,10 +32,9 @@ import { AssistantOverlay } from '../../assistant/overlay';
interface HomePageProps { interface HomePageProps {
children: React.ReactNode; children: React.ReactNode;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
} }
const HomePageComponent: React.FC<HomePageProps> = ({ children, setHeaderActionMenu }) => { const HomePageComponent: React.FC<HomePageProps> = ({ children }) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
useInitSourcerer(getScopeFromPath(pathname)); useInitSourcerer(getScopeFromPath(pathname));
useUrlState(); useUrlState();
@ -58,7 +56,7 @@ const HomePageComponent: React.FC<HomePageProps> = ({ children, setHeaderActionM
<ConsoleManager> <ConsoleManager>
<TourContextProvider> <TourContextProvider>
<> <>
<GlobalHeader setHeaderActionMenu={setHeaderActionMenu} /> <GlobalHeader />
<DragDropContextWrapper browserFields={browserFields}> <DragDropContextWrapper browserFields={browserFields}>
{children} {children}
</DragDropContextWrapper> </DragDropContextWrapper>

View file

@ -16,7 +16,6 @@ export const renderApp = ({
element, element,
history, history,
onAppLeave, onAppLeave,
setHeaderActionMenu,
services, services,
store, store,
usageCollection, usageCollection,
@ -31,7 +30,6 @@ export const renderApp = ({
history={history} history={history}
onAppLeave={onAppLeave} onAppLeave={onAppLeave}
services={services} services={services}
setHeaderActionMenu={setHeaderActionMenu}
store={store} store={store}
theme$={theme$} theme$={theme$}
> >

View file

@ -10,7 +10,7 @@ import type { FC } from 'react';
import React, { memo, useEffect } from 'react'; import React, { memo, useEffect } from 'react';
import { Router, Routes, Route } from '@kbn/shared-ux-router'; import { Router, Routes, Route } from '@kbn/shared-ux-router';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import type { AppLeaveHandler, AppMountParameters } from '@kbn/core/public'; import type { AppLeaveHandler } from '@kbn/core/public';
import { APP_ID } from '../../common/constants'; import { APP_ID } from '../../common/constants';
import { RouteCapture } from '../common/components/endpoint/route_capture'; import { RouteCapture } from '../common/components/endpoint/route_capture';
@ -24,15 +24,9 @@ interface RouterProps {
children: React.ReactNode; children: React.ReactNode;
history: History; history: History;
onAppLeave: (handler: AppLeaveHandler) => void; onAppLeave: (handler: AppLeaveHandler) => void;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
} }
const PageRouterComponent: FC<RouterProps> = ({ const PageRouterComponent: FC<RouterProps> = ({ children, history, onAppLeave }) => {
children,
history,
onAppLeave,
setHeaderActionMenu,
}) => {
const { cases } = useKibana().services; const { cases } = useKibana().services;
const CasesContext = cases.ui.getCasesContext(); const CasesContext = cases.ui.getCasesContext();
const userCasesPermissions = useGetUserCasesPermissions(); const userCasesPermissions = useGetUserCasesPermissions();
@ -55,7 +49,7 @@ const PageRouterComponent: FC<RouterProps> = ({
<Routes> <Routes>
<Route path="/"> <Route path="/">
<CasesContext owner={[APP_ID]} permissions={userCasesPermissions}> <CasesContext owner={[APP_ID]} permissions={userCasesPermissions}>
<HomePage setHeaderActionMenu={setHeaderActionMenu}>{children}</HomePage> <HomePage>{children}</HomePage>
</CasesContext> </CasesContext>
</Route> </Route>
<Route> <Route>

View file

@ -34,3 +34,14 @@ export const getTagsByName = jest
export const createTag = jest export const createTag = jest
.fn() .fn()
.mockImplementation(() => Promise.resolve(DEFAULT_CREATE_TAGS_RESPONSE[0])); .mockImplementation(() => Promise.resolve(DEFAULT_CREATE_TAGS_RESPONSE[0]));
export const fetchTags = jest.fn().mockImplementation(({ tagIds }: { tagIds: string[] }) =>
Promise.resolve(
tagIds.map((id, i) => ({
id,
name: `${MOCK_TAG_NAME}-${i}`,
description: 'test tag description',
color: '#2c7b8',
}))
)
);

View file

@ -121,6 +121,7 @@ export const createStartServicesMock = (
const cloudExperiments = cloudExperimentsMock.createStartMock(); const cloudExperiments = cloudExperimentsMock.createStartMock();
const guidedOnboarding = guidedOnboardingMock.createStart(); const guidedOnboarding = guidedOnboardingMock.createStart();
const cloud = cloudMock.createStart(); const cloud = cloudMock.createStart();
const mockSetHeaderActionMenu = jest.fn();
return { return {
...core, ...core,
@ -220,6 +221,7 @@ export const createStartServicesMock = (
customDataService, customDataService,
uiActions: uiActionsPluginMock.createStartContract(), uiActions: uiActionsPluginMock.createStartContract(),
savedSearch: savedSearchPluginMock.createStartContract(), savedSearch: savedSearchPluginMock.createStartContract(),
setHeaderActionMenu: mockSetHeaderActionMenu,
} as unknown as StartServices; } as unknown as StartServices;
}; };

View file

@ -12,13 +12,10 @@ import { DashboardRenderer as DashboardContainerRenderer } from '@kbn/dashboard-
import { TestProviders } from '../../common/mock'; import { TestProviders } from '../../common/mock';
import { DashboardRenderer } from './dashboard_renderer'; import { DashboardRenderer } from './dashboard_renderer';
jest.mock('@kbn/dashboard-plugin/public', () => { jest.mock('@kbn/dashboard-plugin/public', () => ({
const actual = jest.requireActual('@kbn/dashboard-plugin/public'); DashboardRenderer: jest.fn().mockReturnValue(<div data-test-subj="dashboardRenderer" />),
return { DashboardTopNav: jest.fn().mockReturnValue(<span data-test-subj="dashboardTopNav" />),
...actual, }));
DashboardRenderer: jest.fn().mockReturnValue(<div data-test-subj="dashboardRenderer" />),
};
});
jest.mock('react-router-dom', () => { jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom'); const actual = jest.requireActual('react-router-dom');

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import type { DashboardAPI } from '@kbn/dashboard-plugin/public'; import type { DashboardAPI, DashboardCreationOptions } from '@kbn/dashboard-plugin/public';
import { DashboardRenderer as DashboardContainerRenderer } from '@kbn/dashboard-plugin/public'; import { DashboardRenderer as DashboardContainerRenderer } from '@kbn/dashboard-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public'; import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { Filter, Query } from '@kbn/es-query'; import type { Filter, Query } from '@kbn/es-query';
@ -13,9 +13,14 @@ import type { Filter, Query } from '@kbn/es-query';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { InputsModelId } from '../../common/store/inputs/constants'; import { InputsModelId } from '../../common/store/inputs/constants';
import { inputsActions } from '../../common/store/inputs'; import { inputsActions } from '../../common/store/inputs';
import { useKibana } from '../../common/lib/kibana';
import { APP_UI_ID } from '../../../common';
import { useSecurityTags } from '../context/dashboard_context';
import { DASHBOARDS_PATH } from '../../../common/constants';
const DashboardRendererComponent = ({ const DashboardRendererComponent = ({
canReadDashboard, canReadDashboard,
dashboardContainer,
filters, filters,
id, id,
inputId = InputsModelId.global, inputId = InputsModelId.global,
@ -23,8 +28,10 @@ const DashboardRendererComponent = ({
query, query,
savedObjectId, savedObjectId,
timeRange, timeRange,
viewMode = ViewMode.VIEW,
}: { }: {
canReadDashboard: boolean; canReadDashboard: boolean;
dashboardContainer?: DashboardAPI;
filters?: Filter[]; filters?: Filter[];
id: string; id: string;
inputId?: InputsModelId.global | InputsModelId.timeline; inputId?: InputsModelId.global | InputsModelId.timeline;
@ -37,17 +44,36 @@ const DashboardRendererComponent = ({
to: string; to: string;
toStr?: string | undefined; toStr?: string | undefined;
}; };
viewMode?: ViewMode;
}) => { }) => {
const { embeddable } = useKibana().services;
const dispatch = useDispatch(); const dispatch = useDispatch();
const [dashboardContainer, setDashboardContainer] = useState<DashboardAPI>();
const getCreationOptions = useCallback( const securityTags = useSecurityTags();
const firstSecurityTagId = securityTags?.[0]?.id;
const isCreateDashboard = !savedObjectId;
const getCreationOptions: () => Promise<DashboardCreationOptions> = useCallback(
() => () =>
Promise.resolve({ Promise.resolve({
getInitialInput: () => ({ timeRange, viewMode: ViewMode.VIEW, query, filters }), useSessionStorageIntegration: true,
useControlGroupIntegration: true, useControlGroupIntegration: true,
getInitialInput: () => ({
timeRange,
viewMode,
query,
filters,
}),
getIncomingEmbeddable: () =>
embeddable.getStateTransfer().getIncomingEmbeddablePackage(APP_UI_ID, true),
getEmbeddableAppContext: (dashboardId?: string) => ({
getCurrentPath: () =>
dashboardId ? `${DASHBOARDS_PATH}/${dashboardId}/edit` : `${DASHBOARDS_PATH}/create`,
currentAppId: APP_UI_ID,
}),
}), }),
[filters, query, timeRange] [embeddable, filters, query, timeRange, viewMode]
); );
const refetchByForceRefresh = useCallback(() => { const refetchByForceRefresh = useCallback(() => {
@ -73,20 +99,33 @@ const DashboardRendererComponent = ({
dashboardContainer?.updateInput({ timeRange, query, filters }); dashboardContainer?.updateInput({ timeRange, query, filters });
}, [dashboardContainer, filters, query, timeRange]); }, [dashboardContainer, filters, query, timeRange]);
const handleDashboardLoaded = useCallback( useEffect(() => {
(container: DashboardAPI) => { if (isCreateDashboard && firstSecurityTagId)
setDashboardContainer(container); dashboardContainer?.updateInput({ tags: [firstSecurityTagId] });
onDashboardContainerLoaded?.(container); }, [dashboardContainer, firstSecurityTagId, isCreateDashboard]);
},
[onDashboardContainerLoaded] /** Dashboard renderer is stored in the state as it's a temporary solution for
); * https://github.com/elastic/kibana/issues/167751
return savedObjectId && canReadDashboard ? ( **/
<DashboardContainerRenderer const [dashboardContainerRenderer, setDashboardContainerRenderer] = useState<
ref={handleDashboardLoaded} React.ReactElement | undefined
savedObjectId={savedObjectId} >(undefined);
getCreationOptions={getCreationOptions}
/> useEffect(() => {
) : null; setDashboardContainerRenderer(
<DashboardContainerRenderer
ref={onDashboardContainerLoaded}
savedObjectId={savedObjectId}
getCreationOptions={getCreationOptions}
/>
);
return () => {
setDashboardContainerRenderer(undefined);
};
}, [getCreationOptions, onDashboardContainerLoaded, refetchByForceRefresh, savedObjectId]);
return canReadDashboard ? <>{dashboardContainerRenderer}</> : null;
}; };
DashboardRendererComponent.displayName = 'DashboardRendererComponent'; DashboardRendererComponent.displayName = 'DashboardRendererComponent';
export const DashboardRenderer = React.memo(DashboardRendererComponent); export const DashboardRenderer = React.memo(DashboardRendererComponent);

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import type { DashboardAPI } from '@kbn/dashboard-plugin/public';
import { EDIT_DASHBOARD_TITLE } from '../pages/details/translations';
const DashboardTitleComponent = ({
dashboardContainer,
onTitleLoaded,
}: {
dashboardContainer: DashboardAPI;
onTitleLoaded: (title: string) => void;
}) => {
const dashboardTitle = dashboardContainer.select((state) => state.explicitInput.title).trim();
const title =
dashboardTitle && dashboardTitle.length !== 0 ? dashboardTitle : EDIT_DASHBOARD_TITLE;
useEffect(() => {
onTitleLoaded(title);
}, [dashboardContainer, title, onTitleLoaded]);
return dashboardTitle != null ? <span>{title}</span> : <EuiLoadingSpinner size="m" />;
};
export const DashboardTitle = React.memo(DashboardTitleComponent);

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { DashboardToolBar } from './dashboard_tool_bar';
import type { DashboardAPI } from '@kbn/dashboard-plugin/public';
import { coreMock } from '@kbn/core/public/mocks';
import { DashboardTopNav } from '@kbn/dashboard-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { APP_NAME } from '../../../common/constants';
import { NavigationProvider, SecurityPageName } from '@kbn/security-solution-navigation';
import { TestProviders } from '../../common/mock';
import { useNavigation } from '../../common/lib/kibana';
const mockDashboardTopNav = DashboardTopNav as jest.Mock;
jest.mock('../../common/lib/kibana', () => {
const actual = jest.requireActual('../../common/lib/kibana');
return {
...actual,
useNavigation: jest.fn(),
useCapabilities: jest.fn(() => ({ showWriteControls: true })),
};
});
jest.mock('../../common/components/link_to', () => ({ useGetSecuritySolutionUrl: jest.fn() }));
jest.mock('@kbn/dashboard-plugin/public', () => ({
DashboardTopNav: jest.fn(() => <div data-test-subj="dashboard-top-nav" />),
}));
const mockCore = coreMock.createStart();
const mockNavigateTo = jest.fn();
const mockGetAppUrl = jest.fn();
const mockDashboardContainer = {
select: jest.fn(),
} as unknown as DashboardAPI;
const wrapper = ({ children }: { children: React.ReactNode }) => (
<TestProviders>
<NavigationProvider core={mockCore}>{children}</NavigationProvider>
</TestProviders>
);
describe('DashboardToolBar', () => {
const mockOnLoad = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useNavigation as jest.Mock).mockReturnValue({
navigateTo: mockNavigateTo,
getAppUrl: mockGetAppUrl,
});
render(<DashboardToolBar onLoad={mockOnLoad} dashboardContainer={mockDashboardContainer} />, {
wrapper,
});
});
it('should render the DashboardToolBar component', () => {
expect(screen.getByTestId('dashboard-top-nav')).toBeInTheDocument();
});
it('should render the DashboardToolBar component with the correct props for view mode', () => {
expect(mockOnLoad).toHaveBeenCalledWith(ViewMode.VIEW);
});
it('should render the DashboardTopNav component with the correct redirect to listing url', () => {
mockDashboardTopNav.mock.calls[0][0].redirectTo({ destination: 'listing' });
});
it('should render the DashboardTopNav component with the correct breadcrumb', () => {
expect(mockGetAppUrl.mock.calls[0][0].deepLinkId).toEqual(SecurityPageName.landing);
expect(mockDashboardTopNav.mock.calls[0][0].customLeadingBreadCrumbs[0].text).toEqual(APP_NAME);
});
it('should render the DashboardTopNav component with the correct redirect to create dashboard url', () => {
mockDashboardTopNav.mock.calls[0][0].redirectTo({ destination: 'dashboard' });
expect(mockNavigateTo.mock.calls[0][0].deepLinkId).toEqual(SecurityPageName.dashboards);
expect(mockNavigateTo.mock.calls[0][0].path).toEqual(`/create`);
});
it('should render the DashboardTopNav component with the correct redirect to edit dashboard url', () => {
const mockDashboardId = 'dashboard123';
mockDashboardTopNav.mock.calls[0][0].redirectTo({
destination: 'dashboard',
id: mockDashboardId,
});
expect(mockNavigateTo.mock.calls[0][0].deepLinkId).toEqual(SecurityPageName.dashboards);
expect(mockNavigateTo.mock.calls[0][0].path).toEqual(`${mockDashboardId}/edit`);
});
it('should render the DashboardTopNav component with the correct props', () => {
expect(mockDashboardTopNav.mock.calls[0][0].embedSettings).toEqual(
expect.objectContaining({
forceHideFilterBar: true,
forceShowTopNavMenu: true,
forceShowDatePicker: false,
forceShowQueryInput: false,
})
);
});
});

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useMemo } from 'react';
import type { DashboardAPI } from '@kbn/dashboard-plugin/public';
import { DashboardTopNav, LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { ChromeBreadcrumb } from '@kbn/core/public';
import type { DashboardCapabilities } from '@kbn/dashboard-plugin/common';
import { SecurityPageName } from '../../../common';
import { useCapabilities, useKibana, useNavigation } from '../../common/lib/kibana';
import { APP_NAME } from '../../../common/constants';
const DashboardToolBarComponent = ({
dashboardContainer,
onLoad,
}: {
dashboardContainer: DashboardAPI;
onLoad?: (mode: ViewMode) => void;
}) => {
const { setHeaderActionMenu } = useKibana().services;
const viewMode =
dashboardContainer?.select((state) => state.explicitInput.viewMode) ?? ViewMode.VIEW;
const { navigateTo, getAppUrl } = useNavigation();
const redirectTo = useCallback(
({ destination, id }) => {
if (destination === 'listing') {
navigateTo({ deepLinkId: SecurityPageName.dashboards });
}
if (destination === 'dashboard') {
navigateTo({
deepLinkId: SecurityPageName.dashboards,
path: id ? `${id}/edit` : `/create`,
});
}
},
[navigateTo]
);
const landingBreadcrumb: ChromeBreadcrumb[] = useMemo(
() => [
{
text: APP_NAME,
href: getAppUrl({ deepLinkId: SecurityPageName.landing }),
},
],
[getAppUrl]
);
useEffect(() => {
onLoad?.(viewMode);
}, [onLoad, viewMode]);
const embedSettings = useMemo(
() => ({
forceHideFilterBar: true,
forceShowTopNavMenu: true,
forceShowQueryInput: false,
forceShowDatePicker: false,
}),
[]
);
const { showWriteControls } = useCapabilities<DashboardCapabilities>(LEGACY_DASHBOARD_APP_ID);
return showWriteControls ? (
<DashboardTopNav
customLeadingBreadCrumbs={landingBreadcrumb}
dashboardContainer={dashboardContainer}
forceHideUnifiedSearch={true}
embedSettings={embedSettings}
redirectTo={redirectTo}
showBorderBottom={false}
setCustomHeaderActionMenu={setHeaderActionMenu}
showResetChange={false}
/>
) : null;
};
export const DashboardToolBar = React.memo(DashboardToolBarComponent);

View file

@ -1,81 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RenderResult } from '@testing-library/react';
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import type { Query } from '@kbn/es-query';
import { useKibana } from '../../common/lib/kibana';
import { TestProviders } from '../../common/mock/test_providers';
import type { EditDashboardButtonComponentProps } from './edit_dashboard_button';
import { EditDashboardButton } from './edit_dashboard_button';
import { ViewMode } from '@kbn/embeddable-plugin/public';
jest.mock('../../common/lib/kibana/kibana_react', () => {
return {
useKibana: jest.fn(),
};
});
describe('EditDashboardButton', () => {
const timeRange = {
from: '2023-03-24T00:00:00.000Z',
to: '2023-03-24T23:59:59.999Z',
};
const props = {
filters: [],
query: { query: '', language: '' } as Query,
savedObjectId: 'mockSavedObjectId',
timeRange,
};
const servicesMock = {
dashboard: { locator: { getRedirectUrl: jest.fn() } },
application: {
navigateToApp: jest.fn(),
navigateToUrl: jest.fn(),
},
};
const renderButton = (testProps: EditDashboardButtonComponentProps) => {
return render(
<TestProviders>
<EditDashboardButton {...testProps} />
</TestProviders>
);
};
let renderResult: RenderResult;
beforeEach(() => {
(useKibana as jest.Mock).mockReturnValue({
services: servicesMock,
});
renderResult = renderButton(props);
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should render', () => {
expect(renderResult.queryByTestId('dashboardEditButton')).toBeInTheDocument();
});
it('should render dashboard edit url', () => {
fireEvent.click(renderResult.getByTestId('dashboardEditButton'));
expect(servicesMock.dashboard?.locator?.getRedirectUrl).toHaveBeenCalledWith(
expect.objectContaining({
query: props.query,
filters: props.filters,
timeRange: props.timeRange,
dashboardId: props.savedObjectId,
viewMode: ViewMode.EDIT,
})
);
});
});

View file

@ -1,68 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import type { Query, Filter } from '@kbn/es-query';
import { EuiButton } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { EDIT_DASHBOARD_BUTTON_TITLE } from '../pages/details/translations';
import { useKibana, useNavigation } from '../../common/lib/kibana';
export interface EditDashboardButtonComponentProps {
filters?: Filter[];
query?: Query;
savedObjectId: string | undefined;
timeRange: {
from: string;
to: string;
fromStr?: string | undefined;
toStr?: string | undefined;
};
}
const EditDashboardButtonComponent: React.FC<EditDashboardButtonComponentProps> = ({
filters,
query,
savedObjectId,
timeRange,
}) => {
const {
services: { dashboard },
} = useKibana();
const { navigateTo } = useNavigation();
const onClick = useCallback(
(e) => {
e.preventDefault();
const url = dashboard?.locator?.getRedirectUrl({
query,
filters,
timeRange,
dashboardId: savedObjectId,
viewMode: ViewMode.EDIT,
});
if (url) {
navigateTo({ url });
}
},
[dashboard?.locator, query, filters, timeRange, savedObjectId, navigateTo]
);
return (
<EuiButton
color="primary"
data-test-subj="dashboardEditButton"
fill
iconType="pencil"
onClick={onClick}
>
{EDIT_DASHBOARD_BUTTON_TITLE}
</EuiButton>
);
};
EditDashboardButtonComponent.displayName = 'EditDashboardComponent';
export const EditDashboardButton = React.memo(EditDashboardButtonComponent);

View file

@ -20,7 +20,6 @@ const DashboardContext = React.createContext<DashboardContextType | null>({ secu
export const DashboardContextProvider: React.FC = ({ children }) => { export const DashboardContextProvider: React.FC = ({ children }) => {
const { tags, isLoading } = useFetchSecurityTags(); const { tags, isLoading } = useFetchSecurityTags();
const securityTags = isLoading || !tags ? null : tags; const securityTags = isLoading || !tags ? null : tags;
return <DashboardContext.Provider value={{ securityTags }}>{children}</DashboardContext.Provider>; return <DashboardContext.Provider value={{ securityTags }}>{children}</DashboardContext.Provider>;

View file

@ -1,19 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const DASHBOARD_TITLE = i18n.translate('xpack.securitySolution.dashboards.title', {
defaultMessage: 'Title',
});
export const DASHBOARDS_DESCRIPTION = i18n.translate(
'xpack.securitySolution.dashboards.description',
{
defaultMessage: 'Description',
}
);

View file

@ -6,23 +6,37 @@
*/ */
import { renderHook, act } from '@testing-library/react-hooks'; import { renderHook, act } from '@testing-library/react-hooks';
import type { DashboardStart } from '@kbn/dashboard-plugin/public';
import { useKibana } from '../../common/lib/kibana'; import { useKibana } from '../../common/lib/kibana';
import { useCreateSecurityDashboardLink } from './use_create_security_dashboard_link'; import { useCreateSecurityDashboardLink } from './use_create_security_dashboard_link';
import { DashboardContextProvider } from '../context/dashboard_context'; import { DashboardContextProvider } from '../context/dashboard_context';
import { getTagsByName } from '../../common/containers/tags/api'; import { getTagsByName } from '../../common/containers/tags/api';
import React from 'react';
import { TestProviders } from '../../common/mock';
jest.mock('../../common/lib/kibana'); jest.mock('@kbn/security-solution-navigation/src/context');
jest.mock('../../common/lib/kibana', () => ({
useKibana: jest.fn(),
}));
jest.mock('../../common/containers/tags/api'); jest.mock('../../common/containers/tags/api');
const URL = '/path'; jest.mock('../../common/lib/apm/use_track_http_request');
jest.mock('../../common/components/link_to', () => ({
useGetSecuritySolutionUrl: jest
.fn()
.mockReturnValue(jest.fn().mockReturnValue('/app/security/dashboards/create')),
}));
const renderUseCreateSecurityDashboardLink = () => const renderUseCreateSecurityDashboardLink = () =>
renderHook(() => useCreateSecurityDashboardLink(), { renderHook(() => useCreateSecurityDashboardLink(), {
wrapper: DashboardContextProvider, wrapper: ({ children }) => (
<TestProviders>
<DashboardContextProvider>{children}</DashboardContextProvider>
</TestProviders>
),
}); });
const asyncRenderUseCreateSecurityDashboard = async () => { const asyncRenderUseCreateSecurityDashboard = async () => {
const renderedHook = renderUseCreateSecurityDashboardLink(); const renderedHook = renderUseCreateSecurityDashboardLink();
await act(async () => { await act(async () => {
await renderedHook.waitForNextUpdate(); await renderedHook.waitForNextUpdate();
}); });
@ -30,12 +44,15 @@ const asyncRenderUseCreateSecurityDashboard = async () => {
}; };
describe('useCreateSecurityDashboardLink', () => { describe('useCreateSecurityDashboardLink', () => {
const mockGetRedirectUrl = jest.fn(() => URL);
beforeAll(() => { beforeAll(() => {
useKibana().services.dashboard = { (useKibana as jest.Mock).mockReturnValue({
locator: { getRedirectUrl: mockGetRedirectUrl }, services: {
} as unknown as DashboardStart; savedObjectsTagging: {
create: jest.fn(),
},
http: { get: jest.fn() },
},
});
}); });
afterEach(() => { afterEach(() => {
@ -55,8 +72,7 @@ describe('useCreateSecurityDashboardLink', () => {
const result1 = result.current; const result1 = result.current;
act(() => rerender()); act(() => rerender());
const result2 = result.current; const result2 = result.current;
expect(result1).toEqual(result2);
expect(result1).toBe(result2);
}); });
it('should not re-request tag id when re-rendered', async () => { it('should not re-request tag id when re-rendered', async () => {
@ -71,14 +87,14 @@ describe('useCreateSecurityDashboardLink', () => {
const { result, waitForNextUpdate } = renderUseCreateSecurityDashboardLink(); const { result, waitForNextUpdate } = renderUseCreateSecurityDashboardLink();
expect(result.current.isLoading).toEqual(true); expect(result.current.isLoading).toEqual(true);
expect(result.current.url).toEqual(''); expect(result.current.url).toEqual('/app/security/dashboards/create');
await act(async () => { await act(async () => {
await waitForNextUpdate(); await waitForNextUpdate();
}); });
expect(result.current.isLoading).toEqual(false); expect(result.current.isLoading).toEqual(false);
expect(result.current.url).toEqual(URL); expect(result.current.url).toEqual('/app/security/dashboards/create');
}); });
}); });
}); });

View file

@ -7,24 +7,28 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSecurityTags } from '../context/dashboard_context'; import { useSecurityTags } from '../context/dashboard_context';
import { useKibana } from '../../common/lib/kibana'; import { useGetSecuritySolutionUrl } from '../../common/components/link_to';
import { SecurityPageName } from '../../../common';
type UseCreateDashboard = () => { isLoading: boolean; url: string }; type UseCreateDashboard = () => { isLoading: boolean; url: string };
export const useCreateSecurityDashboardLink: UseCreateDashboard = () => { export const useCreateSecurityDashboardLink: UseCreateDashboard = () => {
const { dashboard } = useKibana().services; const getSecuritySolutionUrl = useGetSecuritySolutionUrl();
const securityTags = useSecurityTags(); const securityTags = useSecurityTags();
const url = getSecuritySolutionUrl({
deepLinkId: SecurityPageName.dashboards,
path: 'create',
});
const result = useMemo(() => { const result = useMemo(() => {
const firstSecurityTagId = securityTags?.[0]?.id; const firstSecurityTagId = securityTags?.[0]?.id;
if (!firstSecurityTagId) { if (!firstSecurityTagId) {
return { isLoading: true, url: '' }; return { isLoading: true, url };
} }
return { return {
isLoading: false, isLoading: false,
url: dashboard?.locator?.getRedirectUrl({ tags: [firstSecurityTagId] }) ?? '', url,
}; };
}, [securityTags, dashboard?.locator]); }, [securityTags, url]);
return result; return result;
}; };

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import type { DashboardAPI } from '@kbn/dashboard-plugin/public';
import { useDashboardRenderer } from './use_dashboard_renderer';
jest.mock('../../common/lib/kibana');
const mockDashboardContainer = { getExplicitInput: () => ({ tags: ['tagId'] }) } as DashboardAPI;
describe('useDashboardRenderer', () => {
it('should set dashboard container correctly when dashboard is loaded', async () => {
const { result } = renderHook(() => useDashboardRenderer());
await act(async () => {
await result.current.handleDashboardLoaded(mockDashboardContainer);
});
expect(result.current.dashboardContainer).toEqual(mockDashboardContainer);
});
});

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useMemo, useState } from 'react';
import type { DashboardAPI } from '@kbn/dashboard-plugin/public';
export const useDashboardRenderer = () => {
const [dashboardContainer, setDashboardContainer] = useState<DashboardAPI>();
const handleDashboardLoaded = useCallback((container: DashboardAPI) => {
setDashboardContainer(container);
}, []);
return useMemo(
() => ({
dashboardContainer,
handleDashboardLoaded,
}),
[dashboardContainer, handleDashboardLoaded]
);
};

View file

@ -8,9 +8,9 @@
import React, { useMemo, useCallback } from 'react'; import React, { useMemo, useCallback } from 'react';
import type { MouseEventHandler } from 'react'; import type { MouseEventHandler } from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui'; import type { EuiBasicTableColumn } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { LinkAnchor } from '../../common/components/links'; import { LinkAnchor } from '../../common/components/links';
import { useKibana, useNavigateTo } from '../../common/lib/kibana'; import { useKibana, useNavigateTo } from '../../common/lib/kibana';
import * as i18n from './translations';
import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../common/lib/telemetry'; import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../common/lib/telemetry';
import { SecurityPageName } from '../../../common/constants'; import { SecurityPageName } from '../../../common/constants';
import { useGetSecuritySolutionUrl } from '../../common/components/link_to'; import { useGetSecuritySolutionUrl } from '../../common/components/link_to';
@ -56,7 +56,9 @@ export const useSecurityDashboardsTableColumns = (): Array<
(): Array<EuiBasicTableColumn<DashboardTableItem>> => [ (): Array<EuiBasicTableColumn<DashboardTableItem>> => [
{ {
field: 'title', field: 'title',
name: i18n.DASHBOARD_TITLE, name: i18n.translate('xpack.securitySolution.dashboards.title', {
defaultMessage: 'Title',
}),
sortable: true, sortable: true,
render: (title: string, { id }) => { render: (title: string, { id }) => {
const href = `${getSecuritySolutionUrl({ const href = `${getSecuritySolutionUrl({
@ -75,7 +77,9 @@ export const useSecurityDashboardsTableColumns = (): Array<
}, },
{ {
field: 'description', field: 'description',
name: i18n.DASHBOARDS_DESCRIPTION, name: i18n.translate('xpack.securitySolution.dashboards.description', {
defaultMessage: 'Description',
}),
sortable: true, sortable: true,
render: (description: string) => description || getEmptyValue(), render: (description: string) => description || getEmptyValue(),
'data-test-subj': 'dashboardTableDescriptionCell', 'data-test-subj': 'dashboardTableDescriptionCell',

View file

@ -5,7 +5,9 @@
* 2.0. * 2.0.
*/ */
import { matchPath } from 'react-router-dom';
import type { GetTrailingBreadcrumbs } from '../../common/components/navigation/breadcrumbs/types'; import type { GetTrailingBreadcrumbs } from '../../common/components/navigation/breadcrumbs/types';
import { CREATE_DASHBOARD_TITLE } from './translations';
/** /**
* This module should only export this function. * This module should only export this function.
@ -13,6 +15,10 @@ import type { GetTrailingBreadcrumbs } from '../../common/components/navigation/
* We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size. * We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size.
*/ */
export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs = (params, getSecuritySolutionUrl) => { export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs = (params, getSecuritySolutionUrl) => {
if (matchPath(params.pathName, { path: '/create' })) {
return [{ text: CREATE_DASHBOARD_TITLE }];
}
const breadcrumbName = params?.state?.dashboardName; const breadcrumbName = params?.state?.dashboardName;
if (breadcrumbName) { if (breadcrumbName) {
return [{ text: breadcrumbName }]; return [{ text: breadcrumbName }];

View file

@ -11,6 +11,7 @@ import { Router } from '@kbn/shared-ux-router';
import { DashboardView } from '.'; import { DashboardView } from '.';
import { useCapabilities } from '../../../common/lib/kibana'; import { useCapabilities } from '../../../common/lib/kibana';
import { TestProviders } from '../../../common/mock'; import { TestProviders } from '../../../common/mock';
import { ViewMode } from '@kbn/embeddable-plugin/public';
jest.mock('react-router-dom', () => { jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom'); const actual = jest.requireActual('react-router-dom');
@ -68,7 +69,7 @@ describe('DashboardView', () => {
test('render when no error state', () => { test('render when no error state', () => {
const { queryByTestId } = render( const { queryByTestId } = render(
<Router history={mockHistory}> <Router history={mockHistory}>
<DashboardView /> <DashboardView initialViewMode={ViewMode.VIEW} />
</Router>, </Router>,
{ wrapper: TestProviders } { wrapper: TestProviders }
); );
@ -83,7 +84,7 @@ describe('DashboardView', () => {
}); });
const { queryByTestId } = render( const { queryByTestId } = render(
<Router history={mockHistory}> <Router history={mockHistory}>
<DashboardView /> <DashboardView initialViewMode={ViewMode.VIEW} />
</Router>, </Router>,
{ wrapper: TestProviders } { wrapper: TestProviders }
); );
@ -95,7 +96,7 @@ describe('DashboardView', () => {
test('render dashboard view with height', () => { test('render dashboard view with height', () => {
const { queryByTestId } = render( const { queryByTestId } = render(
<Router history={mockHistory}> <Router history={mockHistory}>
<DashboardView /> <DashboardView initialViewMode={ViewMode.VIEW} />
</Router>, </Router>,
{ wrapper: TestProviders } { wrapper: TestProviders }
); );

View file

@ -7,13 +7,12 @@
import React, { useState, useCallback, useMemo } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; import { LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public';
import type { DashboardAPI } from '@kbn/dashboard-plugin/public';
import type { DashboardCapabilities } from '@kbn/dashboard-plugin/common/types'; import type { DashboardCapabilities } from '@kbn/dashboard-plugin/common/types';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { pick } from 'lodash/fp'; import { pick } from 'lodash/fp';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { ViewMode } from '@kbn/embeddable-plugin/common';
import { SecurityPageName } from '../../../../common/constants'; import { SecurityPageName } from '../../../../common/constants';
import { SpyRoute } from '../../../common/utils/route/spy_routes'; import { SpyRoute } from '../../../common/utils/route/spy_routes';
import { useCapabilities } from '../../../common/lib/kibana'; import { useCapabilities } from '../../../common/lib/kibana';
@ -26,16 +25,22 @@ import { FiltersGlobal } from '../../../common/components/filters_global';
import { InputsModelId } from '../../../common/store/inputs/constants'; import { InputsModelId } from '../../../common/store/inputs/constants';
import { useSourcererDataView } from '../../../common/containers/sourcerer'; import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { HeaderPage } from '../../../common/components/header_page'; import { HeaderPage } from '../../../common/components/header_page';
import { DASHBOARD_NOT_FOUND_TITLE } from './translations';
import { inputsSelectors } from '../../../common/store'; import { inputsSelectors } from '../../../common/store';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { EditDashboardButton } from '../../components/edit_dashboard_button'; import { DashboardToolBar } from '../../components/dashboard_tool_bar';
type DashboardDetails = Record<string, string>; import { useDashboardRenderer } from '../../hooks/use_dashboard_renderer';
import { DashboardTitle } from '../../components/dashboard_title';
interface DashboardViewProps {
initialViewMode: ViewMode;
}
const dashboardViewFlexGroupStyle = { minHeight: `calc(100vh - 140px)` }; const dashboardViewFlexGroupStyle = { minHeight: `calc(100vh - 140px)` };
const DashboardViewComponent: React.FC = () => { const DashboardViewComponent: React.FC<DashboardViewProps> = ({
initialViewMode,
}: DashboardViewProps) => {
const { fromStr, toStr, from, to } = useDeepEqualSelector((state) => const { fromStr, toStr, from, to } = useDeepEqualSelector((state) =>
pick(['fromStr', 'toStr', 'from', 'to'], inputsSelectors.globalTimeRangeSelector(state)) pick(['fromStr', 'toStr', 'from', 'to'], inputsSelectors.globalTimeRangeSelector(state))
); );
@ -47,36 +52,28 @@ const DashboardViewComponent: React.FC = () => {
); );
const query = useDeepEqualSelector(getGlobalQuerySelector); const query = useDeepEqualSelector(getGlobalQuerySelector);
const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector);
const { indexPattern, indicesExist } = useSourcererDataView(); const { indexPattern } = useSourcererDataView();
const { show: canReadDashboard, showWriteControls } = const { show: canReadDashboard } =
useCapabilities<DashboardCapabilities>(LEGACY_DASHBOARD_APP_ID); useCapabilities<DashboardCapabilities>(LEGACY_DASHBOARD_APP_ID);
const errorState = useMemo( const errorState = useMemo(
() => (canReadDashboard ? null : DashboardViewPromptState.NoReadPermission), () => (canReadDashboard ? null : DashboardViewPromptState.NoReadPermission),
[canReadDashboard] [canReadDashboard]
); );
const [dashboardDetails, setDashboardDetails] = useState<DashboardDetails | undefined>(); const [viewMode, setViewMode] = useState<ViewMode>(initialViewMode);
const onDashboardContainerLoaded = useCallback((dashboard: DashboardAPI) => {
if (dashboard) {
const title = dashboard.getTitle().trim();
if (title) {
setDashboardDetails({ title });
} else {
setDashboardDetails({ title: DASHBOARD_NOT_FOUND_TITLE });
}
}
}, []);
const dashboardExists = useMemo(() => dashboardDetails != null, [dashboardDetails]);
const { detailName: savedObjectId } = useParams<{ detailName?: string }>(); const { detailName: savedObjectId } = useParams<{ detailName?: string }>();
const [dashboardTitle, setDashboardTitle] = useState<string>();
const { dashboardContainer, handleDashboardLoaded } = useDashboardRenderer();
const onDashboardToolBarLoad = useCallback((mode: ViewMode) => {
setViewMode(mode);
}, []);
return ( return (
<> <>
{indicesExist && ( <FiltersGlobal>
<FiltersGlobal> <SiemSearchBar id={InputsModelId.global} indexPattern={indexPattern} />
<SiemSearchBar id={InputsModelId.global} indexPattern={indexPattern} /> </FiltersGlobal>
</FiltersGlobal>
)}
<SecuritySolutionPageWrapper> <SecuritySolutionPageWrapper>
<EuiFlexGroup <EuiFlexGroup
direction="column" direction="column"
@ -85,16 +82,23 @@ const DashboardViewComponent: React.FC = () => {
data-test-subj="dashboard-view-wrapper" data-test-subj="dashboard-view-wrapper"
> >
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<HeaderPage border title={dashboardDetails?.title ?? <EuiLoadingSpinner size="m" />}> {dashboardContainer && (
{showWriteControls && dashboardExists && ( <HeaderPage
<EditDashboardButton border
filters={filters} title={
query={query} <DashboardTitle
savedObjectId={savedObjectId} dashboardContainer={dashboardContainer}
timeRange={timeRange} onTitleLoaded={setDashboardTitle}
/> />
)} }
</HeaderPage> subtitle={
<DashboardToolBar
dashboardContainer={dashboardContainer}
onLoad={onDashboardToolBarLoad}
/>
}
/>
)}
</EuiFlexItem> </EuiFlexItem>
{!errorState && ( {!errorState && (
<EuiFlexItem grow> <EuiFlexItem grow>
@ -102,10 +106,12 @@ const DashboardViewComponent: React.FC = () => {
query={query} query={query}
filters={filters} filters={filters}
canReadDashboard={canReadDashboard} canReadDashboard={canReadDashboard}
dashboardContainer={dashboardContainer}
id={`dashboard-view-${savedObjectId}`} id={`dashboard-view-${savedObjectId}`}
onDashboardContainerLoaded={onDashboardContainerLoaded} onDashboardContainerLoaded={handleDashboardLoaded}
savedObjectId={savedObjectId} savedObjectId={savedObjectId}
timeRange={timeRange} timeRange={timeRange}
viewMode={viewMode}
/> />
</EuiFlexItem> </EuiFlexItem>
)} )}
@ -116,7 +122,7 @@ const DashboardViewComponent: React.FC = () => {
)} )}
<SpyRoute <SpyRoute
pageName={SecurityPageName.dashboards} pageName={SecurityPageName.dashboards}
state={{ dashboardName: dashboardDetails?.title }} state={{ dashboardName: dashboardTitle }}
/> />
</EuiFlexGroup> </EuiFlexGroup>
</SecuritySolutionPageWrapper> </SecuritySolutionPageWrapper>

View file

@ -40,3 +40,24 @@ export const EDIT_DASHBOARD_BUTTON_TITLE = i18n.translate(
defaultMessage: `Edit`, defaultMessage: `Edit`,
} }
); );
export const EDIT_DASHBOARD_TITLE = i18n.translate(
'xpack.securitySolution.dashboards.dashboard.editDashboardTitle',
{
defaultMessage: `Editing new dashboard`,
}
);
export const VIEW_DASHBOARD_BUTTON_TITLE = i18n.translate(
'xpack.securitySolution.dashboards.dashboard.viewDashboardButtonTitle',
{
defaultMessage: `Switch to view mode`,
}
);
export const SAVE_DASHBOARD_BUTTON_TITLE = i18n.translate(
'xpack.securitySolution.dashboards.dashboard.saveDashboardButtonTitle',
{
defaultMessage: `Save`,
}
);

View file

@ -7,6 +7,7 @@
import React from 'react'; import React from 'react';
import { Routes, Route } from '@kbn/shared-ux-router'; import { Routes, Route } from '@kbn/shared-ux-router';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { DashboardsLandingPage } from './landing_page'; import { DashboardsLandingPage } from './landing_page';
import { DashboardView } from './details'; import { DashboardView } from './details';
import { DASHBOARDS_PATH } from '../../../common/constants'; import { DASHBOARDS_PATH } from '../../../common/constants';
@ -16,8 +17,14 @@ const DashboardsContainerComponent = () => {
return ( return (
<DashboardContextProvider> <DashboardContextProvider>
<Routes> <Routes>
<Route strict path={`${DASHBOARDS_PATH}/create`}>
<DashboardView initialViewMode={ViewMode.EDIT} />
</Route>
<Route strict path={`${DASHBOARDS_PATH}/:detailName/edit`}>
<DashboardView initialViewMode={ViewMode.EDIT} />
</Route>
<Route strict path={`${DASHBOARDS_PATH}/:detailName`}> <Route strict path={`${DASHBOARDS_PATH}/:detailName`}>
<DashboardView /> <DashboardView initialViewMode={ViewMode.VIEW} />
</Route> </Route>
<Route path={`${DASHBOARDS_PATH}`}> <Route path={`${DASHBOARDS_PATH}`}>
<DashboardsLandingPage /> <DashboardsLandingPage />

View file

@ -23,13 +23,10 @@ import { DASHBOARDS_PAGE_SECTION_CUSTOM } from './translations';
jest.mock('../../../common/containers/tags/api'); jest.mock('../../../common/containers/tags/api');
jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/lib/kibana');
jest.mock('../../../common/utils/route/spy_routes', () => ({ SpyRoute: () => null })); jest.mock('../../../common/utils/route/spy_routes', () => ({ SpyRoute: () => null }));
jest.mock('@kbn/dashboard-plugin/public', () => { jest.mock('@kbn/dashboard-plugin/public', () => ({
const actual = jest.requireActual('@kbn/dashboard-plugin/public'); DashboardListingTable: jest.fn().mockReturnValue(<span data-test-subj="dashboardsTable" />),
return { DashboardTopNav: jest.fn().mockReturnValue(<span data-test-subj="dashboardTopNav" />),
...actual, }));
DashboardListingTable: jest.fn().mockReturnValue(<span data-test-subj="dashboardsTable" />),
};
});
const mockUseObservable = jest.fn(); const mockUseObservable = jest.fn();

View file

@ -99,20 +99,18 @@ export const DashboardsLandingPage = () => {
})}`, })}`,
[getSecuritySolutionUrl] [getSecuritySolutionUrl]
); );
const { isLoading: loadingCreateDashboardUrl, url: createDashboardUrl } =
useCreateSecurityDashboardLink();
const getHref = useCallback(
(id: string | undefined) => (id ? getSecuritySolutionDashboardUrl(id) : createDashboardUrl),
[createDashboardUrl, getSecuritySolutionDashboardUrl]
);
const goToDashboard = useCallback( const goToDashboard = useCallback(
(dashboardId: string | undefined) => { (dashboardId: string | undefined) => {
track(METRIC_TYPE.CLICK, TELEMETRY_EVENT.DASHBOARD); track(METRIC_TYPE.CLICK, TELEMETRY_EVENT.DASHBOARD);
navigateTo({ url: getHref(dashboardId) }); navigateTo({
url: getSecuritySolutionUrl({
deepLinkId: SecurityPageName.dashboards,
path: dashboardId ?? 'create',
}),
});
}, },
[getHref, navigateTo] [getSecuritySolutionUrl, navigateTo]
); );
const securityTags = useSecurityTags(); const securityTags = useSecurityTags();
@ -151,7 +149,7 @@ export const DashboardsLandingPage = () => {
<EuiHorizontalRule margin="s" /> <EuiHorizontalRule margin="s" />
<EuiSpacer size="m" /> <EuiSpacer size="m" />
<DashboardListingTable <DashboardListingTable
disableCreateDashboardButton={loadingCreateDashboardUrl} disableCreateDashboardButton={!canCreateDashboard}
getDashboardUrl={getSecuritySolutionDashboardUrl} getDashboardUrl={getSecuritySolutionDashboardUrl}
goToDashboard={goToDashboard} goToDashboard={goToDashboard}
initialFilter={initialFilter} initialFilter={initialFilter}

View file

@ -9,3 +9,10 @@ import { i18n } from '@kbn/i18n';
export const DASHBOARDS_PAGE_TITLE = i18n.translate('xpack.securitySolution.dashboards.pageTitle', { export const DASHBOARDS_PAGE_TITLE = i18n.translate('xpack.securitySolution.dashboards.pageTitle', {
defaultMessage: 'Dashboards', defaultMessage: 'Dashboards',
}); });
export const CREATE_DASHBOARD_TITLE = i18n.translate(
'xpack.securitySolution.dashboards.dashboard.createDashboardTitle',
{
defaultMessage: `Editing New Dashboard`,
}
);

View file

@ -21,6 +21,7 @@ import {
APP_UI_ID, APP_UI_ID,
CASES_FEATURE_ID, CASES_FEATURE_ID,
CASES_PATH, CASES_PATH,
DASHBOARDS_PATH,
EXCEPTIONS_PATH, EXCEPTIONS_PATH,
RULES_PATH, RULES_PATH,
SERVER_APP_ID, SERVER_APP_ID,
@ -173,6 +174,13 @@ export const isDetectionsPath = (pathname: string): boolean => {
}); });
}; };
export const isDashboardViewPath = (pathname: string): boolean =>
matchPath(pathname, {
path: `/${DASHBOARDS_PATH}/:id`,
exact: false,
strict: false,
}) != null;
const isAlertsPath = (pathname: string): boolean => { const isAlertsPath = (pathname: string): boolean => {
return !!matchPath(pathname, { return !!matchPath(pathname, {
path: `${ALERTS_PATH}`, path: `${ALERTS_PATH}`,

View file

@ -187,6 +187,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
...this.contract.getStartServices(), ...this.contract.getStartServices(),
apm, apm,
savedObjectsTagging: savedObjectsTaggingOss.getTaggingApi(), savedObjectsTagging: savedObjectsTaggingOss.getTaggingApi(),
setHeaderActionMenu: params.setHeaderActionMenu,
storage: this.storage, storage: this.storage,
sessionStorage: this.sessionStorage, sessionStorage: this.sessionStorage,
security: startPluginsDeps.security, security: startPluginsDeps.security,

View file

@ -7,7 +7,7 @@
import type { Observable } from 'rxjs'; import type { Observable } from 'rxjs';
import type { AppLeaveHandler, CoreStart } from '@kbn/core/public'; import type { CoreStart, AppMountParameters, AppLeaveHandler } from '@kbn/core/public';
import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { FieldFormatsStartCommon } from '@kbn/field-formats-plugin/common'; import type { FieldFormatsStartCommon } from '@kbn/field-formats-plugin/common';
@ -158,6 +158,7 @@ export type StartServices = CoreStart &
sessionStorage: Storage; sessionStorage: Storage;
apm: ApmBase; apm: ApmBase;
savedObjectsTagging?: SavedObjectsTaggingApi; savedObjectsTagging?: SavedObjectsTaggingApi;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
onAppLeave: (handler: AppLeaveHandler) => void; onAppLeave: (handler: AppLeaveHandler) => void;
/** /**

View file

@ -176,6 +176,7 @@
"@kbn/subscription-tracking", "@kbn/subscription-tracking",
"@kbn/core-application-common", "@kbn/core-application-common",
"@kbn/openapi-generator", "@kbn/openapi-generator",
"@kbn/es" "@kbn/es",
"@kbn/react-kibana-mount"
] ]
} }