[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';
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 && (
<DashboardAPIContext.Provider value={dashboardAPI}>
<DashboardTopNav redirectTo={redirectTo} embedSettings={embedSettings} />
</DashboardAPIContext.Provider>
<DashboardTopNav
redirectTo={redirectTo}
embedSettings={embedSettings}
dashboardContainer={dashboardAPI}
/>
)}
{getLegacyConflictWarning?.()}

View file

@ -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(

View file

@ -26,10 +26,12 @@ export const useDashboardMenuItems = ({
redirectTo,
isLabsShown,
setIsLabsShown,
showResetChange,
}: {
redirectTo: DashboardRedirect;
isLabsShown: boolean;
setIsLabsShown: Dispatch<SetStateAction<boolean>>;
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 };
};

View file

@ -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(

View file

@ -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<InheritedChildInput, Dashboard
public controlGroup?: ControlGroupContainer;
public searchSessionId?: string;
// cleanup
public stopSyncingWithUnifiedSearch?: () => void;
private cleanupStateTools: () => void;
@ -185,6 +184,16 @@ export class DashboardContainer extends Container<InheritedChildInput, Dashboard
this.select = reduxTools.select;
}
public getAppContext() {
const embeddableAppContext = this.creationOptions?.getEmbeddableAppContext?.(
this.getDashboardSavedObjectId()
);
return {
...embeddableAppContext,
currentAppId: embeddableAppContext?.currentAppId ?? DASHBOARD_APP_ID,
};
}
public getDashboardSavedObjectId() {
return this.getState().componentState.lastSavedId;
}

View file

@ -16,6 +16,7 @@ import {
EmbeddableFactory,
EmbeddableFactoryDefinition,
EmbeddablePackageState,
EmbeddableAppContext,
} from '@kbn/embeddable-plugin/public';
import { SearchSessionInfoProvider } from '@kbn/data-plugin/public';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
@ -58,6 +59,8 @@ export interface DashboardCreationOptions {
validateLoadedSavedObject?: (result: LoadDashboardReturn) => 'valid' | 'invalid' | 'redirected';
isEmbeddedExternally?: boolean;
getEmbeddableAppContext?: (dashboardId?: string) => EmbeddableAppContext;
}
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 { 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<HTMLElement> | 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<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
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 ? (
<DashboardEditingToolbar isDisabled={!!focusedPanelId} />
) : null}
<EuiHorizontalRule margin="none" />
{showBorderBottom && <EuiHorizontalRule margin="none" />}
</div>
);
}

View file

@ -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,

View file

