From 536ea81793fbe7225a5470f12c89dc65da2bb007 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 4 Aug 2021 19:21:25 +0300 Subject: [PATCH] [Cases] Fix connector's icon bug (#107633) --- .../all_cases/all_cases_generic.test.tsx | 36 ++++++++--- .../all_cases/all_cases_generic.tsx | 3 + .../components/all_cases/columns.test.tsx | 62 ++++++++++++------- .../public/components/all_cases/columns.tsx | 24 +++++-- .../components/all_cases/index.test.tsx | 40 +++++++++--- .../connectors_dropdown.test.tsx | 34 ++++++++-- .../configure_cases/connectors_dropdown.tsx | 6 +- .../components/connectors/card.test.tsx | 43 +++++++++++++ .../public/components/connectors/card.tsx | 10 ++- .../components/create/connector.test.tsx | 25 +++++--- .../cases/public/components/utils.test.ts | 40 ++++++++++++ .../plugins/cases/public/components/utils.ts | 27 ++++++++ 12 files changed, 282 insertions(+), 68 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/connectors/card.test.tsx create mode 100644 x-pack/plugins/cases/public/components/utils.test.ts diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx index 47c683becb244..0e548fd53c89d 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx @@ -7,20 +7,27 @@ import React from 'react'; import { mount } from 'enzyme'; -import { AllCasesGeneric } from './all_cases_generic'; +import { act } from 'react-dom/test-utils'; +import { AllCasesGeneric } from './all_cases_generic'; import { TestProviders } from '../../common/mock'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; import { useGetActionLicense } from '../../containers/use_get_action_license'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { useKibana } from '../../common/lib/kibana'; import { StatusAll } from '../../containers/types'; import { CaseStatuses, SECURITY_SOLUTION_OWNER } from '../../../common'; -import { act } from 'react-dom/test-utils'; +import { connectorsMock } from '../../containers/mock'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/use_get_action_license'); +jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/api'); +jest.mock('../../common/lib/kibana'); const createCaseNavigation = { href: '', onClick: jest.fn() }; @@ -34,26 +41,34 @@ const alertDataMock = { alertId: 'alert-id', owner: SECURITY_SOLUTION_OWNER, }; + +const useKibanaMock = useKibana as jest.Mocked; +const useConnectorsMock = useConnectors as jest.Mock; +const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); + jest.mock('../../common/lib/kibana', () => { const originalModule = jest.requireActual('../../common/lib/kibana'); return { ...originalModule, useKibana: () => ({ services: { - triggersActionsUi: { - actionTypeRegistry: { - get: jest.fn().mockReturnValue({ - actionTypeTitle: '.jira', - iconClass: 'logoSecurity', - }), - }, - }, + triggersActionsUi: mockTriggersActionsUiService, }, }), }; }); describe('AllCasesGeneric ', () => { + const { createMockActionTypeModel } = actionTypeRegistryMock; + + beforeAll(() => { + connectorsMock.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); + }); + beforeEach(() => { jest.resetAllMocks(); (useGetTags as jest.Mock).mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags: jest.fn() }); @@ -68,6 +83,7 @@ describe('AllCasesGeneric ', () => { actionLicense: null, isLoading: false, }); + useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false })); }); it('renders the first available status when hiddenStatus is given', () => diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx index 477ea27be99bf..72491a2bc1e31 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx @@ -37,6 +37,7 @@ import { CasesTableFilters } from './table_filters'; import { EuiBasicTableOnChange } from './types'; import { CasesTable } from './table'; +import { useConnectors } from '../../containers/configure/use_connectors'; const ProgressLoader = styled(EuiProgress)` ${({ $isShow }: { $isShow: boolean }) => @@ -103,6 +104,7 @@ export const AllCasesGeneric = React.memo( // Post Comment to Case const { postComment, isLoading: isCommentUpdating } = usePostComment(); + const { connectors } = useConnectors({ toastPermissionsErrors: false }); const sorting = useMemo( () => ({ @@ -203,6 +205,7 @@ export const AllCasesGeneric = React.memo( refreshCases, showActions, userCanCrud, + connectors, }); const itemIdToExpandedRowMap = useMemo( diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index 0f0189f2d29c2..015ba877a2749 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -10,51 +10,69 @@ import { mount } from 'enzyme'; import '../../common/mock/match_media'; import { ExternalServiceColumn } from './columns'; - import { useGetCasesMockState } from '../../containers/mock'; +import { useKibana } from '../../common/lib/kibana'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { connectors } from '../configure_cases/__mock__'; -jest.mock('../../common/lib/kibana', () => { - const originalModule = jest.requireActual('../../common/lib/kibana'); - return { - ...originalModule, - useKibana: () => ({ - services: { - triggersActionsUi: { - actionTypeRegistry: { - get: jest.fn().mockReturnValue({ - actionTypeTitle: '.jira', - iconClass: 'logoSecurity', - }), - }, - }, - }, - }), - }; -}); +jest.mock('../../common/lib/kibana'); +const useKibanaMock = useKibana as jest.Mocked; describe('ExternalServiceColumn ', () => { + const { createMockActionTypeModel } = actionTypeRegistryMock; + + beforeAll(() => { + connectors.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); + }); + it('Not pushed render', () => { const wrapper = mount( - + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-notPushed"]`).last().exists() ).toBeTruthy(); }); + it('Up to date', () => { const wrapper = mount( - + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-upToDate"]`).last().exists() ).toBeTruthy(); }); + it('Needs update', () => { const wrapper = mount( - + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-requiresUpdate"]`).last().exists() ).toBeTruthy(); }); + + it('it does not throw when accessing the icon if the connector type is not registered', () => { + // If the component throws the test will fail + expect(() => + mount( + + ) + ).not.toThrowError(); + }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index 140dbf2f53c25..8b755b0c60968 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -21,7 +21,14 @@ import { import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; -import { CaseStatuses, CaseType, DeleteCase, Case, SubCase } from '../../../common'; +import { + CaseStatuses, + CaseType, + DeleteCase, + Case, + SubCase, + ActionConnector, +} from '../../../common'; import { getEmptyTagValue } from '../empty_value'; import { FormattedRelativePreferenceDate } from '../formatted_date'; import { CaseDetailsHrefSchema, CaseDetailsLink, CasesNavigation } from '../links'; @@ -35,6 +42,7 @@ import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { useKibana } from '../../common/lib/kibana'; import { StatusContextMenu } from '../case_action_bar/status_context_menu'; import { TruncatedText } from '../truncated_text'; +import { getConnectorIcon } from '../utils'; export type CasesColumns = | EuiTableActionsColumnType @@ -66,6 +74,7 @@ export interface GetCasesColumn { refreshCases?: (a?: boolean) => void; showActions: boolean; userCanCrud: boolean; + connectors?: ActionConnector[]; } export const useCasesColumns = ({ caseDetailsNavigation, @@ -77,6 +86,7 @@ export const useCasesColumns = ({ refreshCases, showActions, userCanCrud, + connectors = [], }: GetCasesColumn): CasesColumns[] => { // Delete case const { @@ -266,7 +276,7 @@ export const useCasesColumns = ({ name: i18n.EXTERNAL_INCIDENT, render: (theCase: Case) => { if (theCase.id != null) { - return ; + return ; } return getEmptyTagValue(); }, @@ -325,6 +335,7 @@ export const useCasesColumns = ({ interface Props { theCase: Case; + connectors: ActionConnector[]; } const IconWrapper = styled.span` @@ -335,26 +346,31 @@ const IconWrapper = styled.span` width: 20px !important; } `; -export const ExternalServiceColumn: React.FC = ({ theCase }) => { + +export const ExternalServiceColumn: React.FC = ({ theCase, connectors }) => { const { triggersActionsUi } = useKibana().services; if (theCase.externalService == null) { return renderStringField(i18n.NOT_PUSHED, `case-table-column-external-notPushed`); } + const lastPushedConnector: ActionConnector | undefined = connectors.find( + (connector) => connector.id === theCase.externalService?.connectorId + ); const lastCaseUpdate = theCase.updatedAt != null ? new Date(theCase.updatedAt) : null; const lastCasePush = theCase.externalService?.pushedAt != null ? new Date(theCase.externalService?.pushedAt) : null; const hasDataToPush = lastCasePush === null || (lastCaseUpdate != null && lastCasePush.getTime() < lastCaseUpdate?.getTime()); + return (

; +const useConnectorsMock = useConnectors as jest.Mock; + +const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); jest.mock('../../common/lib/kibana', () => { const originalModule = jest.requireActual('../../common/lib/kibana'); @@ -42,14 +58,7 @@ jest.mock('../../common/lib/kibana', () => { ...originalModule, useKibana: () => ({ services: { - triggersActionsUi: { - actionTypeRegistry: { - get: jest.fn().mockReturnValue({ - actionTypeTitle: '.jira', - iconClass: 'logoSecurity', - }), - }, - }, + triggersActionsUi: mockTriggersActionsUiService, }, }), }; @@ -139,6 +148,16 @@ describe('AllCasesGeneric', () => { userCanCrud: true, }; + const { createMockActionTypeModel } = actionTypeRegistryMock; + + beforeAll(() => { + connectorsMock.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); + }); + beforeEach(() => { jest.clearAllMocks(); useUpdateCasesMock.mockReturnValue(defaultUpdateCases); @@ -146,6 +165,7 @@ describe('AllCasesGeneric', () => { useDeleteCasesMock.mockReturnValue(defaultDeleteCases); useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); useGetActionLicenseMock.mockReturnValue(defaultActionLicense); + useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false })); moment.tz.setDefault('UTC'); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 141be31a093f1..8eae574776e2e 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -13,6 +13,7 @@ import { ConnectorsDropdown, Props } from './connectors_dropdown'; import { TestProviders } from '../../common/mock'; import { connectors } from './__mock__'; import { useKibana } from '../../common/lib/kibana'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; @@ -27,11 +28,14 @@ describe('ConnectorsDropdown', () => { selectedConnector: 'none', }; + const { createMockActionTypeModel } = actionTypeRegistryMock; + beforeAll(() => { - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ - actionTypeTitle: '.servicenow', - iconClass: 'logoSecurity', - }); + connectors.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -219,4 +223,26 @@ describe('ConnectorsDropdown', () => { options.some((o) => o['data-test-subj'] === 'dropdown-connector-servicenow-sir') ).toBeFalsy(); }); + + test('it does not throw when accessing the icon if the connector type is not registered', () => { + expect(() => + mount( + , + { + wrappingComponent: TestProviders, + } + ) + ).not.toThrowError(); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index d26ec06d696fc..3cab2afd41f41 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -13,6 +13,7 @@ import { ConnectorTypes } from '../../../common'; import { ActionConnector } from '../../containers/configure/types'; import * as i18n from './translations'; import { useKibana } from '../../common/lib/kibana'; +import { getConnectorIcon } from '../utils'; export interface Props { connectors: ActionConnector[]; @@ -81,10 +82,7 @@ const ConnectorsDropdownComponent: React.FC = ({ diff --git a/x-pack/plugins/cases/public/components/connectors/card.test.tsx b/x-pack/plugins/cases/public/components/connectors/card.test.tsx new file mode 100644 index 0000000000000..b5d70a6781916 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/card.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 { mount } from 'enzyme'; + +import { ConnectorTypes } from '../../../common'; +import { useKibana } from '../../common/lib/kibana'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { connectors } from '../configure_cases/__mock__'; +import { ConnectorCard } from './card'; + +jest.mock('../../common/lib/kibana'); +const useKibanaMock = useKibana as jest.Mocked; + +describe('ConnectorCard ', () => { + const { createMockActionTypeModel } = actionTypeRegistryMock; + + beforeAll(() => { + connectors.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); + }); + + it('it does not throw when accessing the icon if the connector type is not registered', () => { + expect(() => + mount( + + ) + ).not.toThrowError(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/card.tsx b/x-pack/plugins/cases/public/components/connectors/card.tsx index b27c7207e7b8a..86cd90dafb376 100644 --- a/x-pack/plugins/cases/public/components/connectors/card.tsx +++ b/x-pack/plugins/cases/public/components/connectors/card.tsx @@ -11,6 +11,7 @@ import styled from 'styled-components'; import { ConnectorTypes } from '../../../common'; import { useKibana } from '../../common/lib/kibana'; +import { getConnectorIcon } from '../utils'; interface ConnectorCardProps { connectorType: ConnectorTypes; @@ -47,16 +48,13 @@ const ConnectorCardDisplay: React.FC = ({ ), [listItems] ); + const icon = useMemo( - () => ( - - ), + () => , // eslint-disable-next-line react-hooks/exhaustive-deps [connectorType] ); + return ( <> {isLoading && } diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx index bc6d5c8717ece..1b480c3f5d78a 100644 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -21,20 +21,18 @@ import { schema, FormProps } from './schema'; import { TestProviders } from '../../common/mock'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; +import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { useKibana } from '../../common/lib/kibana'; + +const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); jest.mock('../../common/lib/kibana', () => ({ useKibana: () => ({ services: { notifications: {}, http: {}, - triggersActionsUi: { - actionTypeRegistry: { - get: jest.fn().mockReturnValue({ - actionTypeTitle: 'test', - iconClass: 'logoSecurity', - }), - }, - }, + triggersActionsUi: mockTriggersActionsUiService, }, }), })); @@ -48,6 +46,7 @@ const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetChoicesMock = useGetChoices as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const useKibanaMock = useKibana as jest.Mocked; const useGetIncidentTypesResponse = { isLoading: false, @@ -87,6 +86,16 @@ describe('Connector', () => { return

{children}
; }; + const { createMockActionTypeModel } = actionTypeRegistryMock; + + beforeAll(() => { + connectorsMock.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); + }); + beforeEach(() => { jest.resetAllMocks(); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts new file mode 100644 index 0000000000000..496dc8d8c8066 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { actionTypeRegistryMock } from '../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { triggersActionsUiMock } from '../../../triggers_actions_ui/public/mocks'; +import { getConnectorIcon } from './utils'; + +describe('Utils', () => { + describe('getConnectorIcon', () => { + const { createMockActionTypeModel } = actionTypeRegistryMock; + const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); + mockTriggersActionsUiService.actionTypeRegistry.register( + createMockActionTypeModel({ id: '.test', iconClass: 'test' }) + ); + + it('it returns the correct icon class', () => { + expect(getConnectorIcon(mockTriggersActionsUiService, '.test')).toBe('test'); + }); + + it('it returns an empty string if the type is undefined', () => { + expect(getConnectorIcon(mockTriggersActionsUiService)).toBe(''); + }); + + it('it returns an empty string if the type is not registered', () => { + expect(getConnectorIcon(mockTriggersActionsUiService, '.not-registered')).toBe(''); + }); + + it('it returns an empty string if it throws', () => { + mockTriggersActionsUiService.actionTypeRegistry.get = () => { + throw new Error(); + }; + + expect(getConnectorIcon(mockTriggersActionsUiService, '.not-registered')).toBe(''); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 033529c27a2d4..5f7480cb84f7c 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -5,8 +5,10 @@ * 2.0. */ +import { IconType } from '@elastic/eui'; import { ConnectorTypes } from '../../common'; import { FieldConfig, ValidationConfig } from '../common/shared_imports'; +import { StartPlugins } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; import { CaseActionConnector } from './types'; @@ -41,3 +43,28 @@ export const getConnectorsFormValidators = ({ }, ], }); + +export const getConnectorIcon = ( + triggersActionsUi: StartPlugins['triggersActionsUi'], + type?: string +): IconType => { + /** + * triggersActionsUi.actionTypeRegistry.get will throw an error if the type is not registered. + * This will break Kibana if not handled properly. + */ + const emptyResponse = ''; + + if (type == null) { + return emptyResponse; + } + + try { + if (triggersActionsUi.actionTypeRegistry.has(type)) { + return triggersActionsUi.actionTypeRegistry.get(type).iconClass; + } + } catch { + return emptyResponse; + } + + return emptyResponse; +};