decouple 'Add to case' action from legacy embeddable framework (#187293)

Part of https://github.com/elastic/kibana/issues/175138

PR decouples "Add to case" action from legacy embeddable framework. PR
also cleans up page load bundle size by moving `isCompatible` and
`execute` implemenations behind async imports.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2024-07-08 10:07:43 -06:00 committed by GitHub
parent 129ad61c76
commit e3cc2e0cc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 412 additions and 495 deletions

View file

@ -85,6 +85,7 @@ export {
export { apiHasUniqueId, type HasUniqueId } from './interfaces/has_uuid';
export {
apiPublishesBlockingError,
hasBlockingError,
type PublishesBlockingError,
} from './interfaces/publishes_blocking_error';
export {

View file

@ -17,3 +17,7 @@ export const apiPublishesBlockingError = (
): unknownApi is PublishesBlockingError => {
return Boolean(unknownApi && (unknownApi as PublishesBlockingError)?.blockingError !== undefined);
};
export function hasBlockingError(api: unknown) {
return apiPublishesBlockingError(api) && api.blockingError?.value !== undefined;
}

View file

@ -11,7 +11,8 @@ 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';
import { getMockServices } from './mocks';
import type { CasesActionContextProps } from './types';
jest.mock('../../cases_context', () =>
jest.fn().mockImplementation(({ children, ...props }) => <div {...props}>{children}</div>)
@ -28,7 +29,11 @@ jest.mock('../../../client/helpers/can_use_cases', () => {
const mockCasePermissions = jest.fn().mockReturnValue({ create: true, update: true });
describe('ActionWrapper', () => {
const props = { ...getMockCaseUiActionProps(), currentAppId: 'securitySolutionUI' };
const props = {
casesActionContextProps: {} as unknown as CasesActionContextProps,
currentAppId: 'securitySolutionUI',
services: getMockServices(),
};
beforeEach(() => {
jest.clearAllMocks();

View file

@ -11,7 +11,7 @@ import { Router } from '@kbn/shared-ux-router';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { SECURITY_SOLUTION_OWNER } from '../../../../common';
import type { CasesUIActionProps } from './types';
import type { CasesActionContextProps, Services } from './types';
import { KibanaContextProvider, useKibana } from '../../../common/lib/kibana';
import CasesProvider from '../../cases_context';
import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
@ -20,13 +20,13 @@ import { canUseCases } from '../../../client/helpers/can_use_cases';
export const DEFAULT_DARK_MODE = 'theme:darkMode' as const;
interface Props {
caseContextProps: CasesUIActionProps['caseContextProps'];
casesActionContextProps: CasesActionContextProps;
currentAppId?: string;
}
const ActionWrapperWithContext: React.FC<PropsWithChildren<Props>> = ({
children,
caseContextProps,
casesActionContextProps,
currentAppId,
}) => {
const { application, i18n, theme } = useKibana().services;
@ -40,7 +40,7 @@ const ActionWrapperWithContext: React.FC<PropsWithChildren<Props>> = ({
<KibanaRenderContextProvider i18n={i18n} theme={theme}>
<CasesProvider
value={{
...caseContextProps,
...casesActionContextProps,
owner: owner ? [owner] : [],
permissions: casePermissions,
features: { alerts: { sync: syncAlerts } },
@ -54,29 +54,31 @@ const ActionWrapperWithContext: React.FC<PropsWithChildren<Props>> = ({
ActionWrapperWithContext.displayName = 'ActionWrapperWithContext';
type ActionWrapperComponentProps = PropsWithChildren<
CasesUIActionProps & { currentAppId?: string }
>;
type ActionWrapperComponentProps = PropsWithChildren<{
casesActionContextProps: CasesActionContextProps;
currentAppId?: string;
services: Services;
}>;
const ActionWrapperComponent: React.FC<ActionWrapperComponentProps> = ({
core,
plugins,
storage,
history,
casesActionContextProps,
children,
caseContextProps,
currentAppId,
services,
}) => {
return (
<KibanaContextProvider
services={{
...core,
...plugins,
storage,
...services.core,
...services.plugins,
storage: services.storage,
}}
>
<Router history={history}>
<ActionWrapperWithContext caseContextProps={caseContextProps} currentAppId={currentAppId}>
<Router history={services.history}>
<ActionWrapperWithContext
casesActionContextProps={casesActionContextProps}
currentAppId={currentAppId}
>
{children}
</ActionWrapperWithContext>
</Router>

View file

@ -1,221 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { LENS_EMBEDDABLE_TYPE, type Embeddable as LensEmbeddable } 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 } from './types';
import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal';
import type { PropsWithChildren } from 'react';
import React from 'react';
import { toMountPoint } from '@kbn/react-kibana-mount';
import {
getMockApplications$,
getMockCaseUiActionProps,
getMockCurrentAppId$,
mockAttributes,
MockEmbeddable,
mockTimeRange,
} from './mocks';
import { useKibana } from '../../../common/lib/kibana';
import { waitFor } from '@testing-library/react';
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', () => ({
KibanaThemeProvider: jest
.fn()
.mockImplementation(({ children }: PropsWithChildren<unknown>) => <>{children}</>),
}));
jest.mock('@kbn/react-kibana-mount', () => ({
toMountPoint: jest.fn(),
}));
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 LensEmbeddable;
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 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 LensEmbeddable,
})
).toEqual(false);
});
it('should return false if not lens embeddable', async () => {
expect(
await action.isCompatible({
...context,
embeddable: new MockEmbeddable('not_lens') as unknown as LensEmbeddable,
})
).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([
{
persistableStateAttachmentState: {
attributes: mockAttributes,
timeRange: mockTimeRange,
},
persistableStateAttachmentTypeId: '.lens',
type: 'persistableState',
},
]);
});
});
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

@ -4,130 +4,39 @@
* 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 type { Embeddable as LensEmbeddable } from '@kbn/lens-plugin/public';
import { createAction } from '@kbn/ui-actions-plugin/public';
import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { createAction, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import type { CaseUI } from '../../../../common';
import { isLensEmbeddable, hasInput, getLensCaseAttachment } from './utils';
import type { ActionContext, CasesUIActionProps } from './types';
import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal';
import type { EmbeddableApiContext } from '@kbn/presentation-publishing';
import type { CasesActionContextProps, Services } from './types';
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: LensEmbeddable;
onSuccess: () => void;
onClose: (theCase?: CaseUI) => void;
}
const AddExistingCaseModalWrapper: React.FC<Props> = ({ embeddable, onClose, onSuccess }) => {
const modal = useCasesAddToExistingCaseModal({
onClose,
onSuccess,
});
const attachments = useMemo(() => {
const { timeRange } = embeddable.getInput();
const attributes = embeddable.getFullAttributes();
// we've checked attributes exists before rendering (isCompatible), attributes should not be undefined here
return attributes != null ? [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, i18n, theme } = core;
export const createAddToExistingCaseLensAction = (
casesActionContextProps: CasesActionContextProps,
services: Services
) => {
let currentAppId: string | undefined;
applicationService?.currentAppId$.subscribe((appId) => {
services.core.application?.currentAppId$.subscribe((appId) => {
currentAppId = appId;
});
return createAction<ActionContext>({
return createAction<EmbeddableApiContext>({
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)
);
const { isCompatible } = await import('./is_compatible');
return isCompatible(embeddable, currentAppId, services.core);
},
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>,
{ i18n, theme }
);
mount(targetDomElement);
const { isLensApi } = await import('@kbn/lens-plugin/public');
if (!isLensApi(embeddable)) throw new IncompatibleActionError();
const { openModal } = await import('./open_modal');
openModal(embeddable, currentAppId, casesActionContextProps, services);
},
});
};

View file

@ -0,0 +1,58 @@
/*
* 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 { BehaviorSubject } from 'rxjs';
import { coreMock } from '@kbn/core/public/mocks';
import { isCompatible } from './is_compatible';
import { canUseCases } from '../../../client/helpers/can_use_cases';
import { mockLensApi } from './mocks';
jest.mock('../../../../common/utils/owner', () => ({
getCaseOwnerByAppId: () => 'securitySolution',
}));
jest.mock('../../../client/helpers/can_use_cases', () => {
const actual = jest.requireActual('../../../client/helpers/can_use_cases');
return {
...actual,
canUseCases: jest.fn(),
};
});
describe('isCompatible', () => {
const appId = 'myAppId';
const mockCoreStart = coreMock.createStart();
const mockCasePermissions = jest.fn();
beforeEach(() => {
(canUseCases as jest.Mock).mockReturnValue(
mockCasePermissions.mockReturnValue({ create: true, update: true })
);
jest.clearAllMocks();
});
test('should return false if error embeddable', async () => {
const errorApi = {
...mockLensApi,
blockingError: new BehaviorSubject<Error | undefined>(new Error('Simulated blocking error')),
};
expect(isCompatible(errorApi, appId, mockCoreStart)).toBe(false);
});
test('should return false if not lens embeddable', async () => {
expect(isCompatible({}, appId, mockCoreStart)).toBe(false);
});
test('should return false if no permission', async () => {
mockCasePermissions.mockReturnValue({ create: false, update: false });
expect(isCompatible(mockLensApi, appId, mockCoreStart)).toBe(false);
});
test('should return true if is lens embeddable', async () => {
expect(isCompatible(mockLensApi, appId, mockCoreStart)).toBe(true);
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 { CoreStart } from '@kbn/core-lifecycle-browser';
import { isLensApi } from '@kbn/lens-plugin/public';
import { hasBlockingError } from '@kbn/presentation-publishing';
import { canUseCases } from '../../../client/helpers/can_use_cases';
import { getCaseOwnerByAppId } from '../../../../common/utils/owner';
export function isCompatible(
embeddable: unknown,
currentAppId: string | undefined,
core: CoreStart
) {
if (!isLensApi(embeddable) || hasBlockingError(embeddable)) return false;
if (!embeddable.getFullAttributes()) {
return false;
}
const timeRange = embeddable.timeRange$?.value ?? embeddable.parentApi?.timeRange$?.value;
if (!timeRange) {
return false;
}
const owner = getCaseOwnerByAppId(currentAppId);
const casePermissions = canUseCases(core.application.capabilities)(owner ? [owner] : undefined);
return casePermissions.update && casePermissions.create;
}

View file

@ -10,36 +10,13 @@ import { BehaviorSubject } from 'rxjs';
import type { PublicAppInfo } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks';
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import type { CasesUIActionProps } from './types';
import type { LensApi, LensSavedObjectAttributes } from '@kbn/lens-plugin/public';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import type { Services } from './types';
const coreStart = coreMock.createStart();
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;
}
getFullAttributes() {
return this.input?.attributes;
}
}
export const mockAttributes = {
export const mockLensAttributes = {
title: 'mockTitle',
description: 'mockDescription',
references: [],
@ -57,9 +34,26 @@ export const mockAttributes = {
},
},
},
} as unknown as TypedLensByValueInput['attributes'];
} as unknown as LensSavedObjectAttributes;
export const mockTimeRange = { from: '', to: '', fromStr: '', toStr: '' };
export const mockLensApi = {
type: 'lens',
getSavedVis: () => {},
canViewUnderlyingData: () => {},
getViewUnderlyingDataArgs: () => {},
getFullAttributes: () => {
return mockLensAttributes;
},
panelTitle: new BehaviorSubject('myPanel'),
hidePanelTitle: new BehaviorSubject('false'),
timeslice$: new BehaviorSubject<[number, number] | undefined>(undefined),
timeRange$: new BehaviorSubject<TimeRange | undefined>({
from: 'now-24h',
to: 'now',
}),
filters$: new BehaviorSubject<Filter[] | undefined>(undefined),
query$: new BehaviorSubject<Query | AggregateQuery | undefined>(undefined),
} as unknown as LensApi;
export const getMockCurrentAppId$ = () => new BehaviorSubject<string>('securitySolutionUI');
export const getMockApplications$ = () =>
@ -67,26 +61,17 @@ export const getMockApplications$ = () =>
new Map([['securitySolutionUI', { category: { label: 'Test' } } as unknown as PublicAppInfo]])
);
export const getMockCaseUiActionProps = () => {
const core = {
...coreStart,
application: { currentAppId$: getMockCurrentAppId$(), capabilities: {} },
uiSettings: {
get: jest.fn().mockReturnValue(true),
export const getMockServices = () => {
return {
core: {
...coreStart,
application: { currentAppId$: getMockCurrentAppId$(), capabilities: {} },
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;
plugins: {},
storage: {},
history: createBrowserHistory(),
} as unknown as Services;
};

View file

@ -0,0 +1,126 @@
/*
* 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 ReactDOM, { unmountComponentAtNode } from 'react-dom';
import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal';
import type { PropsWithChildren } from 'react';
import React from 'react';
import { toMountPoint } from '@kbn/react-kibana-mount';
import {
getMockApplications$,
getMockCurrentAppId$,
mockLensApi,
mockLensAttributes,
getMockServices,
} from './mocks';
import { useKibana } from '../../../common/lib/kibana';
import { waitFor } from '@testing-library/react';
import { openModal } from './open_modal';
import type { CasesActionContextProps } from './types';
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('@kbn/kibana-react-plugin/public', () => ({
KibanaThemeProvider: jest
.fn()
.mockImplementation(({ children }: PropsWithChildren<unknown>) => <>{children}</>),
}));
jest.mock('@kbn/react-kibana-mount', () => ({
toMountPoint: jest.fn(),
}));
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');
describe('openModal', () => {
const mockUseCasesAddToExistingCaseModal = useCasesAddToExistingCaseModal as jest.Mock;
const mockOpenModal = jest.fn();
const mockMount = jest.fn();
beforeEach(() => {
mockUseCasesAddToExistingCaseModal.mockReturnValue({
open: mockOpenModal,
});
(useKibana as jest.Mock).mockReturnValue({
services: {
application: {
currentAppId$: getMockCurrentAppId$(),
applications$: getMockApplications$(),
},
},
});
(toMountPoint as jest.Mock).mockImplementation((node) => {
ReactDOM.render(node, element);
return mockMount;
});
jest.clearAllMocks();
openModal(mockLensApi, 'myAppId', {} as unknown as CasesActionContextProps, getMockServices());
});
test('should open modal with an attachment', async () => {
await waitFor(() => {
expect(mockOpenModal).toHaveBeenCalled();
const getAttachments = mockOpenModal.mock.calls[0][0].getAttachments;
expect(getAttachments()).toEqual([
{
persistableStateAttachmentState: {
attributes: mockLensAttributes,
timeRange: {
from: 'now-24h',
to: 'now',
},
},
persistableStateAttachmentTypeId: '.lens',
type: 'persistableState',
},
]);
});
});
test('should have correct onClose handler - when close modal clicked', () => {
const onClose = mockUseCasesAddToExistingCaseModal.mock.calls[0][0].onClose;
onClose();
expect(unmountComponentAtNode as jest.Mock).toHaveBeenCalled();
});
test('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();
});
test('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();
});
test('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,89 @@
/*
* 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 type { LensApi } from '@kbn/lens-plugin/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { ActionWrapper } from './action_wrapper';
import type { CasesActionContextProps, Services } from './types';
import type { CaseUI } from '../../../../common';
import { getLensCaseAttachment } from './utils';
import { useCasesAddToExistingCaseModal } from '../../all_cases/selector_modal/use_cases_add_to_existing_case_modal';
interface Props {
lensApi: LensApi;
onSuccess: () => void;
onClose: (theCase?: CaseUI) => void;
}
const AddExistingCaseModalWrapper: React.FC<Props> = ({ lensApi, onClose, onSuccess }) => {
const modal = useCasesAddToExistingCaseModal({
onClose,
onSuccess,
});
const timeRange = useStateFromPublishingSubject(lensApi.timeRange$);
const parentTimeRange = useStateFromPublishingSubject(lensApi.parentApi?.timeRange$);
const attachments = useMemo(() => {
const appliedTimeRange = timeRange ?? parentTimeRange;
const attributes = lensApi.getFullAttributes();
return !attributes || !appliedTimeRange
? []
: [getLensCaseAttachment({ attributes, timeRange: appliedTimeRange })];
}, [lensApi, timeRange, parentTimeRange]);
useEffect(() => {
modal.open({ getAttachments: () => attachments });
}, [attachments, modal]);
return null;
};
AddExistingCaseModalWrapper.displayName = 'AddExistingCaseModalWrapper';
export function openModal(
lensApi: LensApi,
currentAppId: string | undefined,
casesActionContextProps: CasesActionContextProps,
services: Services
) {
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
casesActionContextProps={casesActionContextProps}
currentAppId={currentAppId}
services={services}
>
<AddExistingCaseModalWrapper lensApi={lensApi} onClose={onClose} onSuccess={onSuccess} />
</ActionWrapper>,
{ i18n: services.core.i18n, theme: services.core.theme }
);
mount(targetDomElement);
}

View file

@ -8,31 +8,15 @@
import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
import { createAddToExistingCaseLensAction } from './add_to_existing_case';
import type { CasesUIActionProps } from './types';
import type { CasesActionContextProps, Services } 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 addToExistingCaseAction = createAddToExistingCaseLensAction({
core,
plugins,
caseContextProps,
history,
storage,
});
plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, addToExistingCaseAction);
export const registerUIActions = (
casesActionContextProps: CasesActionContextProps,
services: Services
) => {
const addToExistingCaseAction = createAddToExistingCaseLensAction(
casesActionContextProps,
services
);
services.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, addToExistingCaseAction);
};

View file

@ -5,30 +5,23 @@
* 2.0.
*/
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import type { Embeddable } from '@kbn/lens-plugin/public';
import type { CoreStart } from '@kbn/core/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 { CasesPublicStartDependencies } from '../../../types';
import type { CasesContextProps } from '../../cases_context';
export type CasesUIActionContextProps = Pick<
export type CasesActionContextProps = Pick<
CasesContextProps,
| 'externalReferenceAttachmentTypeRegistry'
| 'persistableStateAttachmentTypeRegistry'
| 'getFilesClient'
>;
export interface CasesUIActionProps {
export interface Services {
core: CoreStart;
plugins: CasesPublicStartDependencies;
caseContextProps: CasesUIActionContextProps;
history: H.History;
storage: Storage;
}
export type ActionContext = ActionExecutionContext<{
embeddable: Embeddable;
}>;

View file

@ -5,51 +5,9 @@
* 2.0.
*/
import { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-plugin/public';
import { isLensEmbeddable, hasInput, getLensCaseAttachment } from './utils';
import { 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: () => ({ timeRange: {} }),
getFullAttributes: jest.fn().mockReturnValue({}),
};
// @ts-expect-error: extra attributes are not needed
expect(hasInput(embeddable)).toBe(true);
});
it('return false if attributes are null', () => {
const embeddable = {
getInput: () => ({ timeRange: {} }),
getFullAttributes: jest.fn().mockReturnValue(null),
};
// @ts-expect-error: extra attributes are not needed
expect(hasInput(embeddable)).toBe(false);
});
it('return false if timeRange is null', () => {
const embeddable = { getInput: () => ({ timeRange: null }), getFullAttributes: () => ({}) };
// @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: {} };

View file

@ -4,24 +4,12 @@
* 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 type { LensSavedObjectAttributes } from '@kbn/lens-plugin/public';
import { LENS_EMBEDDABLE_TYPE, type Embeddable as LensEmbeddable } from '@kbn/lens-plugin/public';
import { LENS_ATTACHMENT_TYPE } from '../../../../common/constants/visualizations';
import type { PersistableStateAttachmentPayload } from '../../../../common/types/domain';
import { AttachmentType } from '../../../../common/types/domain';
import type { LensProps } from '../types';
export const isLensEmbeddable = (embeddable: IEmbeddable): embeddable is LensEmbeddable => {
return embeddable.type === LENS_EMBEDDABLE_TYPE;
};
export const hasInput = (embeddable: LensEmbeddable) => {
const { timeRange } = embeddable.getInput();
const attributes = embeddable.getFullAttributes();
return attributes != null && timeRange != null;
};
type PersistableStateAttachmentWithoutOwner = Omit<PersistableStateAttachmentPayload, 'owner'>;
export const getLensCaseAttachment = ({

View file

@ -149,17 +149,19 @@ export class CasesUiPlugin
getFilesClient: plugins.files.filesClientFactory.asScoped,
});
registerActions({
core,
plugins,
caseContextProps: {
registerActions(
{
externalReferenceAttachmentTypeRegistry: this.externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry,
getFilesClient: plugins.files.filesClientFactory.asScoped,
},
history: createBrowserHistory(),
storage: this.storage,
});
{
core,
plugins,
history: createBrowserHistory(),
storage: this.storage,
}
);
return {
api: createClientAPI({ http: core.http }),

View file

@ -75,6 +75,7 @@
"@kbn/core-logging-browser-mocks",
"@kbn/data-views-plugin",
"@kbn/core-http-router-server-internal",
"@kbn/presentation-publishing",
],
"exclude": [
"target/**/*",

View file

@ -22,12 +22,13 @@ export type HasLensConfig = HasType<'lens'> & {
getSavedVis: () => Readonly<LensSavedObjectAttributes | undefined>;
canViewUnderlyingData: () => Promise<boolean>;
getViewUnderlyingDataArgs: () => ViewUnderlyingDataArgs;
getFullAttributes: () => LensSavedObjectAttributes | undefined;
};
export type LensApi = HasLensConfig &
PublishesPanelTitle &
PublishesUnifiedSearch &
Partial<HasParentApi<unknown>>;
Partial<HasParentApi<Partial<PublishesUnifiedSearch>>>;
export const isLensApi = (api: unknown): api is LensApi => {
return Boolean(
@ -36,6 +37,7 @@ export const isLensApi = (api: unknown): api is LensApi => {
typeof (api as HasLensConfig).getSavedVis === 'function' &&
typeof (api as HasLensConfig).canViewUnderlyingData === 'function' &&
typeof (api as HasLensConfig).getViewUnderlyingDataArgs === 'function' &&
typeof (api as HasLensConfig).getFullAttributes === 'function' &&
apiPublishesPanelTitle(api) &&
apiPublishesUnifiedSearch(api)
);

View file

@ -23,6 +23,7 @@ describe('open in discover action', () => {
timeRange$: new BehaviorSubject({ from: 'now-15m', to: 'now' }),
getSavedVis: jest.fn(() => undefined),
canViewUnderlyingData: () => Promise.resolve(true),
getFullAttributes: jest.fn(() => undefined),
getViewUnderlyingDataArgs: jest.fn(() => ({
dataViewSpec: { id: 'index-pattern-id' },
timeRange: { from: 'now-7d', to: 'now' },