diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index f6bfb510cab8..95135f4a0e9a 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -26,15 +26,6 @@ export interface CasesContextFeatures { export type CasesFeatures = Partial; -export interface CasesContextValue { - owner: string[]; - appId: string; - appTitle: string; - userCanCrud: boolean; - basePath: string; - features: CasesContextFeatures; -} - export interface CasesUiConfigType { markdownPlugins: { lens: boolean; diff --git a/x-pack/plugins/cases/public/components/cases_context/cases_context_reducer.ts b/x-pack/plugins/cases/public/components/cases_context/cases_context_reducer.ts new file mode 100644 index 000000000000..f94807232321 --- /dev/null +++ b/x-pack/plugins/cases/public/components/cases_context/cases_context_reducer.ts @@ -0,0 +1,50 @@ +/* + * 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 { CreateCaseFlyoutProps } from '../create/flyout'; + +export const getInitialCasesContextState = (): CasesContextState => { + return { + createCaseFlyout: { + isFlyoutOpen: false, + }, + }; +}; + +export interface CasesContextState { + createCaseFlyout: { + isFlyoutOpen: boolean; + props?: CreateCaseFlyoutProps; + }; +} + +export enum CasesContextStoreActionsList { + OPEN_CREATE_CASE_FLYOUT, + CLOSE_CREATE_CASE_FLYOUT, +} +export type CasesContextStoreAction = + | { + type: CasesContextStoreActionsList.OPEN_CREATE_CASE_FLYOUT; + payload: CreateCaseFlyoutProps; + } + | { type: CasesContextStoreActionsList.CLOSE_CREATE_CASE_FLYOUT }; + +export const casesContextReducer: React.Reducer = ( + state: CasesContextState, + action: CasesContextStoreAction +): CasesContextState => { + switch (action.type) { + case CasesContextStoreActionsList.OPEN_CREATE_CASE_FLYOUT: { + return { ...state, createCaseFlyout: { isFlyoutOpen: true, props: action.payload } }; + } + case CasesContextStoreActionsList.CLOSE_CREATE_CASE_FLYOUT: { + return { ...state, createCaseFlyout: { isFlyoutOpen: false } }; + } + default: + return state; + } +}; diff --git a/x-pack/plugins/cases/public/components/cases_context/cases_global_components.test.tsx b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.test.tsx new file mode 100644 index 000000000000..53c9129812d8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.test.tsx @@ -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 React from 'react'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import { getCreateCaseFlyoutLazyNoProvider } from '../../methods/get_create_case_flyout'; +import { CasesGlobalComponents } from './cases_global_components'; + +jest.mock('../../methods/get_create_case_flyout'); + +const getCreateCaseFlyoutLazyNoProviderMock = getCreateCaseFlyoutLazyNoProvider as jest.Mock; + +describe('Cases context UI', () => { + let appMock: AppMockRenderer; + + beforeEach(() => { + appMock = createAppMockRenderer(); + getCreateCaseFlyoutLazyNoProviderMock.mockClear(); + }); + + describe('create case flyout', () => { + it('should render the create case flyout when isFlyoutOpen is true', async () => { + const state = { + createCaseFlyout: { + isFlyoutOpen: true, + props: { + attachments: [], + }, + }, + }; + appMock.render(); + expect(getCreateCaseFlyoutLazyNoProviderMock).toHaveBeenCalledWith({ attachments: [] }); + }); + it('should not render the create case flyout when isFlyoutOpen is false', async () => { + const state = { + createCaseFlyout: { + isFlyoutOpen: false, + }, + }; + appMock.render(); + expect(getCreateCaseFlyoutLazyNoProviderMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/cases_context/cases_global_components.tsx b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.tsx new file mode 100644 index 000000000000..42ff36e201df --- /dev/null +++ b/x-pack/plugins/cases/public/components/cases_context/cases_global_components.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { getCreateCaseFlyoutLazyNoProvider } from '../../methods'; +import { CasesContextState } from './cases_context_reducer'; + +export const CasesGlobalComponents = React.memo(({ state }: { state: CasesContextState }) => { + return ( + <> + {state.createCaseFlyout.isFlyoutOpen && state.createCaseFlyout.props !== undefined + ? getCreateCaseFlyoutLazyNoProvider(state.createCaseFlyout.props) + : null} + + ); +}); +CasesGlobalComponents.displayName = 'CasesContextUi'; diff --git a/x-pack/plugins/cases/public/components/cases_context/index.tsx b/x-pack/plugins/cases/public/components/cases_context/index.tsx index aceefad97382..1f1da31595a0 100644 --- a/x-pack/plugins/cases/public/components/cases_context/index.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/index.tsx @@ -5,21 +5,38 @@ * 2.0. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useReducer, Dispatch } from 'react'; import { merge } from 'lodash'; -import { CasesContextValue, CasesFeatures } from '../../../common/ui/types'; import { DEFAULT_FEATURES } from '../../../common/constants'; import { DEFAULT_BASE_PATH } from '../../common/navigation'; import { useApplication } from './use_application'; +import { + CasesContextStoreAction, + casesContextReducer, + getInitialCasesContextState, +} from './cases_context_reducer'; +import { CasesContextFeatures, CasesFeatures } from '../../containers/types'; +import { CasesGlobalComponents } from './cases_global_components'; -export const CasesContext = React.createContext(undefined); +export type CasesContextValueDispatch = Dispatch; + +export interface CasesContextValue { + owner: string[]; + appId: string; + appTitle: string; + userCanCrud: boolean; + basePath: string; + features: CasesContextFeatures; + dispatch: CasesContextValueDispatch; +} -export interface CasesContextProps - extends Omit { +export interface CasesContextProps extends Pick { basePath?: string; features?: CasesFeatures; } +export const CasesContext = React.createContext(undefined); + export interface CasesContextStateValue extends Omit { appId?: string; appTitle?: string; @@ -30,6 +47,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ value: { owner, userCanCrud, basePath = DEFAULT_BASE_PATH, features = {} }, }) => { const { appId, appTitle } = useApplication(); + const [state, dispatch] = useReducer(casesContextReducer, getInitialCasesContextState()); const [value, setValue] = useState(() => ({ owner, userCanCrud, @@ -39,6 +57,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ * of the DEFAULT_FEATURES object */ features: merge({}, DEFAULT_FEATURES, features), + dispatch, })); /** @@ -58,7 +77,10 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({ }, [appTitle, appId, userCanCrud]); return isCasesContextValue(value) ? ( - {children} + + + {children} + ) : null; }; CasesProvider.displayName = 'CasesProvider'; @@ -66,3 +88,6 @@ CasesProvider.displayName = 'CasesProvider'; function isCasesContextValue(value: CasesContextStateValue): value is CasesContextValue { return value.appId != null && value.appTitle != null && value.userCanCrud != null; } + +// eslint-disable-next-line import/no-default-export +export default CasesProvider; diff --git a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx index 0097df1587a7..c40dfc98513d 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx @@ -16,8 +16,8 @@ import { UsePostComment } from '../../../containers/use_post_comment'; export interface CreateCaseFlyoutProps { afterCaseCreated?: (theCase: Case, postComment: UsePostComment['postComment']) => Promise; - onClose: () => void; - onSuccess: (theCase: Case) => Promise; + onClose?: () => void; + onSuccess?: (theCase: Case) => Promise; attachments?: CreateCaseAttachment; } @@ -66,6 +66,8 @@ const FormWrapper = styled.div` export const CreateCaseFlyout = React.memo( ({ afterCaseCreated, onClose, onSuccess, attachments }) => { + const handleCancel = onClose || function () {}; + const handleOnSuccess = onSuccess || async function () {}; return ( <> @@ -85,8 +87,8 @@ export const CreateCaseFlyout = React.memo( diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx new file mode 100644 index 000000000000..2c3750887cb1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.test.tsx @@ -0,0 +1,78 @@ +/* + * 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. + */ + +/* eslint-disable react/display-name */ + +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { CasesContext } from '../../cases_context'; +import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; +import { useCasesAddToNewCaseFlyout } from './use_cases_add_to_new_case_flyout'; + +describe('use cases add to new case flyout hook', () => { + const dispatch = jest.fn(); + let wrapper: React.FC; + beforeEach(() => { + dispatch.mockReset(); + wrapper = ({ children }) => { + return ( + + {children} + + ); + }; + }); + + it('should throw if called outside of a cases context', () => { + const { result } = renderHook(() => { + useCasesAddToNewCaseFlyout({}); + }); + expect(result.error?.message).toContain( + 'useCasesContext must be used within a CasesProvider and have a defined value' + ); + }); + + it('should dispatch the open action when invoked', () => { + const { result } = renderHook( + () => { + return useCasesAddToNewCaseFlyout({}); + }, + { wrapper } + ); + result.current.open(); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: CasesContextStoreActionsList.OPEN_CREATE_CASE_FLYOUT, + }) + ); + }); + + it('should dispatch the close action when invoked', () => { + const { result } = renderHook( + () => { + return useCasesAddToNewCaseFlyout({}); + }, + { wrapper } + ); + result.current.close(); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: CasesContextStoreActionsList.CLOSE_CREATE_CASE_FLYOUT, + }) + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx new file mode 100644 index 000000000000..e9514ee582d9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx @@ -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 { useCallback } from 'react'; +import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; +import { useCasesContext } from '../../cases_context/use_cases_context'; +import { CreateCaseFlyoutProps } from './create_case_flyout'; + +export const useCasesAddToNewCaseFlyout = (props: CreateCaseFlyoutProps) => { + const context = useCasesContext(); + + const closeFlyout = useCallback(() => { + context.dispatch({ + type: CasesContextStoreActionsList.CLOSE_CREATE_CASE_FLYOUT, + }); + }, [context]); + + const openFlyout = useCallback(() => { + context.dispatch({ + type: CasesContextStoreActionsList.OPEN_CREATE_CASE_FLYOUT, + payload: { + ...props, + onClose: () => { + closeFlyout(); + if (props.onClose) { + return props.onClose(); + } + }, + afterCaseCreated: async (...args) => { + closeFlyout(); + if (props.afterCaseCreated) { + return props.afterCaseCreated(...args); + } + }, + }, + }); + }, [closeFlyout, context, props]); + return { + open: openFlyout, + close: closeFlyout, + }; +}; + +export type UseCasesAddToNewCaseFlyout = typeof useCasesAddToNewCaseFlyout; diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index c4784f9a891b..c4646ff7f7c0 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -57,6 +57,7 @@ const MySpinner = styled(EuiLoadingSpinner)` `; export type SupportedCreateCaseAttachment = CommentRequestAlertType | CommentRequestUserType; export type CreateCaseAttachment = SupportedCreateCaseAttachment[]; +export type CaseAttachments = SupportedCreateCaseAttachment[]; export interface CreateCaseFormFieldsProps { connectors: ActionConnector[]; diff --git a/x-pack/plugins/cases/public/index.tsx b/x-pack/plugins/cases/public/index.tsx index 79eefba78a48..be23b9a46893 100644 --- a/x-pack/plugins/cases/public/index.tsx +++ b/x-pack/plugins/cases/public/index.tsx @@ -19,6 +19,8 @@ export type { GetCreateCaseFlyoutProps } from './methods/get_create_case_flyout' export type { GetAllCasesSelectorModalProps } from './methods/get_all_cases_selector_modal'; export type { GetRecentCasesProps } from './methods/get_recent_cases'; +export type { CaseAttachments } from './components/create/form'; + export type { ICasesDeepLinkId } from './common/navigation'; export { getCasesDeepLinks, diff --git a/x-pack/plugins/cases/public/methods/get_cases_context.tsx b/x-pack/plugins/cases/public/methods/get_cases_context.tsx new file mode 100644 index 000000000000..a2314696773b --- /dev/null +++ b/x-pack/plugins/cases/public/methods/get_cases_context.tsx @@ -0,0 +1,34 @@ +/* + * 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 { EuiLoadingSpinner } from '@elastic/eui'; +import React, { lazy, ReactNode, Suspense } from 'react'; +import { CasesContextProps } from '../components/cases_context'; + +export type GetCasesContextProps = CasesContextProps; + +const CasesProviderLazy: React.FC<{ value: GetCasesContextProps }> = lazy( + () => import('../components/cases_context') +); + +const CasesProviderLazyWrapper = ({ + owner, + userCanCrud, + features, + children, +}: GetCasesContextProps & { children: ReactNode }) => { + return ( + }> + {children} + + ); +}; +CasesProviderLazyWrapper.displayName = 'CasesProviderLazyWrapper'; + +export const getCasesContextLazy = () => { + return CasesProviderLazyWrapper; +}; diff --git a/x-pack/plugins/cases/public/methods/get_create_case_flyout.tsx b/x-pack/plugins/cases/public/methods/get_create_case_flyout.tsx index 90fbeafaa9ed..a0453c8fbb47 100644 --- a/x-pack/plugins/cases/public/methods/get_create_case_flyout.tsx +++ b/x-pack/plugins/cases/public/methods/get_create_case_flyout.tsx @@ -12,7 +12,7 @@ import { CasesProvider, CasesContextProps } from '../components/cases_context'; export type GetCreateCaseFlyoutProps = CreateCaseFlyoutProps & CasesContextProps; -const CreateCaseFlyoutLazy: React.FC = lazy( +export const CreateCaseFlyoutLazy: React.FC = lazy( () => import('../components/create/flyout') ); export const getCreateCaseFlyoutLazy = ({ @@ -35,3 +35,9 @@ export const getCreateCaseFlyoutLazy = ({ ); + +export const getCreateCaseFlyoutLazyNoProvider = (props: CreateCaseFlyoutProps) => ( + }> + + +); diff --git a/x-pack/plugins/cases/public/mocks.ts b/x-pack/plugins/cases/public/mocks.ts index 6f508d9b6da3..7c89bb1ddc2f 100644 --- a/x-pack/plugins/cases/public/mocks.ts +++ b/x-pack/plugins/cases/public/mocks.ts @@ -10,9 +10,14 @@ import { CasesUiStart } from './types'; const createStartContract = (): jest.Mocked => ({ canUseCases: jest.fn(), getCases: jest.fn(), + getCasesContext: jest.fn(), getAllCasesSelectorModal: jest.fn(), getCreateCaseFlyout: jest.fn(), getRecentCases: jest.fn(), + getCreateCaseFlyoutNoProvider: jest.fn(), + hooks: { + getUseCasesAddToNewCaseFlyout: jest.fn(), + }, }); export const casesPluginMock = { diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts index 70882560edb7..acf51c8380f6 100644 --- a/x-pack/plugins/cases/public/plugin.ts +++ b/x-pack/plugins/cases/public/plugin.ts @@ -14,8 +14,11 @@ import { getAllCasesSelectorModalLazy, getCreateCaseFlyoutLazy, canUseCases, + getCreateCaseFlyoutLazyNoProvider, } from './methods'; import { CasesUiConfigType } from '../common/ui/types'; +import { getCasesContextLazy } from './methods/get_cases_context'; +import { useCasesAddToNewCaseFlyout } from './components/create/flyout/use_cases_add_to_new_case_flyout'; /** * @public @@ -35,9 +38,14 @@ export class CasesUiPlugin implements Plugin} */ getCases: (props: GetCasesProps) => ReactElement; + getCasesContext: () => ( + props: GetCasesContextProps & { children: ReactNode } + ) => ReactElement; /** * Modal to select a case in a list of all owner cases * @param props GetAllCasesSelectorModalProps @@ -78,10 +84,16 @@ export interface CasesUiStart { * @returns A react component that is a flyout for creating a case */ getCreateCaseFlyout: (props: GetCreateCaseFlyoutProps) => ReactElement; + getCreateCaseFlyoutNoProvider: ( + props: CreateCaseFlyoutProps + ) => ReactElement; /** * Get the recent cases component * @param props GetRecentCasesProps * @returns A react component for showing recent cases */ getRecentCases: (props: GetRecentCasesProps) => ReactElement; + hooks: { + getUseCasesAddToNewCaseFlyout: UseCasesAddToNewCaseFlyout; + }; } diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index a8e78410e13c..e8f79ec4e6e2 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -13,7 +13,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { ALERT_STATUS, AlertStatus } from '@kbn/rule-data-utils'; +import { observabilityFeatureId } from '../../../../../common'; +import { useGetUserCasesPermissions } from '../../../../hooks/use_get_user_cases_permissions'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { loadAlertAggregations as loadRuleAggregations } from '../../../../../../../plugins/triggers_actions_ui/public'; import { AlertStatusFilterButton } from '../../../../../common/typings'; import { ParsedTechnicalFields } from '../../../../../../rule_registry/common/parse_technical_fields'; @@ -35,6 +38,7 @@ import { } from '../state_container'; import './styles.scss'; import { AlertsStatusFilter, AlertsDisclaimer, AlertsSearchBar } from '../../components'; +import { ObservabilityAppServices } from '../../../../application/types'; interface RuleStatsState { total: number; @@ -228,6 +232,10 @@ function AlertsPage() { // If there is any data, set hasData to true otherwise we need to wait till all the data is loaded before setting hasData to true or false; undefined indicates the data is still loading. const hasData = hasAnyData === true || (isAllRequestsComplete === false ? undefined : false); + const kibana = useKibana(); + const CasesContext = kibana.services.cases.getCasesContext(); + const userPermissions = useGetUserCasesPermissions(); + if (!hasAnyData && !isAllRequestsComplete) { return ; } @@ -322,13 +330,19 @@ function AlertsPage() { - + + + diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx index d419fbee1d34..20b86fed197f 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx @@ -189,14 +189,14 @@ function ObservabilityActions({ timelines.getAddToExistingCaseButton({ event, casePermissions, - appId: observabilityFeatureId, + appId: observabilityAppId, owner: observabilityFeatureId, onClose: afterCaseSelection, }), timelines.getAddToNewCaseButton({ event, casePermissions, - appId: observabilityFeatureId, + appId: observabilityAppId, owner: observabilityFeatureId, onClose: afterCaseSelection, }), diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index 411dd5542038..ebf361914ca3 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -157,8 +157,8 @@ export const useGetUserCasesPermissions = () => { useEffect(() => { setCasesPermissions({ - crud: !!uiCapabilities[CASES_FEATURE_ID].crud_cases, - read: !!uiCapabilities[CASES_FEATURE_ID].read_cases, + crud: !!uiCapabilities[CASES_FEATURE_ID]?.crud_cases, + read: !!uiCapabilities[CASES_FEATURE_ID]?.read_cases, }); }, [uiCapabilities]); diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_cases_context.tsx b/x-pack/plugins/security_solution/public/common/mock/mock_cases_context.tsx new file mode 100644 index 000000000000..66c3fc2c932e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/mock/mock_cases_context.tsx @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; + +export const mockCasesContext: React.FC = (props) => { + return <>{props?.children ?? null}; +}; +mockCasesContext.displayName = 'CasesContextMock'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 256a063c4415..1499e803fdf3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { connect, ConnectedProps, useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import type { Filter } from '@kbn/es-query'; +import { APP_ID } from '../../../../common/constants'; import { getEsQueryConfig } from '../../../../../../../src/plugins/data/common'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { RowRendererId, TimelineIdLiteral } from '../../../../common/types/timeline'; @@ -24,7 +25,7 @@ import { useAppToasts } from '../../../common/hooks/use_app_toasts'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; import { defaultCellActions } from '../../../common/lib/cell_actions/default_cell_actions'; -import { useKibana } from '../../../common/lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana'; import { inputsModel, inputsSelectors, State } from '../../../common/store'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import * as i18nCommon from '../../../common/translations'; @@ -356,29 +357,34 @@ export const AlertsTableComponent: React.FC = ({ const leadingControlColumns = useMemo(() => getDefaultControlColumn(ACTION_BUTTON_COUNT), []); + const casesPermissions = useGetUserCasesPermissions(); + const CasesContext = kibana.services.cases.getCasesContext(); + if (loading || indexPatternsLoading || isEmpty(selectedPatterns)) { return null; } return ( - + + + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index c5d053c57fc9..4b6cbb6f7e16 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -24,6 +24,7 @@ import { createStore, State } from '../../../common/store'; import { mockHistory, Router } from '../../../common/mock/router'; import { mockTimelines } from '../../../common/mock/mock_timelines_plugin'; import { mockBrowserFields } from '../../../common/containers/source/mock'; +import { mockCasesContext } from '../../../common/mock/mock_cases_context'; // Test will fail because we will to need to mock some core services to make the test work // For now let's forget about SiemSearchBar and QueryBar @@ -72,6 +73,9 @@ jest.mock('../../../common/lib/kibana', () => { siem: { crud_alerts: true, read_alerts: true }, }, }, + cases: { + getCasesContext: mockCasesContext, + }, uiSettings: { get: jest.fn(), }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index db927e67ccc6..66a140987475 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -24,6 +24,7 @@ import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { timelineActions } from '../../../store/timeline'; import { ColumnHeaderOptions, TimelineTabs } from '../../../../../common/types/timeline'; import { defaultRowRenderers } from './renderers'; +import { mockCasesContext } from '../../../../common/mock/mock_cases_context'; jest.mock('../../../../common/lib/kibana/hooks'); jest.mock('../../../../common/hooks/use_app_toasts'); @@ -40,6 +41,9 @@ jest.mock('../../../../common/lib/kibana', () => { siem: { crud_alerts: true, read_alerts: true }, }, }, + cases: { + getCasesContext: () => mockCasesContext, + }, data: { search: jest.fn(), query: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 7257d4246f6f..a9d0028f6d9d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -11,6 +11,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { connect, ConnectedProps, useDispatch } from 'react-redux'; import deepEqual from 'fast-deep-equal'; +import { APP_ID } from '../../../../../common/constants'; +import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana'; import { FIRST_ARIA_INDEX, ARIA_COLINDEX_ATTRIBUTE, @@ -225,60 +227,65 @@ export const BodyComponent = React.memo( }, [columnHeaders.length, containerRef, data.length] ); + const kibana = useKibana(); + const casesPermissions = useGetUserCasesPermissions(); + const CasesContext = kibana.services.cases.getCasesContext(); return ( <> - - + + + - - + + + diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx index b4bd3aa1f0ae..43622b7e4536 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx @@ -23,6 +23,7 @@ import { useTimelineEventsDetails } from '../../../containers/details/index'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks'; import { useDraggableKeyboardWrapper as mockUseDraggableKeyboardWrapper } from '../../../../../../timelines/public/components'; +import { mockCasesContext } from '../../../../common/mock/mock_cases_context'; jest.mock('../../../containers/index', () => ({ useTimelineEvents: jest.fn(), @@ -56,6 +57,9 @@ jest.mock('../../../../common/lib/kibana', () => { navigateToApp: jest.fn(), getUrlForApp: jest.fn(), }, + cases: { + getCasesContext: () => mockCasesContext, + }, docLinks: { links: { query: { eql: 'url-eql_doc' } } }, uiSettings: { get: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx index 8707bb33da08..ffe50f935b9f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx @@ -24,6 +24,7 @@ import { mockSourcererScope } from '../../../../common/containers/sourcerer/mock import { PinnedTabContentComponent, Props as PinnedTabContentComponentProps } from '.'; import { Direction } from '../../../../../common/search_strategy'; import { useDraggableKeyboardWrapper as mockUseDraggableKeyboardWrapper } from '../../../../../../timelines/public/components'; +import { mockCasesContext } from '../../../../common/mock/mock_cases_context'; jest.mock('../../../containers/index', () => ({ useTimelineEvents: jest.fn(), @@ -51,6 +52,9 @@ jest.mock('../../../../common/lib/kibana', () => { navigateToApp: jest.fn(), getUrlForApp: jest.fn(), }, + cases: { + getCasesContext: () => mockCasesContext, + }, uiSettings: { get: jest.fn(), }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 580f5cf9cc2a..019bedacbffe 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -26,6 +26,7 @@ import { useSourcererDataView } from '../../../../common/containers/sourcerer'; import { mockSourcererScope } from '../../../../common/containers/sourcerer/mocks'; import { Direction } from '../../../../../common/search_strategy'; import * as helpers from '../helpers'; +import { mockCasesContext } from '../../../../common/mock/mock_cases_context'; jest.mock('../../../containers/index', () => ({ useTimelineEvents: jest.fn(), @@ -59,6 +60,9 @@ jest.mock('../../../../common/lib/kibana', () => { navigateToApp: jest.fn(), getUrlForApp: jest.fn(), }, + cases: { + getCasesContext: () => mockCasesContext, + }, uiSettings: { get: jest.fn(), }, diff --git a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_new_case_button.tsx b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_new_case_button.tsx index a83cca49c1fd..18ddda0791c5 100644 --- a/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_new_case_button.tsx +++ b/x-pack/plugins/timelines/public/components/actions/timeline/cases/add_to_new_case_button.tsx @@ -8,6 +8,8 @@ import React, { memo } from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { TimelinesStartServices } from '../../../../types'; import { useAddToCase } from '../../../../hooks/use_add_to_case'; import { AddToCaseActionProps } from './add_to_case_action'; import * as i18n from './translations'; @@ -25,7 +27,7 @@ const AddToNewCaseButtonComponent: React.FC = ({ owner, onClose, }) => { - const { addNewCaseClick, isDisabled, userCanCrud } = useAddToCase({ + const { isDisabled, userCanCrud, caseAttachments, onCaseSuccess, onCaseCreated } = useAddToCase({ event, useInsertTimeline, casePermissions, @@ -33,6 +35,20 @@ const AddToNewCaseButtonComponent: React.FC = ({ owner, onClose, }); + const { cases } = useKibana().services; + const createCaseFlyout = cases.hooks.getUseCasesAddToNewCaseFlyout({ + attachments: caseAttachments, + afterCaseCreated: onCaseCreated, + onSuccess: onCaseSuccess, + }); + + const handleClick = () => { + // close the popover + if (onClose) { + onClose(); + } + createCaseFlyout.open(); + }; return ( <> @@ -40,7 +56,7 @@ const AddToNewCaseButtonComponent: React.FC = ({ void; @@ -32,6 +32,7 @@ interface UseAddToCase { closePopover: () => void; isPopoverOpen: boolean; isCreateCaseFlyoutOpen: boolean; + caseAttachments?: CaseAttachments; } export const useAddToCase = ({ @@ -39,6 +40,7 @@ export const useAddToCase = ({ casePermissions, appId, onClose, + owner, }: AddToCaseActionProps): UseAddToCase => { const eventId = event?.ecs._id ?? ''; const dispatch = useDispatch(); @@ -109,6 +111,23 @@ export const useAddToCase = ({ }, [onViewCaseClick, toasts, dispatch, eventId] ); + const caseAttachments: CaseAttachments = useMemo(() => { + const eventIndex = event?.ecs._index ?? ''; + const { ruleId, ruleName } = normalizedEventFields(event); + const attachments = [ + { + alertId: eventId, + index: eventIndex ?? '', + rule: { + id: ruleId, + name: ruleName, + }, + owner, + type: CommentType.alert as const, + }, + ]; + return attachments; + }, [event, eventId, owner]); const onCaseClicked = useCallback( (theCase?: Case) => { @@ -140,6 +159,7 @@ export const useAddToCase = ({ } }, [onClose, closePopover, dispatch, eventId]); return { + caseAttachments, addNewCaseClick, addExistingCaseClick, onCaseClicked,