mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
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:
parent
1ba8be4b8a
commit
b8eed601f1
26 changed files with 1346 additions and 44 deletions
|
@ -7,7 +7,7 @@ pageLoadAssetSize:
|
|||
banners: 17946
|
||||
bfetch: 22837
|
||||
canvas: 1066647
|
||||
cases: 175000
|
||||
cases: 180000
|
||||
charts: 55000
|
||||
cloud: 21076
|
||||
cloudChat: 19894
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -28,7 +28,8 @@
|
|||
"ruleRegistry",
|
||||
"files",
|
||||
"savedObjectsFinder",
|
||||
"savedObjectsManagement"
|
||||
"savedObjectsManagement",
|
||||
"uiActions",
|
||||
],
|
||||
"optionalPlugins": [
|
||||
"home",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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>);
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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';
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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;
|
||||
}>;
|
|
@ -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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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",
|
||||
],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue