diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 3dc0710a62c5d..f9c11d419fa02 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -7,7 +7,7 @@ pageLoadAssetSize: banners: 17946 bfetch: 22837 canvas: 1066647 - cases: 175000 + cases: 180000 charts: 55000 cloud: 21076 cloudChat: 19894 diff --git a/x-pack/plugins/cases/common/utils/owner.test.ts b/x-pack/plugins/cases/common/utils/owner.test.ts index 09016880c0a95..94e2c50ea7f08 100644 --- a/x-pack/plugins/cases/common/utils/owner.test.ts +++ b/x-pack/plugins/cases/common/utils/owner.test.ts @@ -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; +describe('owner utils', () => { + describe('isValidOwner', () => { + const owners = Object.keys(OWNER_INFO) as Array; - 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); + }); }); }); diff --git a/x-pack/plugins/cases/common/utils/owner.ts b/x-pack/plugins/cases/common/utils/owner.ts index 44068f36f0d3f..cd817a59a375e 100644 --- a/x-pack/plugins/cases/common/utils/owner.ts +++ b/x-pack/plugins/cases/common/utils/owner.ts @@ -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; diff --git a/x-pack/plugins/cases/kibana.jsonc b/x-pack/plugins/cases/kibana.jsonc index a37320379b76d..6604dc63402ef 100644 --- a/x-pack/plugins/cases/kibana.jsonc +++ b/x-pack/plugins/cases/kibana.jsonc @@ -28,7 +28,8 @@ "ruleRegistry", "files", "savedObjectsFinder", - "savedObjectsManagement" + "savedObjectsManagement", + "uiActions", ], "optionalPlugins": [ "home", diff --git a/x-pack/plugins/cases/public/common/lib/kibana/services.ts b/x-pack/plugins/cases/public/common/lib/kibana/services.ts index ab06c1be0bf02..b1248488e5286 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/services.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/services.ts @@ -8,7 +8,7 @@ import type { CoreStart } from '@kbn/core/public'; import type { CasesUiConfigType } from '../../../../common/ui/types'; -type GlobalServices = Pick; +type GlobalServices = Pick; 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; } diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index eecde112f3907..5126aadc32fea 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -66,7 +66,6 @@ const useKibanaMock = useKibana as jest.MockedFunction; 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(); 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(); + userEvent.click(screen.getByTestId('cases-table-add-case')); + await waitFor(() => { + expect(onRowClick).not.toHaveBeenCalled(); + }); + }); + it('should tableHeaderSortButton AllCasesList', async () => { appMockRenderer.render(); @@ -347,9 +360,10 @@ describe('AllCasesListGeneric', () => { it('should call onRowClick with no cases and isSelectorView=true when create case is clicked', async () => { appMockRenderer.render(); userEvent.click(screen.getByTestId('cases-table-add-case-filter-bar')); - + const isCreateCase = true; await waitFor(() => { expect(onRowClick).toHaveBeenCalled(); + expect(onRowClick).toBeCalledWith(undefined, isCreateCase); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index e63315d7e6299..d0fc6f038e1e6 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -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( @@ -250,6 +250,10 @@ export const AllCasesList = React.memo( mapToReadableSolutionName(solution) ); + const onCreateCasePressed = useCallback(() => { + onRowClick?.(undefined, true); + }, [onRowClick]); + return ( <> ( severity: filterOptions.severity, }} hiddenStatuses={hiddenStatuses} - onCreateCasePressed={onRowClick} + onCreateCasePressed={onCreateCasePressed} isSelectorView={isSelectorView} isLoading={isLoadingCurrentUserProfile} currentUserProfile={currentUserProfile} @@ -284,7 +288,7 @@ export const AllCasesList = React.memo( void; - onClose?: () => void; + onClose?: (theCase?: CaseUI, isCreateCase?: boolean) => void; + onCreateCaseClicked?: () => void; } const Modal = styled(EuiModal)` @@ -37,20 +38,18 @@ export const AllCasesSelectorModal = React.memo( ({ hiddenStatuses, onRowClick, onClose }) => { const [isModalOpen, setIsModalOpen] = useState(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 ? ( diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx index 8e5a27ae06b5e..301baba1d1ccd 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx @@ -106,13 +106,13 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingCaseModalProp } }, [ - props, + appId, + casesToasts, closeModal, + createAttachments, createNewCaseFlyout, + props, startTransaction, - appId, - createAttachments, - casesToasts, ] ); @@ -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); } }, }, diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/__mocks__/action_wrapper.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/__mocks__/action_wrapper.tsx new file mode 100644 index 0000000000000..6b1dda42d8fe1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/__mocks__/action_wrapper.tsx @@ -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 }) =>
{children}
); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.test.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.test.tsx new file mode 100644 index 0000000000000..31f34de24d5ed --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.test.tsx @@ -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 }) =>
{children}
) +); + +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( + +
+ + ); + expect(mockCasePermissions).toHaveBeenCalledWith([SECURITY_SOLUTION_OWNER]); + }); + + it('renders CasesProvider with correct props for Security solution', () => { + render( + +
+ + ); + 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( + +
+ + ); + + 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( + +
+ + ); + + 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( + +
+ + ); + + 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( + +
+ + ); + expect(mockCasePermissions).toBeCalledWith(undefined); + }); +}); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.tsx new file mode 100644 index 0000000000000..5dd448b9b73e3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/action_wrapper.tsx @@ -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> = ({ + 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 ( + + + {children} + + + ); +}; + +ActionWrapperWithContext.displayName = 'ActionWrapperWithContext'; + +type ActionWrapperComponentProps = PropsWithChildren< + CasesUIActionProps & { currentAppId?: string } +>; + +const ActionWrapperComponent: React.FC = ({ + core, + plugins, + storage, + history, + children, + caseContextProps, + currentAppId, +}) => { + return ( + + + + {children} + + + + ); +}; + +ActionWrapperComponent.displayName = 'ActionWrapper'; + +export const ActionWrapper = React.memo(ActionWrapperComponent); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.test.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.test.tsx new file mode 100644 index 0000000000000..08a96ccb8587b --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.test.tsx @@ -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 }) =>
{children}
), + }; +}); + +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; + 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(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.tsx new file mode 100644 index 0000000000000..ef5a7c794f1f5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_existing_case.tsx @@ -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 = ({ 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({ + 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( + + + , + { theme$: theme.theme$ } + ); + + mount(targetDomElement); + }, + }); +}; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.test.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.test.tsx new file mode 100644 index 0000000000000..7e99bbaae24c6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.test.tsx @@ -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 }) =>
{children}
), + }; +}); + +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; + 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(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.tsx b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.tsx new file mode 100644 index 0000000000000..f09ccaa3a4baf --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/add_to_new_case.tsx @@ -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 = ({ 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({ + 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( + + + , + { theme$: theme.theme$ } + ); + + mount(targetDomElement); + }, + }); +}; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/index.ts b/x-pack/plugins/cases/public/components/visualizations/actions/index.ts new file mode 100644 index 0000000000000..e96d41d4466e5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/index.ts @@ -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'; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/mocks.ts b/x-pack/plugins/cases/public/components/visualizations/actions/mocks.ts new file mode 100644 index 0000000000000..808935fddd2e8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/mocks.ts @@ -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('securitySolutionUI'); +export const getMockApplications$ = () => + new BehaviorSubject>( + 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; +}; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/register.ts b/x-pack/plugins/cases/public/components/visualizations/actions/register.ts new file mode 100644 index 0000000000000..f470a0a34fdcd --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/register.ts @@ -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); +}; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/translations.ts b/x-pack/plugins/cases/public/components/visualizations/actions/translations.ts new file mode 100644 index 0000000000000..ed7ee1370d875 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/translations.ts @@ -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', + } +); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/types.ts b/x-pack/plugins/cases/public/components/visualizations/actions/types.ts new file mode 100644 index 0000000000000..53a2e9d5fd06e --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/types.ts @@ -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; + +export type ActionContext = ActionExecutionContext<{ + embeddable: DashboardVisualizationEmbeddable; +}>; diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/utils.test.ts b/x-pack/plugins/cases/public/components/visualizations/actions/utils.test.ts new file mode 100644 index 0000000000000..3f61ac958dd18 --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/utils.test.ts @@ -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", + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/visualizations/actions/utils.ts b/x-pack/plugins/cases/public/components/visualizations/actions/utils.ts new file mode 100644 index 0000000000000..fcf48bfaf0d8b --- /dev/null +++ b/x-pack/plugins/cases/public/components/visualizations/actions/utils.ts @@ -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) => ({ + comment: `!{lens${JSON.stringify({ + timeRange, + attributes, + })}}`, + type: CommentType.user as const, +}); diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts index 8897b9ccbd046..1f9d3edf35776 100644 --- a/x-pack/plugins/cases/public/plugin.ts +++ b/x-pack/plugins/cases/public/plugin.ts @@ -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(); 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: { diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts index f8fd9270d7cba..4f401dce1df45 100644 --- a/x-pack/plugins/cases/public/types.ts +++ b/x-pack/plugins/cases/public/types.ts @@ -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; + features: FeaturesPluginStart; files: FilesStart; - licensing?: LicensingPluginStart; lens: LensPublicStart; - storage: Storage; - triggersActionsUi: TriggersActionsStart; - features: FeaturesPluginStart; + licensing?: LicensingPluginStart; + savedObjectsManagement: SavedObjectsManagementPluginStart; security: SecurityPluginStart; spaces?: SpacesPluginStart; - apm?: ApmBase; - savedObjectsManagement: SavedObjectsManagementPluginStart; + storage: Storage; + triggersActionsUi: TriggersActionsStart; + uiActions: UiActionsStart; } /** diff --git a/x-pack/plugins/cases/tsconfig.json b/x-pack/plugins/cases/tsconfig.json index 118370770e138..4314e82ce6ba7 100644 --- a/x-pack/plugins/cases/tsconfig.json +++ b/x-pack/plugins/cases/tsconfig.json @@ -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", ],