@ -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<ReactNode | undefined>();
const embeddableRoot: React.RefObject<HTMLDivElement> = 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

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 () => {
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',

View file

@ -45,8 +45,7 @@ export class EditPanelAction implements Action<ActionContext> {
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<ActionContext> {
if (app && path) {
if (this.currentAppId) {
const originatingPath = this.getOriginatingPath?.();
const originatingPath = embeddable.getAppContext()?.getCurrentPath?.();
const state: EmbeddableEditorState = {
originatingApp: this.currentAppId,

View file

@ -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'];

View file

@ -100,7 +100,7 @@ export {
export type {
EmbeddablePhase,
EmbeddablePhaseEvent,
EmbeddableContainerContext,
EmbeddableAppContext,
} from './embeddable_panel/types';
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 { 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;
}

View file

@ -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

View file

@ -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 (
<EmbeddablePanel embeddable={embeddable} containerContext={embeddableContainerContext} />
);
embeddable.getAppContext = () => canvasAppContext;
return <EmbeddablePanel embeddable={embeddable} />;
};
return (embeddableObject: IEmbeddable) => {

View file

@ -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,
});
}
}

View file

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

View file

@ -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(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
<GlobalHeader />
</TestProviders>
);
expect(getByText('Add integrations')).toBeInTheDocument();
@ -87,7 +86,7 @@ describe('global header', () => {
]);
const { queryByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
<GlobalHeader />
</TestProviders>
);
const link = queryByTestId('add-data');
@ -98,7 +97,7 @@ describe('global header', () => {
(useLocation as jest.Mock).mockReturnValue({ pathname: THREAT_INTELLIGENCE_PATH });
const { queryByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
<GlobalHeader />
</TestProviders>
);
const link = queryByTestId('add-data');
@ -118,7 +117,7 @@ describe('global header', () => {
);
const { queryByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
<GlobalHeader />
</TestProviders>
);
const link = queryByTestId('add-data');
@ -130,7 +129,7 @@ describe('global header', () => {
const { getByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
<GlobalHeader />
</TestProviders>
);
expect(getByTestId('sourcerer-trigger')).toBeInTheDocument();
@ -141,7 +140,7 @@ describe('global header', () => {
const { getByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
<GlobalHeader />
</TestProviders>
);
expect(getByTestId('sourcerer-trigger')).toBeInTheDocument();
@ -166,7 +165,7 @@ describe('global header', () => {
const { queryByTestId } = render(
<TestProviders store={mockStore}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
<GlobalHeader />
</TestProviders>
);
@ -180,7 +179,7 @@ describe('global header', () => {
const { findByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
<GlobalHeader />
</TestProviders>
);

View file

@ -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(<OutPortal node={portalNode} />, { theme$: theme.theme$ });
return mount(element);
useEffect(() => {
setHeaderActionMenu((element) => {
const mount = toMountPoint(<OutPortal node={portalNode} />, {
theme,
i18n: kibanaServiceI18n,
});
return mount(element);
});
return () => {
portalNode.unmount();
setHeaderActionMenu(undefined);
};
}, [portalNode, setHeaderActionMenu, theme.theme$]);
return (
<InPortal node={portalNode}>
<EuiHeaderSection side="right">
{isDetectionsPath(pathname) && (
<EuiHeaderSectionItem>
<MlPopover />
</EuiHeaderSectionItem>
)}
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 (
<InPortal node={portalNode}>
<EuiHeaderSection side="right">
{isDetectionsPath(pathname) && (
<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>
<MlPopover />
</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';

View file

@ -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(
<TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -252,7 +254,7 @@ describe('HomePage', () => {
render(
<TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -294,7 +296,7 @@ describe('HomePage', () => {
render(
<TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -326,7 +328,7 @@ describe('HomePage', () => {
render(
<TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -361,7 +363,7 @@ describe('HomePage', () => {
render(
<TestProviders store={mockStore}>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -378,7 +380,7 @@ describe('HomePage', () => {
render(
<TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -420,7 +422,7 @@ describe('HomePage', () => {
render(
<TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -465,7 +467,7 @@ describe('HomePage', () => {
render(
<TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -515,7 +517,7 @@ describe('HomePage', () => {
const TestComponent = () => (
<TestProviders store={mockStore}>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -572,7 +574,7 @@ describe('HomePage', () => {
const TestComponent = () => (
<TestProviders store={mockStore}>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -612,7 +614,7 @@ describe('HomePage', () => {
render(
<TestProviders>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -637,7 +639,7 @@ describe('HomePage', () => {
const TestComponent = () => (
<TestProviders store={store}>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>
@ -669,7 +671,7 @@ describe('HomePage', () => {
const TestComponent = () => (
<TestProviders store={store}>
<HomePage setHeaderActionMenu={jest.fn()}>
<HomePage>
<span />
</HomePage>
</TestProviders>

View file

@ -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<HomePageProps> = ({ children, setHeaderActionMenu }) => {
const HomePageComponent: React.FC<HomePageProps> = ({ children }) => {
const { pathname } = useLocation();
useInitSourcerer(getScopeFromPath(pathname));
useUrlState();
@ -58,7 +56,7 @@ const HomePageComponent: React.FC<HomePageProps> = ({ children, setHeaderActionM
<ConsoleManager>
<TourContextProvider>
<>
<GlobalHeader setHeaderActionMenu={setHeaderActionMenu} />
<GlobalHeader />
<DragDropContextWrapper browserFields={browserFields}>
{children}
</DragDropContextWrapper>

View file

@ -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$}
>

View file

@ -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<RouterProps> = ({
children,
history,
onAppLeave,
setHeaderActionMenu,
}) => {
const PageRouterComponent: FC<RouterProps> = ({ children, history, onAppLeave }) => {
const { cases } = useKibana().services;
const CasesContext = cases.ui.getCasesContext();
const userCasesPermissions = useGetUserCasesPermissions();
@ -55,7 +49,7 @@ const PageRouterComponent: FC<RouterProps> = ({
<Routes>
<Route path="/">
<CasesContext owner={[APP_ID]} permissions={userCasesPermissions}>
<HomePage setHeaderActionMenu={setHeaderActionMenu}>{children}</HomePage>
<HomePage>{children}</HomePage>
</CasesContext>
</Route>
<Route>

View file

@ -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',
}))
)
);

View file

@ -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;
};

View file

@ -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(<div data-test-subj="dashboardRenderer" />),
};
});
jest.mock('@kbn/dashboard-plugin/public', () => ({
DashboardRenderer: jest.fn().mockReturnValue(<div data-test-subj="dashboardRenderer" />),
DashboardTopNav: jest.fn().mockReturnValue(<span data-test-subj="dashboardTopNav" />),
}));
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');

View file

@ -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<DashboardAPI>();
const getCreationOptions = useCallback(
const securityTags = useSecurityTags();
const firstSecurityTagId = securityTags?.[0]?.id;
const isCreateDashboard = !savedObjectId;
const getCreationOptions: () => Promise<DashboardCreationOptions> = 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 ? (
<DashboardContainerRenderer
ref={handleDashboardLoaded}
savedObjectId={savedObjectId}
getCreationOptions={getCreationOptions}
/>
) : 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(
<DashboardContainerRenderer
ref={onDashboardContainerLoaded}
savedObjectId={savedObjectId}
getCreationOptions={getCreationOptions}
/>
);
return () => {
setDashboardContainerRenderer(undefined);
};
}, [getCreationOptions, onDashboardContainerLoaded, refetchByForceRefresh, savedObjectId]);
return canReadDashboard ? <>{dashboardContainerRenderer}</> : null;
};
DashboardRendererComponent.displayName = '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 }) => {
const { tags, isLoading } = useFetchSecurityTags();
const securityTags = isLoading || !tags ? null : tags;
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 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 }) => (
<TestProviders>
<DashboardContextProvider>{children}</DashboardContextProvider>
</TestProviders>
),
});
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');
});
});
});

View file

@ -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;
};

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 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<EuiBasicTableColumn<DashboardTableItem>> => [
{
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',

View file

@ -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 }];

View file

@ -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(
<Router history={mockHistory}>
<DashboardView />
<DashboardView initialViewMode={ViewMode.VIEW} />
</Router>,
{ wrapper: TestProviders }
);
@ -83,7 +84,7 @@ describe('DashboardView', () => {
});
const { queryByTestId } = render(
<Router history={mockHistory}>
<DashboardView />
<DashboardView initialViewMode={ViewMode.VIEW} />
</Router>,
{ wrapper: TestProviders }
);
@ -95,7 +96,7 @@ describe('DashboardView', () => {
test('render dashboard view with height', () => {
const { queryByTestId } = render(
<Router history={mockHistory}>
<DashboardView />
<DashboardView initialViewMode={ViewMode.VIEW} />
</Router>,
{ wrapper: TestProviders }
);

View file

@ -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';
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 DashboardViewComponent: React.FC = () => {
const DashboardViewComponent: React.FC<DashboardViewProps> = ({
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<DashboardCapabilities>(LEGACY_DASHBOARD_APP_ID);
const errorState = useMemo(
() => (canReadDashboard ? null : DashboardViewPromptState.NoReadPermission),
[canReadDashboard]
);
const [dashboardDetails, setDashboardDetails] = useState<DashboardDetails | undefined>();
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<ViewMode>(initialViewMode);
const { detailName: savedObjectId } = useParams<{ detailName?: string }>();
const [dashboardTitle, setDashboardTitle] = useState<string>();
const { dashboardContainer, handleDashboardLoaded } = useDashboardRenderer();
const onDashboardToolBarLoad = useCallback((mode: ViewMode) => {
setViewMode(mode);
}, []);
return (
<>
{indicesExist && (
<FiltersGlobal>
<SiemSearchBar id={InputsModelId.global} indexPattern={indexPattern} />
</FiltersGlobal>
)}
<FiltersGlobal>
<SiemSearchBar id={InputsModelId.global} indexPattern={indexPattern} />
</FiltersGlobal>
<SecuritySolutionPageWrapper>
<EuiFlexGroup
direction="column"
@ -85,16 +82,23 @@ const DashboardViewComponent: React.FC = () => {
data-test-subj="dashboard-view-wrapper"
>
<EuiFlexItem grow={false}>
<HeaderPage border title={dashboardDetails?.title ?? <EuiLoadingSpinner size="m" />}>
{showWriteControls && dashboardExists && (
<EditDashboardButton
filters={filters}
query={query}
savedObjectId={savedObjectId}
timeRange={timeRange}
/>
)}
</HeaderPage>
{dashboardContainer && (
<HeaderPage
border
title={
<DashboardTitle
dashboardContainer={dashboardContainer}
onTitleLoaded={setDashboardTitle}
/>
}
subtitle={
<DashboardToolBar
dashboardContainer={dashboardContainer}
onLoad={onDashboardToolBarLoad}
/>
}
/>
)}
</EuiFlexItem>
{!errorState && (
<EuiFlexItem grow>
@ -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}
/>
</EuiFlexItem>
)}
@ -116,7 +122,7 @@ const DashboardViewComponent: React.FC = () => {
)}
<SpyRoute
pageName={SecurityPageName.dashboards}
state={{ dashboardName: dashboardDetails?.title }}
state={{ dashboardName: dashboardTitle }}
/>
</EuiFlexGroup>
</SecuritySolutionPageWrapper>

View file

@ -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`,
}
);

View file

@ -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 (
<DashboardContextProvider>
<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`}>
<DashboardView />
<DashboardView initialViewMode={ViewMode.VIEW} />
</Route>
<Route path={`${DASHBOARDS_PATH}`}>
<DashboardsLandingPage />

View file

@ -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(<span data-test-subj="dashboardsTable" />),
};
});
jest.mock('@kbn/dashboard-plugin/public', () => ({
DashboardListingTable: jest.fn().mockReturnValue(<span data-test-subj="dashboardsTable" />),
DashboardTopNav: jest.fn().mockReturnValue(<span data-test-subj="dashboardTopNav" />),
}));
const mockUseObservable = jest.fn();

View file

@ -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 = () => {
<EuiHorizontalRule margin="s" />
<EuiSpacer size="m" />
<DashboardListingTable
disableCreateDashboardButton={loadingCreateDashboardUrl}
disableCreateDashboardButton={!canCreateDashboard}
getDashboardUrl={getSecuritySolutionDashboardUrl}
goToDashboard={goToDashboard}
initialFilter={initialFilter}

View file

@ -9,3 +9,10 @@ import { i18n } from '@kbn/i18n';
export const DASHBOARDS_PAGE_TITLE = i18n.translate('xpack.securitySolution.dashboards.pageTitle', {
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,
CASES_FEATURE_ID,
CASES_PATH,
DASHBOARDS_PATH,
EXCEPTIONS_PATH,
RULES_PATH,
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 => {
return !!matchPath(pathname, {
path: `${ALERTS_PATH}`,

View file

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

View file

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

View file

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