diff --git a/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx index f9f3efdd299f3..00b68c2abc547 100644 --- a/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx +++ b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx @@ -31,15 +31,15 @@ import { } from './url/search_sessions_integration'; import { DashboardAPI, DashboardRenderer } from '..'; import { type DashboardEmbedSettings } from './types'; -import { DASHBOARD_APP_ID } from '../dashboard_constants'; import { pluginServices } from '../services/plugin_services'; -import { DashboardTopNav } from './top_nav/dashboard_top_nav'; import { AwaitingDashboardAPI } from '../dashboard_container'; import { DashboardRedirect } from '../dashboard_container/types'; import { useDashboardMountContext } from './hooks/dashboard_mount_context'; +import { createDashboardEditUrl, DASHBOARD_APP_ID } from '../dashboard_constants'; import { useDashboardOutcomeValidation } from './hooks/use_dashboard_outcome_validation'; import { loadDashboardHistoryLocationState } from './locator/load_dashboard_history_location_state'; import type { DashboardCreationOptions } from '../dashboard_container/embeddable/dashboard_container_factory'; +import { DashboardTopNav } from '../dashboard_top_nav'; export interface DashboardAppProps { history: History; @@ -160,6 +160,10 @@ export function DashboardApp({ getInitialInput, validateLoadedSavedObject: validateOutcome, 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, @@ -192,9 +196,11 @@ export function DashboardApp({ {!showNoDataPage && ( <> {dashboardAPI && ( - - - + )} {getLegacyConflictWarning?.()} diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx index 63dd1d96d1169..0190bbaefa00b 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx @@ -5,7 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import { css } from '@emotion/react'; import React, { useCallback } from 'react'; import { METRIC_TYPE } from '@kbn/analytics'; @@ -21,7 +20,7 @@ import { EditorMenu } from './editor_menu'; import { useDashboardAPI } from '../dashboard_app'; import { pluginServices } from '../../services/plugin_services'; 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'; export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }) { @@ -70,12 +69,13 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean } stateTransferService.navigateToEditor(appId, { path, state: { - originatingApp: DASHBOARD_APP_ID, + originatingApp: dashboard.getAppContext()?.currentAppId, + originatingPath: dashboard.getAppContext()?.getCurrentPath?.(), searchSessionId: search.session.getSessionId(), }, }); }, - [stateTransferService, search.session, trackUiMetric] + [stateTransferService, dashboard, search.session, trackUiMetric] ); const createNewEmbeddable = useCallback( diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx index 643765bdfbab6..9c5aedb76c147 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx @@ -26,10 +26,12 @@ export const useDashboardMenuItems = ({ redirectTo, isLabsShown, setIsLabsShown, + showResetChange, }: { redirectTo: DashboardRedirect; isLabsShown: boolean; setIsLabsShown: Dispatch>; + showResetChange?: boolean; }) => { const [isSaveInProgress, setIsSaveInProgress] = useState(false); @@ -276,32 +278,56 @@ export const useDashboardMenuItems = ({ const shareMenuItem = share ? [menuItems.share] : []; const cloneMenuItem = showWriteControls ? [menuItems.clone] : []; const editMenuItem = showWriteControls && !managed ? [menuItems.edit] : []; + const mayberesetChangesMenuItem = showResetChange ? [resetChangesMenuItem] : []; + return [ ...labsMenuItem, menuItems.fullScreen, ...shareMenuItem, ...cloneMenuItem, - resetChangesMenuItem, + ...mayberesetChangesMenuItem, ...editMenuItem, ]; - }, [isLabsEnabled, menuItems, share, showWriteControls, managed, resetChangesMenuItem]); + }, [ + isLabsEnabled, + menuItems, + share, + showWriteControls, + managed, + showResetChange, + resetChangesMenuItem, + ]); const editModeTopNavConfig = useMemo(() => { const labsMenuItem = isLabsEnabled ? [menuItems.labs] : []; const shareMenuItem = share ? [menuItems.share] : []; const editModeItems: TopNavMenuData[] = []; + if (lastSavedId) { - editModeItems.push( - menuItems.saveAs, - menuItems.switchToViewMode, - resetChangesMenuItem, - menuItems.quickSave - ); + editModeItems.push(menuItems.saveAs, menuItems.switchToViewMode); + + if (showResetChange) { + editModeItems.push(resetChangesMenuItem); + } + + editModeItems.push(menuItems.quickSave); } else { editModeItems.push(menuItems.switchToViewMode, menuItems.saveAs); } 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 }; }; diff --git a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx index 050de4189c279..8767b5abe3567 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx @@ -24,7 +24,7 @@ import { ViewMode } from '@kbn/embeddable-plugin/public'; import { pluginServices } from '../../../services/plugin_services'; import { emptyScreenStrings } from '../../_dashboard_container_strings'; 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() { const { @@ -44,6 +44,14 @@ export function DashboardEmptyScreen() { [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(() => { if (!lensAlias || !lensAlias.aliasPath) return; const trackUiMetric = usageCollection.reportUiCounter?.bind( @@ -57,16 +65,19 @@ export function DashboardEmptyScreen() { getStateTransfer().navigateToEditor(lensAlias.aliasApp, { path: lensAlias.aliasPath, state: { - originatingApp: DASHBOARD_APP_ID, + originatingApp, + originatingPath, searchSessionId: search.session.getSessionId(), }, }); - }, [getStateTransfer, lensAlias, search.session, usageCollection]); - - const dashboardContainer = useDashboardContainer(); - const isDarkTheme = useObservable(theme$)?.darkMode; - const isEditMode = - dashboardContainer.select((state) => state.explicitInput.viewMode) === ViewMode.EDIT; + }, [ + getStateTransfer, + lensAlias, + originatingApp, + originatingPath, + search.session, + usageCollection, + ]); // TODO replace these SVGs with versions from EuiIllustration as soon as it becomes available. const imageUrl = basePath.prepend( diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index 4c88d246f6ca3..0899fa0ebc97e 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -53,7 +53,7 @@ import { DASHBOARD_CONTAINER_TYPE } from '../..'; import { placePanel } from '../component/panel_placement'; import { pluginServices } from '../../services/plugin_services'; 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 { DashboardAnalyticsService } from '../../services/analytics/types'; import { DashboardViewport } from '../component/viewport/dashboard_viewport'; @@ -107,7 +107,6 @@ export class DashboardContainer extends Container void; private cleanupStateTools: () => void; @@ -185,6 +184,16 @@ export class DashboardContainer extends Container 'valid' | 'invalid' | 'redirected'; isEmbeddedExternally?: boolean; + + getEmbeddableAppContext?: (dashboardId?: string) => EmbeddableAppContext; } export class DashboardContainerFactoryDefinition diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/_dashboard_top_nav.scss b/src/plugins/dashboard/public/dashboard_top_nav/_dashboard_top_nav.scss similarity index 100% rename from src/plugins/dashboard/public/dashboard_app/top_nav/_dashboard_top_nav.scss rename to src/plugins/dashboard/public/dashboard_top_nav/_dashboard_top_nav.scss diff --git a/src/plugins/dashboard/public/dashboard_top_nav/dashboard_top_nav_with_context.tsx b/src/plugins/dashboard/public/dashboard_top_nav/dashboard_top_nav_with_context.tsx new file mode 100644 index 0000000000000..ed2a7426697c1 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_top_nav/dashboard_top_nav_with_context.tsx @@ -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) => ( + + + +); + +// eslint-disable-next-line import/no-default-export +export default DashboardTopNavWithContext; diff --git a/src/plugins/dashboard/public/dashboard_top_nav/index.tsx b/src/plugins/dashboard/public/dashboard_top_nav/index.tsx new file mode 100644 index 0000000000000..d0cfc496fcc3f --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_top_nav/index.tsx @@ -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 ( + }> + + + ); +}; diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx similarity index 78% rename from src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx rename to src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx index 67c51a19052d9..c2e0e273a572e 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx @@ -18,36 +18,49 @@ import { ViewMode } from '@kbn/embeddable-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; import { TopNavMenuProps } from '@kbn/navigation-plugin/public'; import { EuiHorizontalRule, EuiIcon, EuiToolTipProps } from '@elastic/eui'; - +import { EuiBreadcrumbProps } from '@elastic/eui/src/components/breadcrumbs/breadcrumb'; +import { MountPoint } from '@kbn/core/public'; import { getDashboardTitle, leaveConfirmStrings, getDashboardBreadcrumb, unsavedChangesBadgeStrings, dashboardManagedBadge, -} from '../_dashboard_app_strings'; -import { UI_SETTINGS } from '../../../common'; -import { useDashboardAPI } from '../dashboard_app'; -import { DashboardEmbedSettings } from '../types'; -import { pluginServices } from '../../services/plugin_services'; -import { useDashboardMenuItems } from './use_dashboard_menu_items'; -import { DashboardRedirect } from '../../dashboard_container/types'; -import { DashboardEditingToolbar } from './dashboard_editing_toolbar'; -import { useDashboardMountContext } from '../hooks/dashboard_mount_context'; -import { getFullEditPath, LEGACY_DASHBOARD_APP_ID } from '../../dashboard_constants'; - +} from '../dashboard_app/_dashboard_app_strings'; +import { UI_SETTINGS } from '../../common'; +import { useDashboardAPI } from '../dashboard_app/dashboard_app'; +import { pluginServices } from '../services/plugin_services'; +import { useDashboardMenuItems } from '../dashboard_app/top_nav/use_dashboard_menu_items'; +import { DashboardEmbedSettings } from '../dashboard_app/types'; +import { DashboardEditingToolbar } from '../dashboard_app/top_nav/dashboard_editing_toolbar'; +import { useDashboardMountContext } from '../dashboard_app/hooks/dashboard_mount_context'; +import { getFullEditPath, LEGACY_DASHBOARD_APP_ID } from '../dashboard_constants'; import './_dashboard_top_nav.scss'; -export interface DashboardTopNavProps { +import { DashboardRedirect } from '../dashboard_container/types'; + +export interface InternalDashboardTopNavProps { + customLeadingBreadCrumbs?: EuiBreadcrumbProps[]; embedSettings?: DashboardEmbedSettings; + forceHideUnifiedSearch?: boolean; redirectTo: DashboardRedirect; + setCustomHeaderActionMenu?: (menuMount: MountPoint | undefined) => void; + showBorderBottom?: boolean; + showResetChange?: boolean; } 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 [isLabsShown, setIsLabsShown] = useState(false); - const dashboardTitleRef = useRef(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 serverless.setBreadcrumbs(dashboardTitleBreadcrumbs); } else { - // non-serverless regular breadcrumbs - setBreadcrumbs([ - { - text: getDashboardBreadcrumb(), - 'data-test-subj': 'dashboardListingBreadcrumb', - onClick: () => { - redirectTo({ destination: 'listing' }); + /** + * non-serverless regular breadcrumbs + * Dashboard embedded in other plugins (e.g. SecuritySolution) + * will have custom leading breadcrumbs for back to their app. + **/ + setBreadcrumbs( + 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 @@ -205,12 +232,6 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr }; }, [onAppLeave, getStateTransfer, hasUnsavedChanges, viewMode]); - const { viewModeTopNavConfig, editModeTopNavConfig } = useDashboardMenuItems({ - redirectTo, - isLabsShown, - setIsLabsShown, - }); - const visibilityProps = useMemo(() => { const shouldShowNavBarComponent = (forceShow: boolean): boolean => (forceShow || isChromeVisible) && !fullScreenMode; @@ -218,14 +239,17 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr !forceHide && (filterManager.getFilters().length > 0 || !fullScreenMode); const showTopNavMenu = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowTopNavMenu)); - const showQueryInput = shouldShowNavBarComponent( - Boolean(embedSettings?.forceShowQueryInput || viewMode === ViewMode.PRINT) - ); - const showDatePicker = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker)); + const showQueryInput = Boolean(forceHideUnifiedSearch) + ? false + : shouldShowNavBarComponent( + Boolean(embedSettings?.forceShowQueryInput || viewMode === ViewMode.PRINT) + ); + const showDatePicker = Boolean(forceHideUnifiedSearch) + ? false + : shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker)); const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); const showQueryBar = showQueryInput || showDatePicker || showFilterBar; const showSearchBar = showQueryBar || showFilterBar; - return { showTopNavMenu, showSearchBar, @@ -233,7 +257,21 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr showQueryInput, showDatePicker, }; - }, [embedSettings, filterManager, fullScreenMode, isChromeVisible, viewMode]); + }, [ + embedSettings, + filterManager, + forceHideUnifiedSearch, + fullScreenMode, + isChromeVisible, + viewMode, + ]); + + const { viewModeTopNavConfig, editModeTopNavConfig } = useDashboardMenuItems({ + redirectTo, + isLabsShown, + setIsLabsShown, + showResetChange, + }); UseUnmount(() => { dashboard.clearOverlays(); @@ -301,7 +339,11 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr saveQueryMenuVisibility={allowSaveQuery ? 'allowed_by_app_privilege' : 'globally_managed'} appName={LEGACY_DASHBOARD_APP_ID} visible={viewMode !== ViewMode.PRINT} - setMenuMountPoint={embedSettings || fullScreenMode ? undefined : setHeaderActionMenu} + setMenuMountPoint={ + embedSettings || fullScreenMode + ? setCustomHeaderActionMenu ?? undefined + : setHeaderActionMenu + } className={fullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined} config={ visibilityProps.showTopNavMenu @@ -327,7 +369,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr {viewMode === ViewMode.EDIT ? ( ) : null} - + {showBorderBottom && } ); } diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 6882090df441a..89cc7b1aabed8 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -25,7 +25,7 @@ export { export type { DashboardSetup, DashboardStart, DashboardFeatureFlagConfig } from './plugin'; export { DashboardListingTable } from './dashboard_listing'; - +export { DashboardTopNav } from './dashboard_top_nav'; export { type DashboardAppLocator, type DashboardAppLocatorParams, diff --git a/src/plugins/embeddable/public/embeddable_panel/embeddable_panel.tsx b/src/plugins/embeddable/public/embeddable_panel/embeddable_panel.tsx index 3d823c215498b..93279e311b065 100644 --- a/src/plugins/embeddable/public/embeddable_panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/embeddable_panel/embeddable_panel.tsx @@ -49,14 +49,7 @@ const getEventStatus = (output: EmbeddableOutput): EmbeddablePhase => { }; export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => { - const { - hideHeader, - showShadow, - embeddable, - hideInspector, - containerContext, - onPanelStatusChange, - } = panelProps; + const { hideHeader, showShadow, embeddable, hideInspector, onPanelStatusChange } = panelProps; const [node, setNode] = useState(); const embeddableRoot: React.RefObject = useMemo(() => React.createRef(), []); @@ -74,8 +67,7 @@ export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => { const editPanel = new EditPanelAction( embeddableStart.getEmbeddableFactory, core.application, - stateTransfer, - containerContext?.getCurrentPath + stateTransfer ); const actions: PanelUniversalActions = { @@ -91,7 +83,7 @@ export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => { }; if (!hideInspector) actions.inspectPanel = new InspectPanelAction(inspector); return actions; - }, [containerContext?.getCurrentPath, hideInspector]); + }, [hideInspector]); /** * Track panel status changes diff --git a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.test.tsx b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.test.tsx index a1b1bf4df5ec4..33b1cc15a55bc 100644 --- a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.test.tsx +++ b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.test.tsx @@ -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 () => { applicationMock.currentAppId$ = of('superCoolCurrentApp'); const testPath = '/test-path'; - const action = new EditPanelAction( - getFactory, - applicationMock, - stateTransferMock, - () => testPath - ); + const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock); const embeddable = new EditableEmbeddable( { id: '123', @@ -62,6 +57,9 @@ test('redirects to app using state transfer', async () => { true ); embeddable.getOutput = jest.fn(() => ({ editApp: 'ultraVisualize', editPath: '/123' })); + embeddable.getAppContext = jest.fn().mockReturnValue({ + getCurrentPath: () => testPath, + }); await action.execute({ embeddable }); expect(stateTransferMock.navigateToEditor).toHaveBeenCalledWith('ultraVisualize', { path: '/123', diff --git a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts index fe55b9a39158b..32e9fbac493aa 100644 --- a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts +++ b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts @@ -45,8 +45,7 @@ export class EditPanelAction implements Action { constructor( private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'], private readonly application: ApplicationStart, - private readonly stateTransfer?: EmbeddableStateTransfer, - private readonly getOriginatingPath?: () => string + private readonly stateTransfer?: EmbeddableStateTransfer ) { if (this.application?.currentAppId$) { this.application.currentAppId$ @@ -139,7 +138,7 @@ export class EditPanelAction implements Action { if (app && path) { if (this.currentAppId) { - const originatingPath = this.getOriginatingPath?.(); + const originatingPath = embeddable.getAppContext()?.getCurrentPath?.(); const state: EmbeddableEditorState = { originatingApp: this.currentAppId, diff --git a/src/plugins/embeddable/public/embeddable_panel/types.ts b/src/plugins/embeddable/public/embeddable_panel/types.ts index 9a1b17d4a3a4d..03e29810d4056 100644 --- a/src/plugins/embeddable/public/embeddable_panel/types.ts +++ b/src/plugins/embeddable/public/embeddable_panel/types.ts @@ -19,11 +19,12 @@ import { import { EmbeddableError } from '../lib/embeddables/i_embeddable'; import { EmbeddableContext, EmbeddableInput, EmbeddableOutput, IEmbeddable } from '..'; -export interface EmbeddableContainerContext { +export interface EmbeddableAppContext { /** * Current app's path including query and hash starting from {appId} */ getCurrentPath?: () => string; + currentAppId?: string; } /** @@ -53,7 +54,6 @@ export interface EmbeddablePanelProps { hideHeader?: boolean; hideInspector?: boolean; showNotifications?: boolean; - containerContext?: EmbeddableContainerContext; actionPredicate?: (actionId: string) => boolean; onPanelStatusChange?: (info: EmbeddablePhaseEvent) => void; getActions?: UiActionsService['getTriggerCompatibleActions']; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 0e3650ea8a8a4..ebe3b1a11af03 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -100,7 +100,7 @@ export { export type { EmbeddablePhase, EmbeddablePhaseEvent, - EmbeddableContainerContext, + EmbeddableAppContext, } from './embeddable_panel/types'; export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service'; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index d145bfb3c1ae0..ac1c8462b5bf3 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -17,6 +17,7 @@ import { IContainer } from '../containers'; import { EmbeddableError, EmbeddableOutput, IEmbeddable } from './i_embeddable'; import { EmbeddableInput, ViewMode } from '../../../common/types'; import { genericEmbeddableInputIsEqual, omitGenericEmbeddableInput } from './diff_embeddable_input'; +import { EmbeddableAppContext } from '../../embeddable_panel/types'; function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) { if (input.hidePanelTitles) return ''; @@ -102,6 +103,10 @@ export abstract class Embeddable< .subscribe((title) => this.renderComplete.setTitle(title)); } + public getAppContext(): EmbeddableAppContext | undefined { + return this.parent?.getAppContext(); + } + public reportsEmbeddableLoad() { return false; } diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index f371208271623..92d0309688e76 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -11,6 +11,7 @@ import { ErrorLike } from '@kbn/expressions-plugin/common'; import { Adapters } from '../types'; import { IContainer } from '../containers/i_container'; import { EmbeddableInput } from '../../../common/types'; +import { EmbeddableAppContext } from '../../embeddable_panel/types'; export type EmbeddableError = ErrorLike; export type { EmbeddableInput }; @@ -181,6 +182,11 @@ export interface IEmbeddable< */ getRoot(): IEmbeddable | IContainer; + /** + * Returns the context of this embeddable's container, or undefined. + */ + getAppContext(): EmbeddableAppContext | undefined; + /** * Renders the embeddable at the given node. * @param domNode diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 51c6cdc54131d..ec602510fd9f0 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -17,13 +17,13 @@ import { isErrorEmbeddable, EmbeddablePanel, } 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 { EmbeddableExpression } from '../../expression_types/embeddable'; import { RendererStrings } from '../../../i18n'; import { embeddableInputToExpression } from './embeddable_input_to_expression'; 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; @@ -41,18 +41,19 @@ const renderEmbeddableFactory = (core: CoreStart, plugins: StartDeps) => { return null; } - const embeddableContainerContext: EmbeddableContainerContext = { + const canvasAppContext: EmbeddableAppContext = { getCurrentPath: () => { const urlToApp = core.application.getUrlForApp(currentAppId); const inAppPath = window.location.pathname.replace(urlToApp, ''); return inAppPath + window.location.search + window.location.hash; }, + currentAppId: CANVAS_APP, }; - return ( - - ); + embeddable.getAppContext = () => canvasAppContext; + + return ; }; return (embeddableObject: IEmbeddable) => { diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 0150d922c481f..1817a3eaa8175 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -794,18 +794,14 @@ export class Embeddable * Used for the Edit in Lens link inside the inline editing flyout. */ private async navigateToLensEditor() { - const executionContext = this.getExecutionContext(); + const appContext = this.getAppContext(); /** * The origininating app variable is very important for the Save and Return button * 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 = { - originatingApp: - executionContext?.type === 'dashboard' - ? 'dashboards' - : executionContext?.type ?? 'dashboards', + originatingApp: appContext?.currentAppId ?? 'dashboards', + originatingPath: appContext?.getCurrentPath?.(), valueInput: this.getExplicitInput(), embeddableId: this.id, searchSessionId: this.getInput().searchSessionId, @@ -818,6 +814,7 @@ export class Embeddable await transfer.navigateToEditor(APP_ID, { path: this.output.editPath, state: transferState, + skipAppLeave: true, }); } } diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index 4b9941b1cbe5d..fcf4fd666a58a 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -38,7 +38,6 @@ interface StartAppComponent { children: React.ReactNode; history: History; onAppLeave: (handler: AppLeaveHandler) => void; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; store: Store; theme$: AppMountParameters['theme$']; } @@ -46,7 +45,6 @@ interface StartAppComponent { const StartAppComponent: FC = ({ children, history, - setHeaderActionMenu, onAppLeave, store, theme$, @@ -79,11 +77,7 @@ const StartAppComponent: FC = ({ > - + {children} @@ -113,7 +107,6 @@ interface SecurityAppComponentProps { history: History; onAppLeave: (handler: AppLeaveHandler) => void; services: StartServices; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; store: Store; theme$: AppMountParameters['theme$']; } @@ -123,7 +116,6 @@ const SecurityAppComponent: React.FC = ({ history, onAppLeave, services, - setHeaderActionMenu, store, theme$, }) => { @@ -137,13 +129,7 @@ const SecurityAppComponent: React.FC = ({ }} > - + {children} diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx index fe0b5bd500dc8..bfce21c47867c 100644 --- a/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx @@ -49,7 +49,6 @@ jest.mock('react-reverse-portal', () => ({ })); describe('global header', () => { - const mockSetHeaderActionMenu = jest.fn(); const state = { ...mockGlobalState, timeline: { @@ -75,7 +74,7 @@ describe('global header', () => { ]); const { getByText } = render( - + ); expect(getByText('Add integrations')).toBeInTheDocument(); @@ -87,7 +86,7 @@ describe('global header', () => { ]); const { queryByTestId } = render( - + ); const link = queryByTestId('add-data'); @@ -98,7 +97,7 @@ describe('global header', () => { (useLocation as jest.Mock).mockReturnValue({ pathname: THREAT_INTELLIGENCE_PATH }); const { queryByTestId } = render( - + ); const link = queryByTestId('add-data'); @@ -118,7 +117,7 @@ describe('global header', () => { ); const { queryByTestId } = render( - + ); const link = queryByTestId('add-data'); @@ -130,7 +129,7 @@ describe('global header', () => { const { getByTestId } = render( - + ); expect(getByTestId('sourcerer-trigger')).toBeInTheDocument(); @@ -141,7 +140,7 @@ describe('global header', () => { const { getByTestId } = render( - + ); expect(getByTestId('sourcerer-trigger')).toBeInTheDocument(); @@ -166,7 +165,7 @@ describe('global header', () => { const { queryByTestId } = render( - + ); @@ -180,7 +179,7 @@ describe('global header', () => { const { findByTestId } = render( - + ); diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx index bde0b71a43270..e5a12721a6292 100644 --- a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx @@ -15,11 +15,10 @@ import { useLocation } from 'react-router-dom'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { i18n } from '@kbn/i18n'; -import type { AppMountParameters } from '@kbn/core/public'; -import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { toMountPoint } from '@kbn/react-kibana-mount'; import { MlPopover } from '../../../common/components/ml_popover/ml_popover'; import { useKibana } from '../../../common/lib/kibana'; -import { isDetectionsPath } from '../../../helpers'; +import { isDetectionsPath, isDashboardViewPath } from '../../../helpers'; import { Sourcerer } from '../../../common/components/sourcerer'; import { TimelineId } from '../../../../common/types/timeline'; 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 * right hand side of the Kibana global header */ -export const GlobalHeader = React.memo( - ({ setHeaderActionMenu }: { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'] }) => { - const portalNode = useMemo(() => createHtmlPortalNode(), []); - const { theme } = useKibana().services; - const { pathname } = useLocation(); +export const GlobalHeader = React.memo(() => { + const portalNode = useMemo(() => createHtmlPortalNode(), []); + const { theme, setHeaderActionMenu, i18n: kibanaServiceI18n } = useKibana().services; + const { pathname } = useLocation(); - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const showTimeline = useShallowEqualSelector( - (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show - ); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const showTimeline = useShallowEqualSelector( + (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show + ); - const sourcererScope = getScopeFromPath(pathname); - const showSourcerer = showSourcererByPath(pathname); + const sourcererScope = getScopeFromPath(pathname); + const showSourcerer = showSourcererByPath(pathname); + const dashboardViewPath = isDashboardViewPath(pathname); - const { href, onClick } = useAddIntegrationsUrl(); + const { href, onClick } = useAddIntegrationsUrl(); - useEffect(() => { - setHeaderActionMenu((element) => { - const mount = toMountPoint(, { theme$: theme.theme$ }); - return mount(element); + useEffect(() => { + setHeaderActionMenu((element) => { + const mount = toMountPoint(, { + theme, + i18n: kibanaServiceI18n, }); + return mount(element); + }); - return () => { - portalNode.unmount(); - setHeaderActionMenu(undefined); - }; - }, [portalNode, setHeaderActionMenu, theme.theme$]); - - return ( - - - {isDetectionsPath(pathname) && ( - - - - )} + return () => { + /* Dashboard mounts an edit toolbar, it should be restored when leaving dashboard editing page */ + if (dashboardViewPath) { + return; + } + portalNode.unmount(); + setHeaderActionMenu(undefined); + }; + }, [portalNode, setHeaderActionMenu, theme, kibanaServiceI18n, dashboardViewPath]); + return ( + + + {isDetectionsPath(pathname) && ( - - - {BUTTON_ADD_DATA} - - {showSourcerer && !showTimeline && ( - - )} - - + - - - ); - } -); + )} + + + + + {BUTTON_ADD_DATA} + + {showSourcerer && !showTimeline && ( + + )} + + + + + + ); +}); GlobalHeader.displayName = 'GlobalHeader'; diff --git a/x-pack/plugins/security_solution/public/app/home/index.test.tsx b/x-pack/plugins/security_solution/public/app/home/index.test.tsx index 9bce539a0b311..fc62a8236f9cc 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.test.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.test.tsx @@ -106,6 +106,7 @@ jest.mock('../../timelines/store/timeline', () => ({ const mockedFilterManager = new FilterManager(coreMock.createStart().uiSettings); const mockGetSavedQuery = jest.fn(); +const mockSetHeaderActionMenu = jest.fn(); const dummyFilter: Filter = { meta: { @@ -198,6 +199,7 @@ jest.mock('../../common/lib/kibana', () => { savedQueries: { getSavedQuery: mockGetSavedQuery }, }, }, + setHeaderActionMenu: mockSetHeaderActionMenu, }, }), KibanaServices: { @@ -226,7 +228,7 @@ describe('HomePage', () => { it('calls useInitializeUrlParam for appQuery, filters and savedQuery', () => { render( - + @@ -252,7 +254,7 @@ describe('HomePage', () => { render( - + @@ -294,7 +296,7 @@ describe('HomePage', () => { render( - + @@ -326,7 +328,7 @@ describe('HomePage', () => { render( - + @@ -361,7 +363,7 @@ describe('HomePage', () => { render( - + @@ -378,7 +380,7 @@ describe('HomePage', () => { render( - + @@ -420,7 +422,7 @@ describe('HomePage', () => { render( - + @@ -465,7 +467,7 @@ describe('HomePage', () => { render( - + @@ -515,7 +517,7 @@ describe('HomePage', () => { const TestComponent = () => ( - + @@ -572,7 +574,7 @@ describe('HomePage', () => { const TestComponent = () => ( - + @@ -612,7 +614,7 @@ describe('HomePage', () => { render( - + @@ -637,7 +639,7 @@ describe('HomePage', () => { const TestComponent = () => ( - + @@ -669,7 +671,7 @@ describe('HomePage', () => { const TestComponent = () => ( - + diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index b951501b16cb7..bded1d58c8d84 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -8,7 +8,6 @@ import React from 'react'; 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 { SecuritySolutionAppWrapper } from '../../common/components/page'; @@ -33,10 +32,9 @@ import { AssistantOverlay } from '../../assistant/overlay'; interface HomePageProps { children: React.ReactNode; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; } -const HomePageComponent: React.FC = ({ children, setHeaderActionMenu }) => { +const HomePageComponent: React.FC = ({ children }) => { const { pathname } = useLocation(); useInitSourcerer(getScopeFromPath(pathname)); useUrlState(); @@ -58,7 +56,7 @@ const HomePageComponent: React.FC = ({ children, setHeaderActionM <> - + {children} diff --git a/x-pack/plugins/security_solution/public/app/index.tsx b/x-pack/plugins/security_solution/public/app/index.tsx index e635c2d9fd3d3..6f0fc3eb8d01c 100644 --- a/x-pack/plugins/security_solution/public/app/index.tsx +++ b/x-pack/plugins/security_solution/public/app/index.tsx @@ -16,7 +16,6 @@ export const renderApp = ({ element, history, onAppLeave, - setHeaderActionMenu, services, store, usageCollection, @@ -31,7 +30,6 @@ export const renderApp = ({ history={history} onAppLeave={onAppLeave} services={services} - setHeaderActionMenu={setHeaderActionMenu} store={store} theme$={theme$} > diff --git a/x-pack/plugins/security_solution/public/app/routes.tsx b/x-pack/plugins/security_solution/public/app/routes.tsx index af5aaba76363f..73fe2615b0e5a 100644 --- a/x-pack/plugins/security_solution/public/app/routes.tsx +++ b/x-pack/plugins/security_solution/public/app/routes.tsx @@ -10,7 +10,7 @@ import type { FC } from 'react'; import React, { memo, useEffect } from 'react'; import { Router, Routes, Route } from '@kbn/shared-ux-router'; 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 { RouteCapture } from '../common/components/endpoint/route_capture'; @@ -24,15 +24,9 @@ interface RouterProps { children: React.ReactNode; history: History; onAppLeave: (handler: AppLeaveHandler) => void; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; } -const PageRouterComponent: FC = ({ - children, - history, - onAppLeave, - setHeaderActionMenu, -}) => { +const PageRouterComponent: FC = ({ children, history, onAppLeave }) => { const { cases } = useKibana().services; const CasesContext = cases.ui.getCasesContext(); const userCasesPermissions = useGetUserCasesPermissions(); @@ -55,7 +49,7 @@ const PageRouterComponent: FC = ({ - {children} + {children} diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.ts rename to x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.tsx diff --git a/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts b/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts index d7401ff19d916..9d13b857c155e 100644 --- a/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts @@ -34,3 +34,14 @@ export const getTagsByName = jest export const createTag = jest .fn() .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', + })) + ) +); diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 9c78db17abf37..7c9d7c5f0656c 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -121,6 +121,7 @@ export const createStartServicesMock = ( const cloudExperiments = cloudExperimentsMock.createStartMock(); const guidedOnboarding = guidedOnboardingMock.createStart(); const cloud = cloudMock.createStart(); + const mockSetHeaderActionMenu = jest.fn(); return { ...core, @@ -220,6 +221,7 @@ export const createStartServicesMock = ( customDataService, uiActions: uiActionsPluginMock.createStartContract(), savedSearch: savedSearchPluginMock.createStartContract(), + setHeaderActionMenu: mockSetHeaderActionMenu, } as unknown as StartServices; }; diff --git a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.test.tsx b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.test.tsx index c01f07fa36653..9c6df7bb6e395 100644 --- a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.test.tsx @@ -12,13 +12,10 @@ import { DashboardRenderer as DashboardContainerRenderer } from '@kbn/dashboard- import { TestProviders } from '../../common/mock'; import { DashboardRenderer } from './dashboard_renderer'; -jest.mock('@kbn/dashboard-plugin/public', () => { - const actual = jest.requireActual('@kbn/dashboard-plugin/public'); - return { - ...actual, - DashboardRenderer: jest.fn().mockReturnValue(
), - }; -}); +jest.mock('@kbn/dashboard-plugin/public', () => ({ + DashboardRenderer: jest.fn().mockReturnValue(
), + DashboardTopNav: jest.fn().mockReturnValue(), +})); jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.tsx b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.tsx index aa51842a33c1e..73538439de568 100644 --- a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.tsx @@ -5,7 +5,7 @@ * 2.0. */ 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 { ViewMode } from '@kbn/embeddable-plugin/public'; 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 { InputsModelId } from '../../common/store/inputs/constants'; 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 = ({ canReadDashboard, + dashboardContainer, filters, id, inputId = InputsModelId.global, @@ -23,8 +28,10 @@ const DashboardRendererComponent = ({ query, savedObjectId, timeRange, + viewMode = ViewMode.VIEW, }: { canReadDashboard: boolean; + dashboardContainer?: DashboardAPI; filters?: Filter[]; id: string; inputId?: InputsModelId.global | InputsModelId.timeline; @@ -37,17 +44,36 @@ const DashboardRendererComponent = ({ to: string; toStr?: string | undefined; }; + viewMode?: ViewMode; }) => { + const { embeddable } = useKibana().services; const dispatch = useDispatch(); - const [dashboardContainer, setDashboardContainer] = useState(); - const getCreationOptions = useCallback( + const securityTags = useSecurityTags(); + const firstSecurityTagId = securityTags?.[0]?.id; + + const isCreateDashboard = !savedObjectId; + + const getCreationOptions: () => Promise = useCallback( () => Promise.resolve({ - getInitialInput: () => ({ timeRange, viewMode: ViewMode.VIEW, query, filters }), + useSessionStorageIntegration: 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(() => { @@ -73,20 +99,33 @@ const DashboardRendererComponent = ({ dashboardContainer?.updateInput({ timeRange, query, filters }); }, [dashboardContainer, filters, query, timeRange]); - const handleDashboardLoaded = useCallback( - (container: DashboardAPI) => { - setDashboardContainer(container); - onDashboardContainerLoaded?.(container); - }, - [onDashboardContainerLoaded] - ); - return savedObjectId && canReadDashboard ? ( - - ) : null; + useEffect(() => { + if (isCreateDashboard && firstSecurityTagId) + dashboardContainer?.updateInput({ tags: [firstSecurityTagId] }); + }, [dashboardContainer, firstSecurityTagId, isCreateDashboard]); + + /** Dashboard renderer is stored in the state as it's a temporary solution for + * https://github.com/elastic/kibana/issues/167751 + **/ + const [dashboardContainerRenderer, setDashboardContainerRenderer] = useState< + React.ReactElement | undefined + >(undefined); + + useEffect(() => { + setDashboardContainerRenderer( + + ); + + return () => { + setDashboardContainerRenderer(undefined); + }; + }, [getCreationOptions, onDashboardContainerLoaded, refetchByForceRefresh, savedObjectId]); + + return canReadDashboard ? <>{dashboardContainerRenderer} : null; }; DashboardRendererComponent.displayName = 'DashboardRendererComponent'; export const DashboardRenderer = React.memo(DashboardRendererComponent); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_title.tsx b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_title.tsx new file mode 100644 index 0000000000000..67d8e73bacdb6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_title.tsx @@ -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 ? {title} : ; +}; + +export const DashboardTitle = React.memo(DashboardTitleComponent); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.test.tsx b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.test.tsx new file mode 100644 index 0000000000000..da0bf3e3dbcea --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.test.tsx @@ -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(() =>
), +})); +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 }) => ( + + {children} + +); + +describe('DashboardToolBar', () => { + const mockOnLoad = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ + navigateTo: mockNavigateTo, + getAppUrl: mockGetAppUrl, + }); + render(, { + 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, + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.tsx b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.tsx new file mode 100644 index 0000000000000..eb74f7c563500 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.tsx @@ -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(LEGACY_DASHBOARD_APP_ID); + + return showWriteControls ? ( + + ) : null; +}; + +export const DashboardToolBar = React.memo(DashboardToolBarComponent); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.test.tsx b/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.test.tsx deleted file mode 100644 index 43afa552d50fd..0000000000000 --- a/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.test.tsx +++ /dev/null @@ -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( - - - - ); - }; - - 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, - }) - ); - }); -}); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.tsx b/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.tsx deleted file mode 100644 index bd360229c7e1f..0000000000000 --- a/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.tsx +++ /dev/null @@ -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 = ({ - 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 ( - - {EDIT_DASHBOARD_BUTTON_TITLE} - - ); -}; - -EditDashboardButtonComponent.displayName = 'EditDashboardComponent'; -export const EditDashboardButton = React.memo(EditDashboardButtonComponent); diff --git a/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx b/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx index cb8b40b1c0907..02bccd69eb253 100644 --- a/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx @@ -20,7 +20,6 @@ const DashboardContext = React.createContext({ secu export const DashboardContextProvider: React.FC = ({ children }) => { const { tags, isLoading } = useFetchSecurityTags(); - const securityTags = isLoading || !tags ? null : tags; return {children}; diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/translations.ts b/x-pack/plugins/security_solution/public/dashboards/hooks/translations.ts deleted file mode 100644 index 58254aa8fe9f6..0000000000000 --- a/x-pack/plugins/security_solution/public/dashboards/hooks/translations.ts +++ /dev/null @@ -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', - } -); diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.ts b/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.tsx similarity index 69% rename from x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.ts rename to x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.tsx index 47509521e5574..52540aff6aa7e 100644 --- a/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.ts +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.tsx @@ -6,23 +6,37 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import type { DashboardStart } from '@kbn/dashboard-plugin/public'; import { useKibana } from '../../common/lib/kibana'; import { useCreateSecurityDashboardLink } from './use_create_security_dashboard_link'; import { DashboardContextProvider } from '../context/dashboard_context'; 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'); -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 = () => renderHook(() => useCreateSecurityDashboardLink(), { - wrapper: DashboardContextProvider, + wrapper: ({ children }) => ( + + {children} + + ), }); const asyncRenderUseCreateSecurityDashboard = async () => { const renderedHook = renderUseCreateSecurityDashboardLink(); + await act(async () => { await renderedHook.waitForNextUpdate(); }); @@ -30,12 +44,15 @@ const asyncRenderUseCreateSecurityDashboard = async () => { }; describe('useCreateSecurityDashboardLink', () => { - const mockGetRedirectUrl = jest.fn(() => URL); - beforeAll(() => { - useKibana().services.dashboard = { - locator: { getRedirectUrl: mockGetRedirectUrl }, - } as unknown as DashboardStart; + (useKibana as jest.Mock).mockReturnValue({ + services: { + savedObjectsTagging: { + create: jest.fn(), + }, + http: { get: jest.fn() }, + }, + }); }); afterEach(() => { @@ -55,8 +72,7 @@ describe('useCreateSecurityDashboardLink', () => { const result1 = result.current; act(() => rerender()); const result2 = result.current; - - expect(result1).toBe(result2); + expect(result1).toEqual(result2); }); it('should not re-request tag id when re-rendered', async () => { @@ -71,14 +87,14 @@ describe('useCreateSecurityDashboardLink', () => { const { result, waitForNextUpdate } = renderUseCreateSecurityDashboardLink(); expect(result.current.isLoading).toEqual(true); - expect(result.current.url).toEqual(''); + expect(result.current.url).toEqual('/app/security/dashboards/create'); await act(async () => { await waitForNextUpdate(); }); expect(result.current.isLoading).toEqual(false); - expect(result.current.url).toEqual(URL); + expect(result.current.url).toEqual('/app/security/dashboards/create'); }); }); }); diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.ts b/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.ts index 633d9d01efe17..24f91eec5211c 100644 --- a/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.ts +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.ts @@ -7,24 +7,28 @@ import { useMemo } from 'react'; 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 }; export const useCreateSecurityDashboardLink: UseCreateDashboard = () => { - const { dashboard } = useKibana().services; + const getSecuritySolutionUrl = useGetSecuritySolutionUrl(); const securityTags = useSecurityTags(); - + const url = getSecuritySolutionUrl({ + deepLinkId: SecurityPageName.dashboards, + path: 'create', + }); const result = useMemo(() => { const firstSecurityTagId = securityTags?.[0]?.id; if (!firstSecurityTagId) { - return { isLoading: true, url: '' }; + return { isLoading: true, url }; } return { isLoading: false, - url: dashboard?.locator?.getRedirectUrl({ tags: [firstSecurityTagId] }) ?? '', + url, }; - }, [securityTags, dashboard?.locator]); + }, [securityTags, url]); return result; }; diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.test.tsx b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.test.tsx new file mode 100644 index 0000000000000..d8f5bae2361c8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.test.tsx @@ -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); + }); +}); diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.tsx b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.tsx new file mode 100644 index 0000000000000..104692e62f2bb --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.tsx @@ -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(); + + const handleDashboardLoaded = useCallback((container: DashboardAPI) => { + setDashboardContainer(container); + }, []); + + return useMemo( + () => ({ + dashboardContainer, + handleDashboardLoaded, + }), + [dashboardContainer, handleDashboardLoaded] + ); +}; diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_security_dashboards_table.tsx b/x-pack/plugins/security_solution/public/dashboards/hooks/use_security_dashboards_table.tsx index 7f58f804d488c..07446294ab203 100644 --- a/x-pack/plugins/security_solution/public/dashboards/hooks/use_security_dashboards_table.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_security_dashboards_table.tsx @@ -8,9 +8,9 @@ import React, { useMemo, useCallback } from 'react'; import type { MouseEventHandler } from 'react'; import type { EuiBasicTableColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { LinkAnchor } from '../../common/components/links'; import { useKibana, useNavigateTo } from '../../common/lib/kibana'; -import * as i18n from './translations'; import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../common/lib/telemetry'; import { SecurityPageName } from '../../../common/constants'; import { useGetSecuritySolutionUrl } from '../../common/components/link_to'; @@ -56,7 +56,9 @@ export const useSecurityDashboardsTableColumns = (): Array< (): Array> => [ { field: 'title', - name: i18n.DASHBOARD_TITLE, + name: i18n.translate('xpack.securitySolution.dashboards.title', { + defaultMessage: 'Title', + }), sortable: true, render: (title: string, { id }) => { const href = `${getSecuritySolutionUrl({ @@ -75,7 +77,9 @@ export const useSecurityDashboardsTableColumns = (): Array< }, { field: 'description', - name: i18n.DASHBOARDS_DESCRIPTION, + name: i18n.translate('xpack.securitySolution.dashboards.description', { + defaultMessage: 'Description', + }), sortable: true, render: (description: string) => description || getEmptyValue(), 'data-test-subj': 'dashboardTableDescriptionCell', diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/breadcrumbs.ts b/x-pack/plugins/security_solution/public/dashboards/pages/breadcrumbs.ts index 01e663e8abb7e..f59369cb4e755 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/breadcrumbs.ts +++ b/x-pack/plugins/security_solution/public/dashboards/pages/breadcrumbs.ts @@ -5,7 +5,9 @@ * 2.0. */ +import { matchPath } from 'react-router-dom'; import type { GetTrailingBreadcrumbs } from '../../common/components/navigation/breadcrumbs/types'; +import { CREATE_DASHBOARD_TITLE } from './translations'; /** * 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. */ export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs = (params, getSecuritySolutionUrl) => { + if (matchPath(params.pathName, { path: '/create' })) { + return [{ text: CREATE_DASHBOARD_TITLE }]; + } + const breadcrumbName = params?.state?.dashboardName; if (breadcrumbName) { return [{ text: breadcrumbName }]; diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/details/index.test.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/details/index.test.tsx index 9411bda35a632..3c85a18f2d3aa 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/details/index.test.tsx @@ -11,6 +11,7 @@ import { Router } from '@kbn/shared-ux-router'; import { DashboardView } from '.'; import { useCapabilities } from '../../../common/lib/kibana'; import { TestProviders } from '../../../common/mock'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); @@ -68,7 +69,7 @@ describe('DashboardView', () => { test('render when no error state', () => { const { queryByTestId } = render( - + , { wrapper: TestProviders } ); @@ -83,7 +84,7 @@ describe('DashboardView', () => { }); const { queryByTestId } = render( - + , { wrapper: TestProviders } ); @@ -95,7 +96,7 @@ describe('DashboardView', () => { test('render dashboard view with height', () => { const { queryByTestId } = render( - + , { wrapper: TestProviders } ); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/details/index.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/details/index.tsx index 8bbfec9f99218..6f07b377a22d0 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/details/index.tsx @@ -7,13 +7,12 @@ import React, { useState, useCallback, useMemo } from 'react'; 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 { useParams } from 'react-router-dom'; - 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 { SpyRoute } from '../../../common/utils/route/spy_routes'; 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 { useSourcererDataView } from '../../../common/containers/sourcerer'; import { HeaderPage } from '../../../common/components/header_page'; -import { DASHBOARD_NOT_FOUND_TITLE } from './translations'; import { inputsSelectors } from '../../../common/store'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; -import { EditDashboardButton } from '../../components/edit_dashboard_button'; +import { DashboardToolBar } from '../../components/dashboard_tool_bar'; + +import { useDashboardRenderer } from '../../hooks/use_dashboard_renderer'; +import { DashboardTitle } from '../../components/dashboard_title'; -type DashboardDetails = Record; +interface DashboardViewProps { + initialViewMode: ViewMode; +} const dashboardViewFlexGroupStyle = { minHeight: `calc(100vh - 140px)` }; -const DashboardViewComponent: React.FC = () => { +const DashboardViewComponent: React.FC = ({ + initialViewMode, +}: DashboardViewProps) => { const { fromStr, toStr, from, to } = useDeepEqualSelector((state) => pick(['fromStr', 'toStr', 'from', 'to'], inputsSelectors.globalTimeRangeSelector(state)) ); @@ -47,36 +52,28 @@ const DashboardViewComponent: React.FC = () => { ); const query = useDeepEqualSelector(getGlobalQuerySelector); const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); - const { indexPattern, indicesExist } = useSourcererDataView(); + const { indexPattern } = useSourcererDataView(); - const { show: canReadDashboard, showWriteControls } = + const { show: canReadDashboard } = useCapabilities(LEGACY_DASHBOARD_APP_ID); const errorState = useMemo( () => (canReadDashboard ? null : DashboardViewPromptState.NoReadPermission), [canReadDashboard] ); - const [dashboardDetails, setDashboardDetails] = useState(); - 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 [viewMode, setViewMode] = useState(initialViewMode); const { detailName: savedObjectId } = useParams<{ detailName?: string }>(); + const [dashboardTitle, setDashboardTitle] = useState(); + + const { dashboardContainer, handleDashboardLoaded } = useDashboardRenderer(); + const onDashboardToolBarLoad = useCallback((mode: ViewMode) => { + setViewMode(mode); + }, []); return ( <> - {indicesExist && ( - - - - )} + + + { data-test-subj="dashboard-view-wrapper" > - }> - {showWriteControls && dashboardExists && ( - - )} - + {dashboardContainer && ( + + } + subtitle={ + + } + /> + )} {!errorState && ( @@ -102,10 +106,12 @@ const DashboardViewComponent: React.FC = () => { query={query} filters={filters} canReadDashboard={canReadDashboard} + dashboardContainer={dashboardContainer} id={`dashboard-view-${savedObjectId}`} - onDashboardContainerLoaded={onDashboardContainerLoaded} + onDashboardContainerLoaded={handleDashboardLoaded} savedObjectId={savedObjectId} timeRange={timeRange} + viewMode={viewMode} /> )} @@ -116,7 +122,7 @@ const DashboardViewComponent: React.FC = () => { )} diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/details/translations.ts b/x-pack/plugins/security_solution/public/dashboards/pages/details/translations.ts index ddfa94bd75584..a760d79a8e30d 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/details/translations.ts +++ b/x-pack/plugins/security_solution/public/dashboards/pages/details/translations.ts @@ -40,3 +40,24 @@ export const EDIT_DASHBOARD_BUTTON_TITLE = i18n.translate( 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`, + } +); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/index.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/index.tsx index 0bd521f47a69c..993a4b37a1ec7 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/index.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { Routes, Route } from '@kbn/shared-ux-router'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; import { DashboardsLandingPage } from './landing_page'; import { DashboardView } from './details'; import { DASHBOARDS_PATH } from '../../../common/constants'; @@ -16,8 +17,14 @@ const DashboardsContainerComponent = () => { return ( + + + + + + - + diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx index ac26f9038d5d4..8723bfb69f326 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx @@ -23,13 +23,10 @@ import { DASHBOARDS_PAGE_SECTION_CUSTOM } from './translations'; jest.mock('../../../common/containers/tags/api'); jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/utils/route/spy_routes', () => ({ SpyRoute: () => null })); -jest.mock('@kbn/dashboard-plugin/public', () => { - const actual = jest.requireActual('@kbn/dashboard-plugin/public'); - return { - ...actual, - DashboardListingTable: jest.fn().mockReturnValue(), - }; -}); +jest.mock('@kbn/dashboard-plugin/public', () => ({ + DashboardListingTable: jest.fn().mockReturnValue(), + DashboardTopNav: jest.fn().mockReturnValue(), +})); const mockUseObservable = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx index 2af7ef2f9902b..10fb3c060548f 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx @@ -99,20 +99,18 @@ export const DashboardsLandingPage = () => { })}`, [getSecuritySolutionUrl] ); - const { isLoading: loadingCreateDashboardUrl, url: createDashboardUrl } = - useCreateSecurityDashboardLink(); - - const getHref = useCallback( - (id: string | undefined) => (id ? getSecuritySolutionDashboardUrl(id) : createDashboardUrl), - [createDashboardUrl, getSecuritySolutionDashboardUrl] - ); const goToDashboard = useCallback( (dashboardId: string | undefined) => { 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(); @@ -151,7 +149,7 @@ export const DashboardsLandingPage = () => { { }); }; +export const isDashboardViewPath = (pathname: string): boolean => + matchPath(pathname, { + path: `/${DASHBOARDS_PATH}/:id`, + exact: false, + strict: false, + }) != null; + const isAlertsPath = (pathname: string): boolean => { return !!matchPath(pathname, { path: `${ALERTS_PATH}`, diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 904ec870e9a2c..65453e37d686b 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -187,6 +187,7 @@ export class Plugin implements IPlugin void; /** diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 19ced3697e2c9..de11312c2f60e 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -176,6 +176,7 @@ "@kbn/subscription-tracking", "@kbn/core-application-common", "@kbn/openapi-generator", - "@kbn/es" + "@kbn/es", + "@kbn/react-kibana-mount" ] }