Register Add to new case and add to existing case action (#154918)

## Summary

Original issue: https://github.com/elastic/kibana/issues/154842

Collaboration with @cnasikas 

In dashboard, when clicking on `...` of each chart, we should be able
to:

- [x] - Add to New case
- [x] - Add to existing case
- [x] - Create case from existing case modal
- [x]  - Create case from existing case modal when no existing cases






512b32f9-10df-4f8b-a456-08f28a987826






### Checklist

Delete any items that are not applicable to this PR.


- [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: Christos Nasikas <christos.nasikas@elastic.co>
This commit is contained in:
Angela Chuang 2023-06-02 11:52:47 +01:00 committed by GitHub
parent 1ba8be4b8a
commit b8eed601f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1346 additions and 44 deletions

View file

@ -7,7 +7,7 @@ pageLoadAssetSize:
banners: 17946
bfetch: 22837
canvas: 1066647
cases: 175000
cases: 180000
charts: 55000
cloud: 21076
cloudChat: 19894

View file

@ -6,16 +6,30 @@
*/
import { OWNER_INFO } from '../constants';
import { isValidOwner } from './owner';
import { getCaseOwnerByAppId, isValidOwner } from './owner';
describe('isValidOwner', () => {
const owners = Object.keys(OWNER_INFO) as Array<keyof typeof OWNER_INFO>;
describe('owner utils', () => {
describe('isValidOwner', () => {
const owners = Object.keys(OWNER_INFO) as Array<keyof typeof OWNER_INFO>;
it.each(owners)('returns true for valid owner: %s', (owner) => {
expect(isValidOwner(owner)).toBe(true);
it.each(owners)('returns true for valid owner: %s', (owner) => {
expect(isValidOwner(owner)).toBe(true);
});
it('return false for invalid owner', () => {
expect(isValidOwner('not-valid')).toBe(false);
});
});
it('return false for invalid owner', () => {
expect(isValidOwner('not-valid')).toBe(false);
describe('getCaseOwnerByAppId', () => {
const tests = Object.values(OWNER_INFO).map((info) => [info.id, info.appId]);
it.each(tests)('for owner %s it returns %s', (owner, appId) => {
expect(getCaseOwnerByAppId(appId)).toBe(owner);
});
it('return undefined for invalid application ID', () => {
expect(getCaseOwnerByAppId('not-valid')).toBe(undefined);
});
});
});

View file

@ -9,3 +9,6 @@ import { OWNER_INFO } from '../constants';
export const isValidOwner = (owner: string): owner is keyof typeof OWNER_INFO =>
Object.keys(OWNER_INFO).includes(owner);
export const getCaseOwnerByAppId = (currentAppId?: string) =>
Object.values(OWNER_INFO).find((info) => info.appId === currentAppId)?.id;

View file

@ -28,7 +28,8 @@
"ruleRegistry",
"files",
"savedObjectsFinder",
"savedObjectsManagement"
"savedObjectsManagement",
"uiActions",
],
"optionalPlugins": [
"home",

View file

@ -8,7 +8,7 @@
import type { CoreStart } from '@kbn/core/public';
import type { CasesUiConfigType } from '../../../../common/ui/types';
type GlobalServices = Pick<CoreStart, 'http'>;
type GlobalServices = Pick<CoreStart, 'application' | 'http' | 'theme'>;
export class KibanaServices {
private static kibanaVersion?: string;
@ -16,14 +16,16 @@ export class KibanaServices {
private static config?: CasesUiConfigType;
public static init({
application,
config,
http,
kibanaVersion,
config,
theme,
}: GlobalServices & {
kibanaVersion: string;
config: CasesUiConfigType;
}) {
this.services = { http };
this.services = { application, http, theme };
this.kibanaVersion = kibanaVersion;
this.config = config;
}

View file

@ -66,7 +66,6 @@ const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>;
const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock;
const useUpdateCaseMock = useUpdateCase as jest.Mock;
const useLicenseMock = useLicense as jest.Mock;
const mockTriggersActionsUiService = triggersActionsUiMock.createStart();
const mockKibana = () => {
@ -165,7 +164,6 @@ describe('AllCasesListGeneric', () => {
it('should render AllCasesList', async () => {
useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true });
appMockRenderer.render(<AllCasesList />);
await waitFor(() => {
@ -260,6 +258,21 @@ describe('AllCasesListGeneric', () => {
});
});
it('should not call onCreateCasePressed if onRowClick is not provided when create case from case page', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
data: {
...defaultGetCases.data,
cases: [],
},
});
appMockRenderer.render(<AllCasesList isSelectorView={false} />);
userEvent.click(screen.getByTestId('cases-table-add-case'));
await waitFor(() => {
expect(onRowClick).not.toHaveBeenCalled();
});
});
it('should tableHeaderSortButton AllCasesList', async () => {
appMockRenderer.render(<AllCasesList />);
@ -347,9 +360,10 @@ describe('AllCasesListGeneric', () => {
it('should call onRowClick with no cases and isSelectorView=true when create case is clicked', async () => {
appMockRenderer.render(<AllCasesList isSelectorView={true} onRowClick={onRowClick} />);
userEvent.click(screen.getByTestId('cases-table-add-case-filter-bar'));
const isCreateCase = true;
await waitFor(() => {
expect(onRowClick).toHaveBeenCalled();
expect(onRowClick).toBeCalledWith(undefined, isCreateCase);
});
});

View file

@ -69,7 +69,7 @@ const mapToReadableSolutionName = (solution: string): Solution => {
export interface AllCasesListProps {
hiddenStatuses?: CaseStatusWithAllStatus[];
isSelectorView?: boolean;
onRowClick?: (theCase?: CaseUI) => void;
onRowClick?: (theCase?: CaseUI, isCreateCase?: boolean) => void;
}
export const AllCasesList = React.memo<AllCasesListProps>(
@ -250,6 +250,10 @@ export const AllCasesList = React.memo<AllCasesListProps>(
mapToReadableSolutionName(solution)
);
const onCreateCasePressed = useCallback(() => {
onRowClick?.(undefined, true);
}, [onRowClick]);
return (
<>
<ProgressLoader
@ -276,7 +280,7 @@ export const AllCasesList = React.memo<AllCasesListProps>(
severity: filterOptions.severity,
}}
hiddenStatuses={hiddenStatuses}
onCreateCasePressed={onRowClick}
onCreateCasePressed={onCreateCasePressed}
isSelectorView={isSelectorView}
isLoading={isLoadingCurrentUserProfile}
currentUserProfile={currentUserProfile}
@ -284,7 +288,7 @@ export const AllCasesList = React.memo<AllCasesListProps>(
<CasesTable
columns={columns}
data={data}
goToCreateCase={onRowClick}
goToCreateCase={onRowClick ? onCreateCasePressed : undefined}
isCasesLoading={isLoadingCases}
isCommentUpdating={isLoadingCases}
isDataEmpty={isDataEmpty}

View file

@ -23,7 +23,8 @@ import { AllCasesList } from '../all_cases_list';
export interface AllCasesSelectorModalProps {
hiddenStatuses?: CaseStatusWithAllStatus[];
onRowClick?: (theCase?: CaseUI) => void;
onClose?: () => void;
onClose?: (theCase?: CaseUI, isCreateCase?: boolean) => void;
onCreateCaseClicked?: () => void;
}
const Modal = styled(EuiModal)`
@ -37,20 +38,18 @@ export const AllCasesSelectorModal = React.memo<AllCasesSelectorModalProps>(
({ hiddenStatuses, onRowClick, onClose }) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(true);
const closeModal = useCallback(() => {
if (onClose) {
onClose();
}
onClose?.();
setIsModalOpen(false);
}, [onClose]);
const onClick = useCallback(
(theCase?: CaseUI) => {
closeModal();
if (onRowClick) {
onRowClick(theCase);
}
(theCase?: CaseUI, isCreateCase?: boolean) => {
onClose?.(theCase, isCreateCase);
setIsModalOpen(false);
onRowClick?.(theCase);
},
[closeModal, onRowClick]
[onClose, onRowClick]
);
return isModalOpen ? (

View file

@ -106,13 +106,13 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingCaseModalProp
}
},
[
props,
closeModal,
createNewCaseFlyout,
startTransaction,
appId,
createAttachments,
casesToasts,
closeModal,
createAttachments,
createNewCaseFlyout,
props,
startTransaction,
]
);
@ -130,11 +130,11 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingCaseModalProp
onRowClick: (theCase?: CaseUI) => {
handleOnRowClick(theCase, getAttachments);
},
onClose: () => {
onClose: (theCase?: CaseUI, isCreateCase?: boolean) => {
closeModal();
if (props.onClose) {
return props.onClose();
return props.onClose(theCase, isCreateCase);
}
},
},

View file

@ -0,0 +1,12 @@
/*
* 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';
export const ActionWrapper = jest
.fn()
.mockImplementation(({ children }) => <div data-test-subj="action-wrapper">{children}</div>);

View file

@ -0,0 +1,152 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { SECURITY_SOLUTION_OWNER } from '../../../../common';
import { canUseCases } from '../../../client/helpers/can_use_cases';
import CasesProvider from '../../cases_context';
import { ActionWrapper } from './action_wrapper';
import { getMockCaseUiActionProps } from './mocks';
jest.mock('../../cases_context', () =>
jest.fn().mockImplementation(({ children, ...props }) => <div {...props}>{children}</div>)
);
jest.mock('../../../client/helpers/can_use_cases', () => {
const actual = jest.requireActual('../../../client/helpers/can_use_cases');
return {
...actual,
canUseCases: jest.fn(),
};
});
const mockCasePermissions = jest.fn().mockReturnValue({ create: true, update: true });
describe('ActionWrapper', () => {
const props = { ...getMockCaseUiActionProps(), currentAppId: 'securitySolutionUI' };
beforeEach(() => {
jest.clearAllMocks();
(canUseCases as jest.Mock).mockReturnValue(mockCasePermissions);
});
it('reads cases permissions', () => {
render(
<ActionWrapper {...props}>
<div />
</ActionWrapper>
);
expect(mockCasePermissions).toHaveBeenCalledWith([SECURITY_SOLUTION_OWNER]);
});
it('renders CasesProvider with correct props for Security solution', () => {
render(
<ActionWrapper {...props}>
<div />
</ActionWrapper>
);
expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(`
Object {
"features": Object {
"alerts": Object {
"sync": true,
},
},
"owner": Array [
"securitySolution",
],
"permissions": Object {
"create": true,
"update": true,
},
}
`);
});
it('renders CasesProvider with correct props for stack management', () => {
render(
<ActionWrapper {...props} currentAppId="management">
<div />
</ActionWrapper>
);
expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(`
Object {
"features": Object {
"alerts": Object {
"sync": false,
},
},
"owner": Array [
"cases",
],
"permissions": Object {
"create": true,
"update": true,
},
}
`);
});
it('renders CasesProvider with correct props for observability', () => {
render(
<ActionWrapper {...props} currentAppId="observability-overview">
<div />
</ActionWrapper>
);
expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(`
Object {
"features": Object {
"alerts": Object {
"sync": false,
},
},
"owner": Array [
"observability",
],
"permissions": Object {
"create": true,
"update": true,
},
}
`);
});
it('renders CasesProvider with correct props for an application without cases', () => {
render(
<ActionWrapper {...props} currentAppId="dashboard">
<div />
</ActionWrapper>
);
expect((CasesProvider as jest.Mock).mock.calls[0][0].value).toMatchInlineSnapshot(`
Object {
"features": Object {
"alerts": Object {
"sync": false,
},
},
"owner": Array [],
"permissions": Object {
"create": true,
"update": true,
},
}
`);
});
it('should check permission with undefined if owner is not found', () => {
render(
<ActionWrapper {...props} currentAppId="dashboard">
<div />
</ActionWrapper>
);
expect(mockCasePermissions).toBeCalledWith(undefined);
});
});

View file

@ -0,0 +1,91 @@
/*
* 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 { PropsWithChildren } from 'react';
import React from 'react';
import { Router } from 'react-router-dom';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { useIsDarkTheme } from '../../../common/use_is_dark_theme';
import { SECURITY_SOLUTION_OWNER } from '../../../../common';
import type { CasesUIActionProps } from './types';
import { KibanaContextProvider, useKibana } from '../../../common/lib/kibana';
import CasesProvider from '../../cases_context';
import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
import { canUseCases } from '../../../client/helpers/can_use_cases';
export const DEFAULT_DARK_MODE = 'theme:darkMode' as const;
interface Props {
caseContextProps: CasesUIActionProps['caseContextProps'];
currentAppId?: string;
}
const ActionWrapperWithContext: React.FC<PropsWithChildren<Props>> = ({
children,
caseContextProps,
currentAppId,
}) => {
const { application } = useKibana().services;
const isDarkTheme = useIsDarkTheme();
const owner = getCaseOwnerByAppId(currentAppId);
const casePermissions = canUseCases(application.capabilities)(owner ? [owner] : undefined);
// TODO: Remove when https://github.com/elastic/kibana/issues/143201 is developed
const syncAlerts = owner === SECURITY_SOLUTION_OWNER;
return (
<EuiThemeProvider darkMode={isDarkTheme}>
<CasesProvider
value={{
...caseContextProps,
owner: owner ? [owner] : [],
permissions: casePermissions,
features: { alerts: { sync: syncAlerts } },
}}
>
{children}
</CasesProvider>
</EuiThemeProvider>
);
};
ActionWrapperWithContext.displayName = 'ActionWrapperWithContext';
type ActionWrapperComponentProps = PropsWithChildren<
CasesUIActionProps & { currentAppId?: string }
>;
const ActionWrapperComponent: React.FC<ActionWrapperComponentProps> = ({
core,
plugins,
storage,
history,
children,
caseContextProps,
currentAppId,
}) => {
return (
<KibanaContextProvider
services={{
...core,
...plugins,
storage,
}}
>
<Router history={history}>
<ActionWrapperWithContext caseContextProps={caseContextProps} currentAppId={currentAppId}>
{children}
</ActionWrapperWithContext>
</Router>
</KibanaContextProvider>
);
};
ActionWrapperComponent.displayName = 'ActionWrapper';
export const ActionWrapper = React.memo(ActionWrapperComponent);

View file

@ -0,0 +1,217 @@
/*
* 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 { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-plugin/public';
import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import type { Action } from '@kbn/ui-actions-plugin/public';
import ReactDOM, { unmountComponentAtNode } from 'react-dom';
import { createAddToExistingCaseLensAction } from './add_to_existing_case';
import type { ActionContext, DashboardVisualizationEmbeddable } from './types';
import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal';
import React from 'react';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import {
getMockApplications$,
getMockCaseUiActionProps,
getMockCurrentAppId$,
mockAttributes,
MockEmbeddable,
mockTimeRange,
} from './mocks';
import { CommentType } from '../../../../common';
import { useKibana } from '../../../common/lib/kibana';
import { waitFor } from '@testing-library/dom';
import { canUseCases } from '../../../client/helpers/can_use_cases';
import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
const element = document.createElement('div');
document.body.appendChild(element);
jest.mock('../../all_cases/selector_modal/use_cases_add_to_existing_case_modal', () => ({
useCasesAddToExistingCaseModal: jest.fn(),
}));
jest.mock('../../../client/helpers/can_use_cases', () => {
const actual = jest.requireActual('../../../client/helpers/can_use_cases');
return {
...actual,
canUseCases: jest.fn(),
};
});
jest.mock('@kbn/kibana-react-plugin/public', () => ({
toMountPoint: jest.fn(),
KibanaThemeProvider: jest.fn().mockImplementation(({ children }) => <>{children}</>),
}));
jest.mock('../../../common/lib/kibana', () => {
return {
useKibana: jest.fn(),
KibanaContextProvider: jest
.fn()
.mockImplementation(({ children, ...props }) => <div {...props}>{children}</div>),
};
});
jest.mock('react-dom', () => {
const original = jest.requireActual('react-dom');
return { ...original, unmountComponentAtNode: jest.fn() };
});
jest.mock('./action_wrapper');
jest.mock('../../../../common/utils/owner', () => ({
getCaseOwnerByAppId: jest.fn().mockReturnValue('securitySolution'),
}));
describe('createAddToExistingCaseLensAction', () => {
const mockEmbeddable = new MockEmbeddable(LENS_EMBEDDABLE_TYPE, {
id: 'mockId',
attributes: mockAttributes,
timeRange: mockTimeRange,
}) as unknown as DashboardVisualizationEmbeddable;
const context = {
embeddable: mockEmbeddable,
} as unknown as ActionContext;
const caseUiActionProps = getMockCaseUiActionProps();
const mockUseCasesAddToExistingCaseModal = useCasesAddToExistingCaseModal as jest.Mock;
const mockOpenModal = jest.fn();
const mockMount = jest.fn();
let action: Action<ActionContext>;
const mockCasePermissions = jest.fn();
beforeEach(() => {
mockUseCasesAddToExistingCaseModal.mockReturnValue({
open: mockOpenModal,
});
(useKibana as jest.Mock).mockReturnValue({
services: {
application: {
currentAppId$: getMockCurrentAppId$(),
applications$: getMockApplications$(),
},
},
});
(canUseCases as jest.Mock).mockReturnValue(
mockCasePermissions.mockReturnValue({ create: true, update: true })
);
(toMountPoint as jest.Mock).mockImplementation((node) => {
ReactDOM.render(node, element);
return mockMount;
});
jest.clearAllMocks();
action = createAddToExistingCaseLensAction(caseUiActionProps);
});
test('it should return display name', () => {
expect(action.getDisplayName(context)).toEqual('Add to existing case');
});
it('should return icon type', () => {
expect(action.getIconType(context)).toEqual('casesApp');
});
describe('isCompatible', () => {
it('should return false if error embeddable', async () => {
expect(
await action.isCompatible({
...context,
embeddable: new ErrorEmbeddable('some error', {
id: '123',
}) as unknown as DashboardVisualizationEmbeddable,
})
).toEqual(false);
});
it('should return false if not lens embeddable', async () => {
expect(
await action.isCompatible({
...context,
embeddable: new MockEmbeddable('not_lens') as unknown as DashboardVisualizationEmbeddable,
})
).toEqual(false);
});
it('should return false if no permission', async () => {
mockCasePermissions.mockReturnValue({ create: false, update: false });
expect(await action.isCompatible(context)).toEqual(false);
});
it('should return true if is lens embeddable', async () => {
expect(await action.isCompatible(context)).toEqual(true);
});
it('should check permission with undefined if owner is not found', async () => {
(getCaseOwnerByAppId as jest.Mock).mockReturnValue(undefined);
await action.isCompatible(context);
expect(mockCasePermissions).toBeCalledWith(undefined);
});
});
describe('execute', () => {
beforeEach(async () => {
await action.execute(context);
});
it('should execute', () => {
expect(toMountPoint).toHaveBeenCalled();
expect(mockMount).toHaveBeenCalled();
});
});
describe('Add to existing case modal', () => {
beforeEach(async () => {
await action.execute(context);
});
it('should open modal with an attachment', async () => {
await waitFor(() => {
expect(mockOpenModal).toHaveBeenCalled();
const getAttachments = mockOpenModal.mock.calls[0][0].getAttachments;
expect(getAttachments()).toEqual(
expect.objectContaining([
{
comment: `!{lens${JSON.stringify({
timeRange: mockTimeRange,
attributes: mockAttributes,
})}}`,
type: CommentType.user as const,
},
])
);
});
});
it('should have correct onClose handler - when close modal clicked', () => {
const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose;
onClose();
expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
});
it('should have correct onClose handler - when case selected', () => {
const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose;
onClose({ id: 'case-id', title: 'case-title' });
expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
});
it('should have correct onClose handler - when case created', () => {
const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose;
onClose(null, true);
expect(unmountComponentAtNode as jest.Mock).not.toHaveBeenCalled();
});
it('should have correct onSuccess handler', () => {
const onSuccess = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onSuccess;
onSuccess();
expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,132 @@
/*
* 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, useMemo } from 'react';
import { unmountComponentAtNode } from 'react-dom';
import { createAction } from '@kbn/ui-actions-plugin/public';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import type { CaseUI } from '../../../../common';
import { isLensEmbeddable, hasInput, getLensCaseAttachment } from './utils';
import type { ActionContext, CasesUIActionProps, DashboardVisualizationEmbeddable } from './types';
import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal';
import { ADD_TO_EXISTING_CASE_DISPLAYNAME } from './translations';
import { ActionWrapper } from './action_wrapper';
import { canUseCases } from '../../../client/helpers/can_use_cases';
import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
export const ACTION_ID = 'embeddable_addToExistingCase';
export const DEFAULT_DARK_MODE = 'theme:darkMode' as const;
interface Props {
embeddable: DashboardVisualizationEmbeddable;
onSuccess: () => void;
onClose: (theCase?: CaseUI) => void;
}
const AddExistingCaseModalWrapper: React.FC<Props> = ({ embeddable, onClose, onSuccess }) => {
const modal = useCasesAddToExistingCaseModal({
onClose,
onSuccess,
});
const attachments = useMemo(() => {
const { attributes, timeRange } = embeddable.getInput();
return [getLensCaseAttachment({ attributes, timeRange })];
}, [embeddable]);
useEffect(() => {
modal.open({ getAttachments: () => attachments });
}, [attachments, modal]);
return null;
};
AddExistingCaseModalWrapper.displayName = 'AddExistingCaseModalWrapper';
export const createAddToExistingCaseLensAction = ({
core,
plugins,
storage,
history,
caseContextProps,
}: CasesUIActionProps) => {
const { application: applicationService, theme } = core;
let currentAppId: string | undefined;
applicationService?.currentAppId$.subscribe((appId) => {
currentAppId = appId;
});
return createAction<ActionContext>({
id: ACTION_ID,
type: 'actionButton',
getIconType: () => 'casesApp',
getDisplayName: () => ADD_TO_EXISTING_CASE_DISPLAYNAME,
isCompatible: async ({ embeddable }) => {
const owner = getCaseOwnerByAppId(currentAppId);
const casePermissions = canUseCases(applicationService.capabilities)(
owner ? [owner] : undefined
);
return (
!isErrorEmbeddable(embeddable) &&
isLensEmbeddable(embeddable) &&
casePermissions.update &&
casePermissions.create &&
hasInput(embeddable)
);
},
execute: async ({ embeddable }) => {
const targetDomElement = document.createElement('div');
const cleanupDom = (shouldCleanup?: boolean) => {
if (targetDomElement != null && shouldCleanup) {
unmountComponentAtNode(targetDomElement);
}
};
const onClose = (theCase?: CaseUI, isCreateCase?: boolean) => {
const closeModalClickedScenario = theCase == null && !isCreateCase;
const caseSelectedScenario = theCase != null;
// When `Creating` a case from the `add to existing case modal`,
// we close the modal and then open the flyout.
// If we clean up dom when closing the modal, then the flyout won't open.
// Thus we do not clean up dom when `Creating` a case.
const shouldCleanup = closeModalClickedScenario || caseSelectedScenario;
cleanupDom(shouldCleanup);
};
const onSuccess = () => {
cleanupDom(true);
};
const mount = toMountPoint(
<ActionWrapper
core={core}
caseContextProps={caseContextProps}
storage={storage}
plugins={plugins}
history={history}
currentAppId={currentAppId}
>
<AddExistingCaseModalWrapper
embeddable={embeddable}
onClose={onClose}
onSuccess={onSuccess}
/>
</ActionWrapper>,
{ theme$: theme.theme$ }
);
mount(targetDomElement);
},
});
};

View file

@ -0,0 +1,208 @@
/*
* 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 { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-plugin/public';
import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import type { Action } from '@kbn/ui-actions-plugin/public';
import { createAddToNewCaseLensAction } from './add_to_new_case';
import type { ActionContext, DashboardVisualizationEmbeddable } from './types';
import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to_new_case_flyout';
import React from 'react';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import {
getMockApplications$,
getMockCaseUiActionProps,
getMockCurrentAppId$,
mockAttributes,
MockEmbeddable,
mockTimeRange,
} from './mocks';
import ReactDOM, { unmountComponentAtNode } from 'react-dom';
import { useKibana } from '../../../common/lib/kibana';
import { CommentType } from '../../../../common';
import { waitFor } from '@testing-library/dom';
import { canUseCases } from '../../../client/helpers/can_use_cases';
import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
const element = document.createElement('div');
document.body.appendChild(element);
jest.mock('@kbn/kibana-react-plugin/public', () => ({
toMountPoint: jest.fn(),
}));
jest.mock('../../../common/lib/kibana', () => {
return {
useKibana: jest.fn(),
KibanaContextProvider: jest
.fn()
.mockImplementation(({ children, ...props }) => <div {...props}>{children}</div>),
};
});
jest.mock('../../create/flyout/use_cases_add_to_new_case_flyout', () => ({
useCasesAddToNewCaseFlyout: jest.fn(),
}));
jest.mock('../../../client/helpers/can_use_cases', () => {
const actual = jest.requireActual('../../../client/helpers/can_use_cases');
return {
...actual,
canUseCases: jest.fn(),
};
});
jest.mock('react-dom', () => {
const original = jest.requireActual('react-dom');
return { ...original, unmountComponentAtNode: jest.fn() };
});
jest.mock('./action_wrapper');
jest.mock('../../../../common/utils/owner', () => ({
getCaseOwnerByAppId: jest.fn().mockReturnValue('securitySolution'),
}));
describe('createAddToNewCaseLensAction', () => {
const mockEmbeddable = new MockEmbeddable(LENS_EMBEDDABLE_TYPE, {
id: 'mockId',
attributes: mockAttributes,
timeRange: mockTimeRange,
}) as unknown as DashboardVisualizationEmbeddable;
const context = {
embeddable: mockEmbeddable,
} as unknown as ActionContext;
const caseUiActionProps = getMockCaseUiActionProps();
const mockUseCasesAddToNewCaseFlyout = useCasesAddToNewCaseFlyout as jest.Mock;
const mockOpenFlyout = jest.fn();
const mockMount = jest.fn();
let action: Action<ActionContext>;
const mockCasePermissions = jest.fn();
beforeEach(() => {
mockUseCasesAddToNewCaseFlyout.mockReturnValue({
open: mockOpenFlyout,
});
(useKibana as jest.Mock).mockReturnValue({
services: {
application: {
currentAppId$: getMockCurrentAppId$(),
applications$: getMockApplications$(),
},
},
});
(canUseCases as jest.Mock).mockReturnValue(
mockCasePermissions.mockReturnValue({ create: true, update: true })
);
(toMountPoint as jest.Mock).mockImplementation((node) => {
ReactDOM.render(node, element);
return mockMount;
});
jest.clearAllMocks();
action = createAddToNewCaseLensAction(caseUiActionProps);
});
test('it should return display name', () => {
expect(action.getDisplayName(context)).toEqual('Add to new case');
});
it('should return icon type', () => {
expect(action.getIconType(context)).toEqual('casesApp');
});
describe('isCompatible', () => {
it('should return false if error embeddable', async () => {
expect(
await action.isCompatible({
...context,
embeddable: new ErrorEmbeddable('some error', {
id: '123',
}) as unknown as DashboardVisualizationEmbeddable,
})
).toEqual(false);
});
it('should return false if not lens embeddable', async () => {
expect(
await action.isCompatible({
...context,
embeddable: new MockEmbeddable('not_lens') as unknown as DashboardVisualizationEmbeddable,
})
).toEqual(false);
});
it('should return false if no permission', async () => {
mockCasePermissions.mockReturnValue({ create: false, update: false });
expect(await action.isCompatible(context)).toEqual(false);
});
it('should return true if is lens embeddable', async () => {
expect(await action.isCompatible(context)).toEqual(true);
});
it('should check permission with undefined if owner is not found', async () => {
(getCaseOwnerByAppId as jest.Mock).mockReturnValue(undefined);
await action.isCompatible(context);
expect(mockCasePermissions).toBeCalledWith(undefined);
});
});
describe('execute', () => {
beforeEach(async () => {
await action.execute(context);
});
it('should execute', () => {
expect(toMountPoint).toHaveBeenCalled();
expect(mockMount).toHaveBeenCalled();
});
});
describe('Add to new case flyout', () => {
beforeEach(async () => {
await action.execute(context);
});
it('should open flyout', async () => {
await waitFor(() => {
expect(mockOpenFlyout).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
{
comment: `!{lens${JSON.stringify({
timeRange: mockTimeRange,
attributes: mockAttributes,
})}}`,
type: CommentType.user as const,
},
],
})
);
});
});
it('should have correct onClose handler', () => {
const onClose = mockUseCasesAddToNewCaseFlyout.mock.calls[0][0].onClose;
onClose();
expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
});
it('should have correct onSuccess handler', () => {
const onSuccess = mockUseCasesAddToNewCaseFlyout.mock.calls[0][0].onSuccess;
onSuccess();
expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,122 @@
/*
* 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, useMemo } from 'react';
import { unmountComponentAtNode } from 'react-dom';
import { createAction } from '@kbn/ui-actions-plugin/public';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
import { hasInput, isLensEmbeddable, getLensCaseAttachment } from './utils';
import type { ActionContext, CasesUIActionProps, DashboardVisualizationEmbeddable } from './types';
import { ADD_TO_CASE_SUCCESS, ADD_TO_NEW_CASE_DISPLAYNAME } from './translations';
import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to_new_case_flyout';
import { ActionWrapper } from './action_wrapper';
import { canUseCases } from '../../../client/helpers/can_use_cases';
export const ACTION_ID = 'embeddable_addToNewCase';
export const DEFAULT_DARK_MODE = 'theme:darkMode' as const;
interface Props {
embeddable: DashboardVisualizationEmbeddable;
onSuccess: () => void;
onClose: () => void;
}
const AddToNewCaseFlyoutWrapper: React.FC<Props> = ({ embeddable, onClose, onSuccess }) => {
const { attributes, timeRange } = embeddable.getInput();
const createNewCaseFlyout = useCasesAddToNewCaseFlyout({
onClose,
onSuccess,
toastContent: ADD_TO_CASE_SUCCESS,
});
const attachments = useMemo(
() => [getLensCaseAttachment({ attributes, timeRange })],
[attributes, timeRange]
);
useEffect(() => {
createNewCaseFlyout.open({ attachments });
}, [attachments, createNewCaseFlyout]);
return null;
};
AddToNewCaseFlyoutWrapper.displayName = 'AddToNewCaseFlyoutWrapper';
export const createAddToNewCaseLensAction = ({
core,
plugins,
storage,
history,
caseContextProps,
}: CasesUIActionProps) => {
const { application: applicationService, theme } = core;
let currentAppId: string | undefined;
applicationService?.currentAppId$.subscribe((appId) => {
currentAppId = appId;
});
return createAction<ActionContext>({
id: ACTION_ID,
type: 'actionButton',
getIconType: () => 'casesApp',
getDisplayName: () => ADD_TO_NEW_CASE_DISPLAYNAME,
isCompatible: async ({ embeddable }) => {
const owner = getCaseOwnerByAppId(currentAppId);
const casePermissions = canUseCases(applicationService.capabilities)(
owner ? [owner] : undefined
);
return (
!isErrorEmbeddable(embeddable) &&
isLensEmbeddable(embeddable) &&
casePermissions.update &&
casePermissions.create &&
hasInput(embeddable)
);
},
execute: async ({ embeddable }) => {
const targetDomElement = document.createElement('div');
const cleanupDom = () => {
if (targetDomElement != null) {
unmountComponentAtNode(targetDomElement);
}
};
const onFlyoutClose = () => {
cleanupDom();
};
const mount = toMountPoint(
<ActionWrapper
core={core}
caseContextProps={caseContextProps}
storage={storage}
plugins={plugins}
history={history}
currentAppId={currentAppId}
>
<AddToNewCaseFlyoutWrapper
embeddable={embeddable}
onClose={onFlyoutClose}
onSuccess={onFlyoutClose}
/>
</ActionWrapper>,
{ theme$: theme.theme$ }
);
mount(targetDomElement);
},
});
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { registerUIActions as registerActions } from './register';

View file

@ -0,0 +1,96 @@
/*
* 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 { CoreTheme, PublicAppInfo } from '@kbn/core/public';
import { BehaviorSubject, of } from 'rxjs';
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { createBrowserHistory } from 'history';
import type { CasesUIActionProps } from './types';
const mockTheme: CoreTheme = {
darkMode: false,
};
const createThemeMock = (): CoreTheme => {
return { ...mockTheme };
};
export const createTheme$Mock = () => {
return of(createThemeMock());
};
export class MockEmbeddable {
public type;
private input;
constructor(
type: string,
input?: {
attributes: TypedLensByValueInput['attributes'];
id: string;
timeRange: { from: string; to: string; fromStr: string; toStr: string };
}
) {
this.type = type;
this.input = input;
}
getFilters() {}
getQuery() {}
getInput() {
return this.input;
}
}
export const mockAttributes = {
title: 'mockTitle',
description: 'mockDescription',
references: [],
state: {
visualization: {
id: 'mockId',
type: 'mockType',
title: 'mockTitle',
visualizationType: 'mockVisualizationType',
references: [],
state: {
datasourceStates: {
indexpattern: {},
},
},
},
},
} as unknown as TypedLensByValueInput['attributes'];
export const mockTimeRange = { from: '', to: '', fromStr: '', toStr: '' };
export const getMockCurrentAppId$ = () => new BehaviorSubject<string>('securitySolutionUI');
export const getMockApplications$ = () =>
new BehaviorSubject<Map<string, PublicAppInfo>>(
new Map([['securitySolutionUI', { category: { label: 'Test' } } as unknown as PublicAppInfo]])
);
export const getMockCaseUiActionProps = () => {
const core = {
application: { currentAppId$: getMockCurrentAppId$(), capabilities: {} },
theme: { theme$: createTheme$Mock() },
uiSettings: {
get: jest.fn().mockReturnValue(true),
},
};
const plugins = {};
const storage = {};
const history = createBrowserHistory();
const caseContextProps = {};
const caseUiActionProps = {
core,
plugins,
storage,
history,
caseContextProps,
} as unknown as CasesUIActionProps;
return caseUiActionProps;
};

View file

@ -0,0 +1,48 @@
/*
* 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 { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
import { createAddToNewCaseLensAction } from './add_to_new_case';
import { createAddToExistingCaseLensAction } from './add_to_existing_case';
import type { CasesUIActionProps } from './types';
export const registerUIActions = ({
core,
plugins,
caseContextProps,
history,
storage,
}: CasesUIActionProps) => {
registerLensActions({ core, plugins, caseContextProps, history, storage });
};
const registerLensActions = ({
core,
plugins,
caseContextProps,
history,
storage,
}: CasesUIActionProps) => {
const addToNewCaseAction = createAddToNewCaseLensAction({
core,
plugins,
caseContextProps,
history,
storage,
});
plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, addToNewCaseAction);
const addToExistingCaseAction = createAddToExistingCaseLensAction({
core,
plugins,
caseContextProps,
history,
storage,
});
plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, addToExistingCaseAction);
};

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const ADD_TO_CASE_SUCCESS = i18n.translate(
'xpack.cases.visualizationActions.addToExistingCaseSuccessContent',
{
defaultMessage: 'Successfully added visualization to the case',
}
);
export const ADD_TO_NEW_CASE_DISPLAYNAME = i18n.translate(
'xpack.cases.actions.visualizationActions.addToNewCase.displayName',
{
defaultMessage: 'Add to new case',
}
);
export const ADD_TO_EXISTING_CASE_DISPLAYNAME = i18n.translate(
'xpack.cases.actions.visualizationActions.addToExistingCase.displayName',
{
defaultMessage: 'Add to existing case',
}
);

View file

@ -0,0 +1,44 @@
/*
* 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 { TimeRange } from '@kbn/data-plugin/common';
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import type { IEmbeddable } from '@kbn/embeddable-plugin/public';
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import type * as H from 'history';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import type { CasesPluginStart } from '../../../types';
import type { CasesContextProps } from '../../cases_context';
export type CasesUIActionContextProps = Pick<
CasesContextProps,
| 'externalReferenceAttachmentTypeRegistry'
| 'persistableStateAttachmentTypeRegistry'
| 'getFilesClient'
>;
export interface CasesUIActionProps {
core: CoreStart;
plugins: CasesPluginStart;
caseContextProps: CasesUIActionContextProps;
history: H.History;
storage: Storage;
}
export interface EmbeddableInput {
attributes: TypedLensByValueInput['attributes'];
id: string;
timeRange: TimeRange;
}
export type DashboardVisualizationEmbeddable = IEmbeddable<EmbeddableInput>;
export type ActionContext = ActionExecutionContext<{
embeddable: DashboardVisualizationEmbeddable;
}>;

View file

@ -0,0 +1,60 @@
/*
* 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 { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-plugin/public';
import { isLensEmbeddable, hasInput, getLensCaseAttachment } from './utils';
describe('utils', () => {
describe('isLensEmbeddable', () => {
it('return true if it is a lens embeddable', () => {
// @ts-expect-error: extra attributes are not needed
expect(isLensEmbeddable({ type: LENS_EMBEDDABLE_TYPE })).toBe(true);
});
it('return false if it is not a lens embeddable', () => {
// @ts-expect-error: extra attributes are not needed
expect(isLensEmbeddable({ type: 'not-exist' })).toBe(false);
});
});
describe('hasInput', () => {
it('return true if it has correct input', () => {
const embeddable = { getInput: () => ({ attributes: {}, timeRange: {} }) };
// @ts-expect-error: extra attributes are not needed
expect(hasInput(embeddable)).toBe(true);
});
it('return false if attributes are null', () => {
const embeddable = { getInput: () => ({ attributes: null, timeRange: {} }) };
// @ts-expect-error: extra attributes are not needed
expect(hasInput(embeddable)).toBe(false);
});
it('return false if timeRange is null', () => {
const embeddable = { getInput: () => ({ attributes: {}, timeRange: null }) };
// @ts-expect-error: extra attributes are not needed
expect(hasInput(embeddable)).toBe(false);
});
});
describe('getLensCaseAttachment', () => {
it('create a case lens attachment correctly', () => {
const embeddable = { attributes: {}, timeRange: {} };
// @ts-expect-error: extra attributes are not needed
expect(getLensCaseAttachment(embeddable)).toMatchInlineSnapshot(`
Object {
"comment": "!{lens{\\"timeRange\\":{},\\"attributes\\":{}}}",
"type": "user",
}
`);
});
});
});

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { IEmbeddable } from '@kbn/embeddable-plugin/public';
import { LENS_EMBEDDABLE_TYPE, type Embeddable as LensEmbeddable } from '@kbn/lens-plugin/public';
import { CommentType } from '../../../../common';
import type { DashboardVisualizationEmbeddable, EmbeddableInput } from './types';
export const isLensEmbeddable = (embeddable: IEmbeddable): embeddable is LensEmbeddable => {
return embeddable.type === LENS_EMBEDDABLE_TYPE;
};
export const hasInput = (embeddable: DashboardVisualizationEmbeddable) => {
const { attributes, timeRange } = embeddable.getInput();
return attributes != null && timeRange != null;
};
export const getLensCaseAttachment = ({ timeRange, attributes }: Omit<EmbeddableInput, 'id'>) => ({
comment: `!{lens${JSON.stringify({
timeRange,
attributes,
})}}`,
type: CommentType.user as const,
});

View file

@ -8,7 +8,8 @@
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import type { ManagementAppMountParams } from '@kbn/management-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import type { CasesUiStart, CasesPluginSetup, CasesPluginStart, CasesUiSetup } from './types';
import { createBrowserHistory } from 'history';
import { KibanaServices } from './common/lib/kibana';
import type { CasesUiConfigType } from '../common/ui/types';
import { APP_ID, APP_PATH } from '../common/constants';
@ -28,7 +29,9 @@ import { getUICapabilities } from './client/helpers/capabilities';
import { ExternalReferenceAttachmentTypeRegistry } from './client/attachment_framework/external_reference_registry';
import { PersistableStateAttachmentTypeRegistry } from './client/attachment_framework/persistable_state_registry';
import { registerCaseFileKinds } from './files';
import type { CasesPluginSetup, CasesPluginStart, CasesUiSetup, CasesUiStart } from './types';
import { registerInternalAttachments } from './internal_attachments';
import { registerActions } from './components/visualizations/actions';
/**
* @public
@ -57,7 +60,6 @@ export class CasesUiPlugin
registerInternalAttachments(externalReferenceAttachmentTypeRegistry);
const config = this.initializerContext.config.get<CasesUiConfigType>();
registerCaseFileKinds(config.files, plugins.files);
if (plugins.home) {
plugins.home.featureCatalogue.register({
id: APP_ID,
@ -127,6 +129,18 @@ export class CasesUiPlugin
getFilesClient: plugins.files.filesClientFactory.asScoped,
});
registerActions({
core,
plugins,
caseContextProps: {
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
getFilesClient: plugins.files.filesClientFactory.asScoped,
},
history: createBrowserHistory(),
storage: this.storage,
});
return {
api: createClientAPI({ http: core.http }),
ui: {

View file

@ -24,6 +24,8 @@ import type { ApmBase } from '@elastic/apm-rum';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import type { FilesSetup, FilesStart } from '@kbn/files-plugin/public';
import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type {
CasesBulkGetRequest,
CasesBulkGetResponse,
@ -62,18 +64,19 @@ export interface CasesPluginSetup {
}
export interface CasesPluginStart {
apm?: ApmBase;
data: DataPublicPluginStart;
embeddable: EmbeddableStart;
files: FilesStart;
licensing?: LicensingPluginStart;
lens: LensPublicStart;
storage: Storage;
triggersActionsUi: TriggersActionsStart;
features: FeaturesPluginStart;
files: FilesStart;
lens: LensPublicStart;
licensing?: LicensingPluginStart;
savedObjectsManagement: SavedObjectsManagementPluginStart;
security: SecurityPluginStart;
spaces?: SpacesPluginStart;
apm?: ApmBase;
savedObjectsManagement: SavedObjectsManagementPluginStart;
storage: Storage;
triggersActionsUi: TriggersActionsStart;
uiActions: UiActionsStart;
}
/**

View file

@ -63,6 +63,8 @@
"@kbn/saved-objects-finder-plugin",
"@kbn/saved-objects-management-plugin",
"@kbn/utility-types-jest",
"@kbn/ui-actions-plugin",
"@kbn/core-lifecycle-browser",
"@kbn/core-saved-objects-api-server-mocks",
"@kbn/core-theme-browser",
],