diff --git a/package.json b/package.json index 97e1fe7c24bce..ebadbc429e35a 100644 --- a/package.json +++ b/package.json @@ -486,7 +486,6 @@ "fast-deep-equal": "^3.1.1", "fflate": "^0.6.9", "file-saver": "^1.3.8", - "fnv-plus": "^1.3.1", "font-awesome": "4.7.0", "formik": "^2.2.9", "fp-ts": "^2.3.1", @@ -820,7 +819,6 @@ "@types/fetch-mock": "^7.3.1", "@types/file-saver": "^2.0.0", "@types/flot": "^0.0.31", - "@types/fnv-plus": "^1.3.0", "@types/geojson": "7946.0.7", "@types/getos": "^3.0.0", "@types/gulp": "^4.0.6", diff --git a/renovate.json b/renovate.json index 4075b2452bea1..bdaf373932fa1 100644 --- a/renovate.json +++ b/renovate.json @@ -210,7 +210,7 @@ }, { "groupName": "Profiling", - "matchPackageNames": ["fnv-plus", "peggy", "@types/dagre", "@types/fnv-plus"], + "matchPackageNames": ["peggy", "@types/dagre"], "reviewers": ["team:profiling-ui"], "matchBaseBranches": ["main"], "labels": ["release_note:skip", "backport:skip"], diff --git a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx index 5a03e2dd3187c..6588cefdb8f2b 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx +++ b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.test.tsx @@ -211,7 +211,7 @@ describe('GaugeComponent', function () { }); describe('ticks and color bands', () => { - it('sets proper color bands for values smaller than maximum', () => { + it('sets proper color bands and ticks on color bands for values smaller than maximum', () => { const palette = { type: 'palette' as const, name: 'custom', @@ -236,6 +236,7 @@ describe('GaugeComponent', function () { }, } as GaugeRenderProps; const goal = shallowWithIntl().find(Goal); + expect(goal.prop('ticks')).toEqual([0, 1, 2, 3, 4, 10]); expect(goal.prop('bands')).toEqual([0, 1, 2, 3, 4, 10]); }); it('sets proper color bands if palette steps are smaller than minimum', () => { diff --git a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx index b75d613f814a7..37e3ca6bdd8f1 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx +++ b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx @@ -18,6 +18,8 @@ import { GaugeLabelMajorMode, GaugeLabelMajorModes, GaugeColorModes, + GaugeShapes, + GaugeTicksPositions, } from '../../common'; import { getAccessorsFromArgs, @@ -30,7 +32,7 @@ import { } from './utils'; import { getIcons } from './utils/icons'; import './index.scss'; -import { GaugeCentralMajorMode } from '../../common/types'; +import { GaugeCentralMajorMode, GaugeTicksPosition } from '../../common/types'; import { isBulletShape, isRoundShape } from '../../common/utils'; import './gauge.scss'; @@ -135,6 +137,35 @@ const getPreviousSectionValue = (value: number, bands: number[]) => { return prevSectionValue; }; +function getTicksLabels(baseStops: number[]) { + const tenPercentRange = (Math.max(...baseStops) - Math.min(...baseStops)) * 0.1; + const lastIndex = baseStops.length - 1; + return baseStops.filter((stop, i) => { + if (i === 0 || i === lastIndex) { + return true; + } + + return !( + stop - baseStops[i - 1] < tenPercentRange || baseStops[lastIndex] - stop < tenPercentRange + ); + }); +} + +function getTicks( + ticksPosition: GaugeTicksPosition, + range: [number, number], + colorBands?: number[], + percentageMode?: boolean +) { + if (ticksPosition === GaugeTicksPositions.HIDDEN) { + return []; + } + + if (ticksPosition === GaugeTicksPositions.BANDS && colorBands) { + return colorBands && getTicksLabels(colorBands); + } +} + export const GaugeComponent: FC = memo( ({ data, args, uiState, formatFactory, paletteService, chartsThemeService, renderComplete }) => { const { @@ -146,6 +177,7 @@ export const GaugeComponent: FC = memo( labelMajorMode, centralMajor, centralMajorMode, + ticksPosition, commonLabel, } = args; @@ -294,6 +326,12 @@ export const GaugeComponent: FC = memo( actualValue = actualValueToPercentsLegacy(palette?.params as CustomPaletteState, actualValue); } + const totalTicks = getTicks(ticksPosition, [min, max], bands, args.percentageMode); + const ticks = + totalTicks && gaugeType === GaugeShapes.CIRCLE + ? totalTicks.slice(0, totalTicks.length - 1) + : totalTicks; + const goalConfig = getGoalConfig(gaugeType); const labelMajorTitle = getTitle(labelMajorMode, labelMajor, metricColumn?.name); @@ -329,6 +367,7 @@ export const GaugeComponent: FC = memo( tickValueFormatter={({ value: tickValue }) => tickFormatter.convert(tickValue)} tooltipValueFormatter={(tooltipValue) => tickFormatter.convert(tooltipValue)} bands={bands} + ticks={ticks} domain={{ min, max }} bandFillColor={ colorMode === GaugeColorModes.PALETTE diff --git a/src/plugins/guided_onboarding/public/constants/guides_config/security.ts b/src/plugins/guided_onboarding/public/constants/guides_config/security.ts index df17d00d7f2d4..769d95f978edc 100644 --- a/src/plugins/guided_onboarding/public/constants/guides_config/security.ts +++ b/src/plugins/guided_onboarding/public/constants/guides_config/security.ts @@ -21,6 +21,11 @@ export const securityConfig: GuideConfig = { 'Nullam ligula enim, malesuada a finibus vel, cursus sed risus.', 'Vivamus pretium, elit dictum lacinia aliquet, libero nibh dictum enim, a rhoncus leo magna in sapien.', ], + integration: 'endpoint', + location: { + appID: 'integrations', + path: '/browse/security', + }, }, { id: 'rules', diff --git a/src/plugins/guided_onboarding/public/mocks.tsx b/src/plugins/guided_onboarding/public/mocks.tsx new file mode 100644 index 0000000000000..dff111fba67c8 --- /dev/null +++ b/src/plugins/guided_onboarding/public/mocks.tsx @@ -0,0 +1,31 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; +import { GuidedOnboardingPluginStart } from '.'; + +const apiServiceMock: jest.Mocked = { + guidedOnboardingApi: { + setup: jest.fn(), + fetchActiveGuideState$: () => new BehaviorSubject(undefined), + fetchAllGuidesState: jest.fn(), + updateGuideState: jest.fn(), + activateGuide: jest.fn(), + completeGuide: jest.fn(), + isGuideStepActive$: () => new BehaviorSubject(false), + startGuideStep: jest.fn(), + completeGuideStep: jest.fn(), + isGuidedOnboardingActiveForIntegration$: () => new BehaviorSubject(false), + completeGuidedOnboardingForIntegration: jest.fn(), + }, +}; + +export const guidedOnboardingMock = { + createSetup: () => {}, + createStart: () => apiServiceMock, +}; diff --git a/src/plugins/guided_onboarding/public/services/api.mocks.ts b/src/plugins/guided_onboarding/public/services/api.mocks.ts new file mode 100644 index 0000000000000..19dd67c7d7b1b --- /dev/null +++ b/src/plugins/guided_onboarding/public/services/api.mocks.ts @@ -0,0 +1,97 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { GuideState } from '../../common/types'; + +export const searchAddDataActiveState: GuideState = { + guideId: 'search', + isActive: true, + status: 'in_progress', + steps: [ + { + id: 'add_data', + status: 'active', + }, + { + id: 'browse_docs', + status: 'inactive', + }, + { + id: 'search_experience', + status: 'inactive', + }, + ], +}; + +export const searchAddDataInProgressState: GuideState = { + isActive: true, + status: 'in_progress', + steps: [ + { + id: 'add_data', + status: 'in_progress', + }, + { + id: 'browse_docs', + status: 'inactive', + }, + { + id: 'search_experience', + status: 'inactive', + }, + ], + guideId: 'search', +}; + +export const securityAddDataInProgressState: GuideState = { + guideId: 'security', + status: 'in_progress', + isActive: true, + steps: [ + { + id: 'add_data', + status: 'in_progress', + }, + { + id: 'rules', + status: 'inactive', + }, + ], +}; + +export const securityRulesActivesState: GuideState = { + guideId: 'security', + isActive: true, + status: 'in_progress', + steps: [ + { + id: 'add_data', + status: 'complete', + }, + { + id: 'rules', + status: 'active', + }, + ], +}; + +export const noGuideActiveState: GuideState = { + guideId: 'security', + status: 'in_progress', + isActive: false, + steps: [ + { + id: 'add_data', + status: 'in_progress', + }, + { + id: 'rules', + status: 'inactive', + }, + ], +}; diff --git a/src/plugins/guided_onboarding/public/services/api.test.ts b/src/plugins/guided_onboarding/public/services/api.test.ts index ffe5596bd7e35..fad2a01d79891 100644 --- a/src/plugins/guided_onboarding/public/services/api.test.ts +++ b/src/plugins/guided_onboarding/public/services/api.test.ts @@ -14,29 +14,17 @@ import { API_BASE_PATH } from '../../common/constants'; import { guidesConfig } from '../constants/guides_config'; import type { GuideState } from '../../common/types'; import { ApiService } from './api'; +import { + noGuideActiveState, + searchAddDataActiveState, + securityAddDataInProgressState, + securityRulesActivesState, +} from './api.mocks'; const searchGuide = 'search'; const firstStep = guidesConfig[searchGuide].steps[0].id; - -const mockActiveSearchGuideState: GuideState = { - guideId: searchGuide, - isActive: true, - status: 'in_progress', - steps: [ - { - id: 'add_data', - status: 'active', - }, - { - id: 'browse_docs', - status: 'inactive', - }, - { - id: 'search_experience', - status: 'inactive', - }, - ], -}; +const endpointIntegration = 'endpoint'; +const kubernetesIntegration = 'kubernetes'; describe('GuidedOnboarding ApiService', () => { let httpClient: jest.Mocked; @@ -46,7 +34,7 @@ describe('GuidedOnboarding ApiService', () => { beforeEach(() => { httpClient = httpServiceMock.createStartContract({ basePath: '/base/path' }); httpClient.get.mockResolvedValue({ - state: { activeGuide: searchGuide, activeStep: firstStep }, + state: [securityAddDataInProgressState], }); apiService = new ApiService(); apiService.setup(httpClient); @@ -72,7 +60,7 @@ describe('GuidedOnboarding ApiService', () => { await apiService.activateGuide(searchGuide); const state = await firstValueFrom(apiService.fetchActiveGuideState$()); - expect(state).toEqual(mockActiveSearchGuideState); + expect(state).toEqual(searchAddDataActiveState); }); }); @@ -87,14 +75,14 @@ describe('GuidedOnboarding ApiService', () => { describe('updateGuideState', () => { it('sends a request to the put API', async () => { const updatedState: GuideState = { - ...mockActiveSearchGuideState, + ...searchAddDataActiveState, steps: [ { - id: mockActiveSearchGuideState.steps[0].id, + id: searchAddDataActiveState.steps[0].id, status: 'in_progress', // update the first step status }, - mockActiveSearchGuideState.steps[1], - mockActiveSearchGuideState.steps[2], + searchAddDataActiveState.steps[1], + searchAddDataActiveState.steps[2], ], }; await apiService.updateGuideState(updatedState, false); @@ -108,14 +96,14 @@ describe('GuidedOnboarding ApiService', () => { describe('isGuideStepActive$', () => { it('returns true if the step has been started', async (done) => { const updatedState: GuideState = { - ...mockActiveSearchGuideState, + ...searchAddDataActiveState, steps: [ { - id: mockActiveSearchGuideState.steps[0].id, + id: searchAddDataActiveState.steps[0].id, status: 'in_progress', }, - mockActiveSearchGuideState.steps[1], - mockActiveSearchGuideState.steps[2], + searchAddDataActiveState.steps[1], + searchAddDataActiveState.steps[2], ], }; await apiService.updateGuideState(updatedState, false); @@ -130,7 +118,7 @@ describe('GuidedOnboarding ApiService', () => { }); it('returns false if the step is not been started', async (done) => { - await apiService.updateGuideState(mockActiveSearchGuideState, false); + await apiService.updateGuideState(searchAddDataActiveState, false); subscription = apiService .isGuideStepActive$(searchGuide, firstStep) .subscribe((isStepActive) => { @@ -170,21 +158,18 @@ describe('GuidedOnboarding ApiService', () => { }); it('reactivates a guide that has already been started', async () => { - await apiService.activateGuide(searchGuide, mockActiveSearchGuideState); + await apiService.activateGuide(searchGuide, searchAddDataActiveState); expect(httpClient.put).toHaveBeenCalledTimes(1); expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { - body: JSON.stringify({ - ...mockActiveSearchGuideState, - isActive: true, - }), + body: JSON.stringify(searchAddDataActiveState), }); }); }); describe('completeGuide', () => { const readyToCompleteGuideState: GuideState = { - ...mockActiveSearchGuideState, + ...searchAddDataActiveState, steps: [ { id: 'add_data', @@ -224,7 +209,7 @@ describe('GuidedOnboarding ApiService', () => { it('returns undefined if the selected guide has uncompleted steps', async () => { const incompleteGuideState: GuideState = { - ...mockActiveSearchGuideState, + ...searchAddDataActiveState, steps: [ { id: 'add_data', @@ -249,7 +234,7 @@ describe('GuidedOnboarding ApiService', () => { describe('startGuideStep', () => { beforeEach(async () => { - await apiService.updateGuideState(mockActiveSearchGuideState, false); + await apiService.updateGuideState(searchAddDataActiveState, false); }); it('updates the selected step and marks it as in_progress', async () => { @@ -257,16 +242,16 @@ describe('GuidedOnboarding ApiService', () => { expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { body: JSON.stringify({ - ...mockActiveSearchGuideState, + ...searchAddDataActiveState, isActive: true, status: 'in_progress', steps: [ { - id: mockActiveSearchGuideState.steps[0].id, + id: searchAddDataActiveState.steps[0].id, status: 'in_progress', }, - mockActiveSearchGuideState.steps[1], - mockActiveSearchGuideState.steps[2], + searchAddDataActiveState.steps[1], + searchAddDataActiveState.steps[2], ], }), }); @@ -281,14 +266,14 @@ describe('GuidedOnboarding ApiService', () => { describe('completeGuideStep', () => { it(`completes the step when it's in progress`, async () => { const updatedState: GuideState = { - ...mockActiveSearchGuideState, + ...searchAddDataActiveState, steps: [ { - id: mockActiveSearchGuideState.steps[0].id, + id: searchAddDataActiveState.steps[0].id, status: 'in_progress', // Mark a step as in_progress in order to test the "completeGuideStep" behavior }, - mockActiveSearchGuideState.steps[1], - mockActiveSearchGuideState.steps[2], + searchAddDataActiveState.steps[1], + searchAddDataActiveState.steps[2], ], }; await apiService.updateGuideState(updatedState, false); @@ -303,14 +288,14 @@ describe('GuidedOnboarding ApiService', () => { ...updatedState, steps: [ { - id: mockActiveSearchGuideState.steps[0].id, + id: searchAddDataActiveState.steps[0].id, status: 'complete', }, { - id: mockActiveSearchGuideState.steps[1].id, + id: searchAddDataActiveState.steps[1].id, status: 'active', }, - mockActiveSearchGuideState.steps[2], + searchAddDataActiveState.steps[2], ], }), }); @@ -322,11 +307,91 @@ describe('GuidedOnboarding ApiService', () => { }); it('does nothing if the step is not in progress', async () => { - await apiService.updateGuideState(mockActiveSearchGuideState, false); + await apiService.updateGuideState(searchAddDataActiveState, false); await apiService.completeGuideStep(searchGuide, firstStep); // Expect only 1 call from updateGuideState() expect(httpClient.put).toHaveBeenCalledTimes(1); }); }); + + describe('isGuidedOnboardingActiveForIntegration$', () => { + it('returns true if the integration is part of the active step', async (done) => { + httpClient.get.mockResolvedValue({ + state: [securityAddDataInProgressState], + }); + apiService.setup(httpClient); + subscription = apiService + .isGuidedOnboardingActiveForIntegration$(endpointIntegration) + .subscribe((isIntegrationInGuideStep) => { + if (isIntegrationInGuideStep) { + done(); + } + }); + }); + + it('returns false if another integration is part of the active step', async (done) => { + httpClient.get.mockResolvedValue({ + state: [securityAddDataInProgressState], + }); + apiService.setup(httpClient); + subscription = apiService + .isGuidedOnboardingActiveForIntegration$(kubernetesIntegration) + .subscribe((isIntegrationInGuideStep) => { + if (!isIntegrationInGuideStep) { + done(); + } + }); + }); + + it('returns false if no guide is active', async (done) => { + httpClient.get.mockResolvedValue({ + state: [noGuideActiveState], + }); + apiService.setup(httpClient); + subscription = apiService + .isGuidedOnboardingActiveForIntegration$(endpointIntegration) + .subscribe((isIntegrationInGuideStep) => { + if (!isIntegrationInGuideStep) { + done(); + } + }); + }); + }); + + describe('completeGuidedOnboardingForIntegration', () => { + it(`completes the step if it's active for the integration`, async () => { + httpClient.get.mockResolvedValue({ + state: [securityAddDataInProgressState], + }); + apiService.setup(httpClient); + + await apiService.completeGuidedOnboardingForIntegration(endpointIntegration); + expect(httpClient.put).toHaveBeenCalledTimes(1); + // this assertion depends on the guides config + expect(httpClient.put).toHaveBeenCalledWith(`${API_BASE_PATH}/state`, { + body: JSON.stringify(securityRulesActivesState), + }); + }); + + it(`does nothing if the step has a different integration`, async () => { + httpClient.get.mockResolvedValue({ + state: [securityAddDataInProgressState], + }); + apiService.setup(httpClient); + + await apiService.completeGuidedOnboardingForIntegration(kubernetesIntegration); + expect(httpClient.put).not.toHaveBeenCalled(); + }); + + it(`does nothing if no guide is active`, async () => { + httpClient.get.mockResolvedValue({ + state: [noGuideActiveState], + }); + apiService.setup(httpClient); + + await apiService.completeGuidedOnboardingForIntegration(endpointIntegration); + expect(httpClient.put).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/plugins/guided_onboarding/public/services/api.ts b/src/plugins/guided_onboarding/public/services/api.ts index 1adfaa5d8cc23..9ffe13bbbb63f 100644 --- a/src/plugins/guided_onboarding/public/services/api.ts +++ b/src/plugins/guided_onboarding/public/services/api.ts @@ -9,11 +9,17 @@ import { HttpSetup } from '@kbn/core/public'; import { BehaviorSubject, map, from, concatMap, of, Observable, firstValueFrom } from 'rxjs'; +import { GuidedOnboardingApi } from '../types'; +import { + getGuideConfig, + getInProgressStepId, + isIntegrationInGuideStep, + isLastStep, +} from './helpers'; import { API_BASE_PATH } from '../../common/constants'; import type { GuideState, GuideId, GuideStep, GuideStepIds } from '../../common/types'; -import { isLastStep, getGuideConfig } from './helpers'; -export class ApiService { +export class ApiService implements GuidedOnboardingApi { private client: HttpSetup | undefined; private onboardingGuideState$!: BehaviorSubject; public isGuidePanelOpen$: BehaviorSubject = new BehaviorSubject(false); @@ -102,8 +108,8 @@ export class ApiService { /** * Activates a guide by guideId * This is useful for the onboarding landing page, when a user selects a guide to start or continue - * @param {GuideId} guideID the id of the guide (one of search, observability, security) - * @param {GuideState} guideState (optional) the selected guide state, if it exists (i.e., if a user is continuing a guide) + * @param {GuideId} guideId the id of the guide (one of search, observability, security) + * @param {GuideState} guide (optional) the selected guide state, if it exists (i.e., if a user is continuing a guide) * @return {Promise} a promise with the updated guide state */ public async activateGuide( @@ -150,7 +156,7 @@ export class ApiService { * Completes a guide * Updates the overall guide status to 'complete', and marks it as inactive * This is useful for the dropdown panel, when the user clicks the "Continue using Elastic" button after completing all steps - * @param {GuideId} guideID the id of the guide (one of search, observability, security) + * @param {GuideId} guideId the id of the guide (one of search, observability, security) * @return {Promise} a promise with the updated guide state */ public async completeGuide(guideId: GuideId): Promise<{ state: GuideState } | undefined> { @@ -300,6 +306,38 @@ export class ApiService { return undefined; } + + /** + * An observable with the boolean value if the guided onboarding is currently active for the integration. + * Returns true, if the passed integration is used in the current guide's step. + * Returns false otherwise. + * @param {string} integration the integration (package name) to check for in the guided onboarding config + * @return {Observable} an observable with the boolean value + */ + public isGuidedOnboardingActiveForIntegration$(integration?: string): Observable { + return this.fetchActiveGuideState$().pipe( + map((state) => { + return state ? isIntegrationInGuideStep(state, integration) : false; + }) + ); + } + + public async completeGuidedOnboardingForIntegration( + integration?: string + ): Promise<{ state: GuideState } | undefined> { + if (integration) { + const currentState = await firstValueFrom(this.fetchActiveGuideState$()); + if (currentState) { + const inProgressStepId = getInProgressStepId(currentState); + if (inProgressStepId) { + const isIntegrationStepActive = isIntegrationInGuideStep(currentState, integration); + if (isIntegrationStepActive) { + return await this.completeGuideStep(currentState?.guideId, inProgressStepId); + } + } + } + } + } } export const apiService = new ApiService(); diff --git a/src/plugins/guided_onboarding/public/services/helpers.test.ts b/src/plugins/guided_onboarding/public/services/helpers.test.ts index bc09a9185424c..586566fe9488b 100644 --- a/src/plugins/guided_onboarding/public/services/helpers.test.ts +++ b/src/plugins/guided_onboarding/public/services/helpers.test.ts @@ -7,11 +7,16 @@ */ import { guidesConfig } from '../constants/guides_config'; -import { isLastStep } from './helpers'; +import { isIntegrationInGuideStep, isLastStep } from './helpers'; +import { + noGuideActiveState, + securityAddDataInProgressState, + securityRulesActivesState, +} from './api.mocks'; const searchGuide = 'search'; const firstStep = guidesConfig[searchGuide].steps[0].id; -const lastStep = guidesConfig[searchGuide].steps[2].id; +const lastStep = guidesConfig[searchGuide].steps[guidesConfig[searchGuide].steps.length - 1].id; describe('GuidedOnboarding ApiService helpers', () => { // this test suite depends on the guides config @@ -26,4 +31,27 @@ describe('GuidedOnboarding ApiService helpers', () => { expect(result).toBe(false); }); }); + + describe('isIntegrationInGuideStep', () => { + it('return true if the integration is defined in the guide step config', () => { + const result = isIntegrationInGuideStep(securityAddDataInProgressState, 'endpoint'); + expect(result).toBe(true); + }); + it('returns false if a different integration is defined in the guide step', () => { + const result = isIntegrationInGuideStep(securityAddDataInProgressState, 'kubernetes'); + expect(result).toBe(false); + }); + it('returns false if no integration is defined in the guide step', () => { + const result = isIntegrationInGuideStep(securityRulesActivesState, 'endpoint'); + expect(result).toBe(false); + }); + it('returns false if no guide is active', () => { + const result = isIntegrationInGuideStep(noGuideActiveState, 'endpoint'); + expect(result).toBe(false); + }); + it('returns false if no integration passed', () => { + const result = isIntegrationInGuideStep(securityAddDataInProgressState); + expect(result).toBe(false); + }); + }); }); diff --git a/src/plugins/guided_onboarding/public/services/helpers.ts b/src/plugins/guided_onboarding/public/services/helpers.ts index ea4245be99150..0e738646c558d 100644 --- a/src/plugins/guided_onboarding/public/services/helpers.ts +++ b/src/plugins/guided_onboarding/public/services/helpers.ts @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -import type { GuideId } from '../../common/types'; +import type { GuideId, GuideState, GuideStepIds } from '../../common/types'; import { guidesConfig } from '../constants/guides_config'; -import type { GuideConfig, StepConfig } from '../types'; +import { GuideConfig, StepConfig } from '../types'; export const getGuideConfig = (guideID?: string): GuideConfig | undefined => { if (guideID && Object.keys(guidesConfig).includes(guideID)) { @@ -33,3 +33,26 @@ export const isLastStep = (guideID: string, stepID: string): boolean => { } return false; }; + +export const getInProgressStepId = (state: GuideState): GuideStepIds | undefined => { + const inProgressStep = state.steps.find((step) => step.status === 'in_progress'); + return inProgressStep ? inProgressStep.id : undefined; +}; + +const getInProgressStepConfig = (state: GuideState): StepConfig | undefined => { + const inProgressStepId = getInProgressStepId(state); + if (inProgressStepId) { + const config = getGuideConfig(state.guideId); + if (config) { + return config.steps.find((step) => step.id === inProgressStepId); + } + } +}; + +export const isIntegrationInGuideStep = (state: GuideState, integration?: string): boolean => { + if (state.isActive) { + const stepConfig = getInProgressStepConfig(state); + return stepConfig ? stepConfig.integration === integration : false; + } + return false; +}; diff --git a/src/plugins/guided_onboarding/public/types.ts b/src/plugins/guided_onboarding/public/types.ts index 4a16c16336c6b..dbc300bf0d43d 100755 --- a/src/plugins/guided_onboarding/public/types.ts +++ b/src/plugins/guided_onboarding/public/types.ts @@ -6,15 +6,16 @@ * Side Public License, v 1. */ +import { Observable } from 'rxjs'; import { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; -import { GuideId, GuideStepIds, StepStatus } from '../common/types'; -import { ApiService } from './services/api'; +import { HttpSetup } from '@kbn/core/public'; +import { GuideId, GuideState, GuideStepIds, StepStatus } from '../common/types'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface GuidedOnboardingPluginSetup {} export interface GuidedOnboardingPluginStart { - guidedOnboardingApi?: ApiService; + guidedOnboardingApi?: GuidedOnboardingApi; } export interface AppPluginStartDependencies { @@ -25,6 +26,34 @@ export interface ClientConfigType { ui: boolean; } +export interface GuidedOnboardingApi { + setup: (httpClient: HttpSetup) => void; + fetchActiveGuideState$: () => Observable; + fetchAllGuidesState: () => Promise<{ state: GuideState[] } | undefined>; + updateGuideState: ( + newState: GuideState, + panelState: boolean + ) => Promise<{ state: GuideState } | undefined>; + activateGuide: ( + guideId: GuideId, + guide?: GuideState + ) => Promise<{ state: GuideState } | undefined>; + completeGuide: (guideId: GuideId) => Promise<{ state: GuideState } | undefined>; + isGuideStepActive$: (guideId: GuideId, stepId: GuideStepIds) => Observable; + startGuideStep: ( + guideId: GuideId, + stepId: GuideStepIds + ) => Promise<{ state: GuideState } | undefined>; + completeGuideStep: ( + guideId: GuideId, + stepId: GuideStepIds + ) => Promise<{ state: GuideState } | undefined>; + isGuidedOnboardingActiveForIntegration$: (integration?: string) => Observable; + completeGuidedOnboardingForIntegration: ( + integration?: string + ) => Promise<{ state: GuideState } | undefined>; +} + export interface StepConfig { id: GuideStepIds; title: string; @@ -34,6 +63,7 @@ export interface StepConfig { path: string; }; status?: StepStatus; + integration?: string; } export interface GuideConfig { title: string; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts index 4cd9df6bf2137..5cc9f8bb427cf 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_infra_metrics_client/create_infra_metrics_client.ts @@ -9,7 +9,10 @@ import { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; import { APMRouteHandlerResources } from '../../../../routes/typings'; import { getInfraMetricIndices } from '../../get_infra_metric_indices'; -type InfraMetricsSearchParams = Omit; +type InfraMetricsSearchParams = Omit & { + size: number; + track_total_hits: boolean | number; +}; export type InfraMetricsClient = ReturnType; diff --git a/x-pack/plugins/apm/server/routes/infrastructure/get_host_names.ts b/x-pack/plugins/apm/server/routes/infrastructure/get_host_names.ts index 8f47c2d1664bf..257233fada9cb 100644 --- a/x-pack/plugins/apm/server/routes/infrastructure/get_host_names.ts +++ b/x-pack/plugins/apm/server/routes/infrastructure/get_host_names.ts @@ -29,6 +29,7 @@ export async function getContainerHostNames({ const response = await infraMetricsClient.search({ size: 0, + track_total_hits: false, query: { bool: { filter: [ diff --git a/x-pack/plugins/apm/server/routes/services/get_service_instance_container_metadata.ts b/x-pack/plugins/apm/server/routes/services/get_service_instance_container_metadata.ts index ebce4f9338714..b5b98890ac636 100644 --- a/x-pack/plugins/apm/server/routes/services/get_service_instance_container_metadata.ts +++ b/x-pack/plugins/apm/server/routes/services/get_service_instance_container_metadata.ts @@ -51,6 +51,7 @@ export const getServiceInstanceContainerMetadata = async ({ const response = await infraMetricsClient.search({ size: 1, + track_total_hits: false, query: { bool: { filter: [ diff --git a/x-pack/plugins/apm/server/routes/services/get_service_overview_container_metadata.ts b/x-pack/plugins/apm/server/routes/services/get_service_overview_container_metadata.ts index c1dcfe97e208b..4b8d979a7dd85 100644 --- a/x-pack/plugins/apm/server/routes/services/get_service_overview_container_metadata.ts +++ b/x-pack/plugins/apm/server/routes/services/get_service_overview_container_metadata.ts @@ -43,6 +43,7 @@ export const getServiceOverviewContainerMetadata = async ({ const response = await infraMetricsClient.search({ size: 0, + track_total_hits: false, query: { bool: { filter: [ diff --git a/x-pack/plugins/cases/public/common/mock/permissions.ts b/x-pack/plugins/cases/public/common/mock/permissions.ts index 1166dbed8ca88..7395e966037ea 100644 --- a/x-pack/plugins/cases/public/common/mock/permissions.ts +++ b/x-pack/plugins/cases/public/common/mock/permissions.ts @@ -17,6 +17,8 @@ export const noUpdateCasesPermissions = () => buildCasesPermissions({ update: fa export const noPushCasesPermissions = () => buildCasesPermissions({ push: false }); export const noDeleteCasesPermissions = () => buildCasesPermissions({ delete: false }); export const writeCasesPermissions = () => buildCasesPermissions({ read: false }); +export const onlyDeleteCasesPermission = () => + buildCasesPermissions({ read: false, create: false, update: false, delete: true, push: false }); export const buildCasesPermissions = (overrides: Partial> = {}) => { const create = overrides.create ?? true; diff --git a/x-pack/plugins/cases/public/components/actions/delete/translations.ts b/x-pack/plugins/cases/public/components/actions/delete/translations.ts new file mode 100644 index 0000000000000..1ead4dd9f7d17 --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/delete/translations.ts @@ -0,0 +1,20 @@ +/* + * 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 { DELETED_CASES } from '../../../common/translations'; + +export const BULK_ACTION_DELETE_LABEL = i18n.translate( + 'xpack.cases.caseTable.bulkActions.deleteCases', + { + defaultMessage: 'Delete cases', + } +); + +export const DELETE_ACTION_LABEL = i18n.translate('xpack.cases.caseTable.action.deleteCase', { + defaultMessage: 'Delete case', +}); diff --git a/x-pack/plugins/cases/public/components/actions/delete/use_delete_action.test.tsx b/x-pack/plugins/cases/public/components/actions/delete/use_delete_action.test.tsx new file mode 100644 index 0000000000000..747aa0e84e1d7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/delete/use_delete_action.test.tsx @@ -0,0 +1,187 @@ +/* + * 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 { AppMockRenderer, createAppMockRenderer } from '../../../common/mock'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { useDeleteAction } from './use_delete_action'; + +import * as api from '../../../containers/api'; +import { basicCase } from '../../../containers/mock'; + +jest.mock('../../../containers/api'); + +describe('useDeleteAction', () => { + let appMockRender: AppMockRenderer; + const onAction = jest.fn(); + const onActionSuccess = jest.fn(); + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders an action with one case', async () => { + const { result } = renderHook( + () => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current.getAction([basicCase])).toMatchInlineSnapshot(` + Object { + "data-test-subj": "cases-bulk-action-delete", + "disabled": false, + "icon": , + "key": "cases-bulk-action-delete", + "name": + Delete case + , + "onClick": [Function], + } + `); + }); + + it('renders an action with multiple cases', async () => { + const { result } = renderHook( + () => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current.getAction([basicCase, basicCase])).toMatchInlineSnapshot(` + Object { + "data-test-subj": "cases-bulk-action-delete", + "disabled": false, + "icon": , + "key": "cases-bulk-action-delete", + "name": + Delete cases + , + "onClick": [Function], + } + `); + }); + + it('deletes the selected cases', async () => { + const deleteSpy = jest.spyOn(api, 'deleteCases'); + + const { result, waitFor } = renderHook( + () => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const action = result.current.getAction([basicCase]); + + act(() => { + action.onClick(); + }); + + expect(onAction).toHaveBeenCalled(); + expect(result.current.isModalVisible).toBe(true); + + act(() => { + result.current.onConfirmDeletion(); + }); + + await waitFor(() => { + expect(result.current.isModalVisible).toBe(false); + expect(onActionSuccess).toHaveBeenCalled(); + expect(deleteSpy).toHaveBeenCalledWith(['basic-case-id'], expect.anything()); + }); + }); + + it('closes the modal', async () => { + const { result, waitFor } = renderHook( + () => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const action = result.current.getAction([basicCase]); + + act(() => { + action.onClick(); + }); + + expect(result.current.isModalVisible).toBe(true); + + act(() => { + result.current.onCloseModal(); + }); + + await waitFor(() => { + expect(result.current.isModalVisible).toBe(false); + }); + }); + + it('shows the success toaster correctly when delete one case', async () => { + const { result, waitFor } = renderHook( + () => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const action = result.current.getAction([basicCase]); + + act(() => { + action.onClick(); + }); + + act(() => { + result.current.onConfirmDeletion(); + }); + + await waitFor(() => { + expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + 'Deleted case' + ); + }); + }); + + it('shows the success toaster correctly when delete multiple case', async () => { + const { result, waitFor } = renderHook( + () => useDeleteAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const action = result.current.getAction([basicCase, basicCase]); + + act(() => { + action.onClick(); + }); + + act(() => { + result.current.onConfirmDeletion(); + }); + + await waitFor(() => { + expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + 'Deleted 2 cases' + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/actions/delete/use_delete_action.tsx b/x-pack/plugins/cases/public/components/actions/delete/use_delete_action.tsx new file mode 100644 index 0000000000000..92184dbd64648 --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/delete/use_delete_action.tsx @@ -0,0 +1,67 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { EuiIcon, EuiTextColor, useEuiTheme } from '@elastic/eui'; +import { Case } from '../../../../common'; +import { useDeleteCases } from '../../../containers/use_delete_cases'; + +import * as i18n from './translations'; +import { UseActionProps } from '../types'; +import { useCasesContext } from '../../cases_context/use_cases_context'; + +const getDeleteActionTitle = (totalCases: number): string => + totalCases > 1 ? i18n.BULK_ACTION_DELETE_LABEL : i18n.DELETE_ACTION_LABEL; + +export const useDeleteAction = ({ onAction, onActionSuccess, isDisabled }: UseActionProps) => { + const euiTheme = useEuiTheme(); + const { permissions } = useCasesContext(); + const [isModalVisible, setIsModalVisible] = useState(false); + const [caseToBeDeleted, setCaseToBeDeleted] = useState([]); + const canDelete = permissions.delete; + const isActionDisabled = isDisabled || !canDelete; + + const onCloseModal = useCallback(() => setIsModalVisible(false), []); + const openModal = useCallback( + (selectedCases: Case[]) => { + onAction(); + setIsModalVisible(true); + setCaseToBeDeleted(selectedCases); + }, + [onAction] + ); + + const { mutate: deleteCases } = useDeleteCases(); + + const onConfirmDeletion = useCallback(() => { + onCloseModal(); + deleteCases( + { + caseIds: caseToBeDeleted.map(({ id }) => id), + successToasterTitle: i18n.DELETED_CASES(caseToBeDeleted.length), + }, + { onSuccess: onActionSuccess } + ); + }, [deleteCases, onActionSuccess, onCloseModal, caseToBeDeleted]); + + const color = isActionDisabled ? euiTheme.euiTheme.colors.disabled : 'danger'; + + const getAction = (selectedCases: Case[]) => { + return { + name: {getDeleteActionTitle(selectedCases.length)}, + onClick: () => openModal(selectedCases), + disabled: isActionDisabled, + 'data-test-subj': 'cases-bulk-action-delete', + icon: , + key: 'cases-bulk-action-delete', + }; + }; + + return { getAction, isModalVisible, onConfirmDeletion, onCloseModal, canDelete }; +}; + +export type UseDeleteAction = ReturnType; diff --git a/x-pack/plugins/cases/public/components/actions/status/translations.ts b/x-pack/plugins/cases/public/components/actions/status/translations.ts new file mode 100644 index 0000000000000..68141c7dc344a --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/status/translations.ts @@ -0,0 +1,67 @@ +/* + * 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 { MARK_CASE_IN_PROGRESS, OPEN_CASE, CLOSE_CASE } from '../../../common/translations'; + +export const CLOSED_CASES = ({ + totalCases, + caseTitle, +}: { + totalCases: number; + caseTitle?: string; +}) => + i18n.translate('xpack.cases.containers.closedCases', { + values: { caseTitle, totalCases }, + defaultMessage: 'Closed {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}', + }); + +export const REOPENED_CASES = ({ + totalCases, + caseTitle, +}: { + totalCases: number; + caseTitle?: string; +}) => + i18n.translate('xpack.cases.containers.reopenedCases', { + values: { caseTitle, totalCases }, + defaultMessage: 'Opened {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}', + }); + +export const MARK_IN_PROGRESS_CASES = ({ + totalCases, + caseTitle, +}: { + totalCases: number; + caseTitle?: string; +}) => + i18n.translate('xpack.cases.containers.markInProgressCases', { + values: { caseTitle, totalCases }, + defaultMessage: + 'Marked {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}} as in progress', + }); + +export const BULK_ACTION_STATUS_CLOSE = i18n.translate( + 'xpack.cases.caseTable.bulkActions.status.close', + { + defaultMessage: 'Close selected', + } +); + +export const BULK_ACTION_STATUS_OPEN = i18n.translate( + 'xpack.cases.caseTable.bulkActions.status.open', + { + defaultMessage: 'Open selected', + } +); + +export const BULK_ACTION_STATUS_IN_PROGRESS = i18n.translate( + 'xpack.cases.caseTable.bulkActions.status.inProgress', + { + defaultMessage: 'Mark in progress', + } +); diff --git a/x-pack/plugins/cases/public/components/actions/status/use_status_action.test.tsx b/x-pack/plugins/cases/public/components/actions/status/use_status_action.test.tsx new file mode 100644 index 0000000000000..a0f16dbaa1760 --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/status/use_status_action.test.tsx @@ -0,0 +1,198 @@ +/* + * 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 { AppMockRenderer, createAppMockRenderer } from '../../../common/mock'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { useStatusAction } from './use_status_action'; + +import * as api from '../../../containers/api'; +import { basicCase } from '../../../containers/mock'; +import { CaseStatuses } from '../../../../common'; + +jest.mock('../../../containers/api'); + +describe('useStatusAction', () => { + let appMockRender: AppMockRenderer; + const onAction = jest.fn(); + const onActionSuccess = jest.fn(); + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders an action', async () => { + const { result } = renderHook( + () => + useStatusAction({ + onAction, + onActionSuccess, + isDisabled: false, + }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current.getActions([basicCase])).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "cases-bulk-action-status-open", + "disabled": true, + "icon": "empty", + "key": "cases-bulk-action-status-open", + "name": "Open", + "onClick": [Function], + }, + Object { + "data-test-subj": "cases-bulk-action-status-in-progress", + "disabled": false, + "icon": "empty", + "key": "cases-bulk-action-status-in-progress", + "name": "In progress", + "onClick": [Function], + }, + Object { + "data-test-subj": "cases-bulk-action-status-closed", + "disabled": false, + "icon": "empty", + "key": "cases-bulk-status-action", + "name": "Closed", + "onClick": [Function], + }, + ] + `); + }); + + it('update the status cases', async () => { + const updateSpy = jest.spyOn(api, 'updateCases'); + + const { result, waitFor } = renderHook( + () => useStatusAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const actions = result.current.getActions([basicCase]); + + for (const [index, status] of [ + CaseStatuses.open, + CaseStatuses['in-progress'], + CaseStatuses.closed, + ].entries()) { + act(() => { + // @ts-expect-error: onClick expects a MouseEvent argument + actions[index]!.onClick(); + }); + + await waitFor(() => { + expect(onAction).toHaveBeenCalled(); + expect(onActionSuccess).toHaveBeenCalled(); + expect(updateSpy).toHaveBeenCalledWith( + [{ status, id: basicCase.id, version: basicCase.version }], + expect.anything() + ); + }); + } + }); + + const singleCaseTests = [ + [CaseStatuses.open, 0, 'Opened "Another horrible breach!!"'], + [CaseStatuses['in-progress'], 1, 'Marked "Another horrible breach!!" as in progress'], + [CaseStatuses.closed, 2, 'Closed "Another horrible breach!!"'], + ]; + + it.each(singleCaseTests)( + 'shows the success toaster correctly when updating the status of the case: %s', + async (_, index, expectedMessage) => { + const { result, waitFor } = renderHook( + () => useStatusAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const actions = result.current.getActions([basicCase]); + + act(() => { + // @ts-expect-error: onClick expects a MouseEvent argument + actions[index]!.onClick(); + }); + + await waitFor(() => { + expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + expectedMessage + ); + }); + } + ); + + const multipleCasesTests: Array<[CaseStatuses, number, string]> = [ + [CaseStatuses.open, 0, 'Opened 2 cases'], + [CaseStatuses['in-progress'], 1, 'Marked 2 cases as in progress'], + [CaseStatuses.closed, 2, 'Closed 2 cases'], + ]; + + it.each(multipleCasesTests)( + 'shows the success toaster correctly when updating the status of the case: %s', + async (_, index, expectedMessage) => { + const { result, waitFor } = renderHook( + () => useStatusAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const actions = result.current.getActions([basicCase, basicCase]); + + act(() => { + // @ts-expect-error: onClick expects a MouseEvent argument + actions[index]!.onClick(); + }); + + await waitFor(() => { + expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + expectedMessage + ); + }); + } + ); + + const disabledTests: Array<[CaseStatuses, number]> = [ + [CaseStatuses.open, 0], + [CaseStatuses['in-progress'], 1], + [CaseStatuses.closed, 2], + ]; + + it.each(disabledTests)('disables the status button correctly: %s', async (status, index) => { + const { result } = renderHook( + () => useStatusAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const actions = result.current.getActions([{ ...basicCase, status }]); + expect(actions[index].disabled).toBe(true); + }); + + it.each(disabledTests)( + 'disables the status button correctly if isDisabled=true: %s', + async (status, index) => { + const { result } = renderHook( + () => useStatusAction({ onAction, onActionSuccess, isDisabled: true }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const actions = result.current.getActions([basicCase]); + expect(actions[index].disabled).toBe(true); + } + ); +}); diff --git a/x-pack/plugins/cases/public/components/actions/status/use_status_action.tsx b/x-pack/plugins/cases/public/components/actions/status/use_status_action.tsx new file mode 100644 index 0000000000000..8f1100aaab90d --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/status/use_status_action.tsx @@ -0,0 +1,107 @@ +/* + * 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 { EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; +import { useUpdateCases } from '../../../containers/use_bulk_update_case'; +import { Case, CaseStatuses } from '../../../../common'; + +import * as i18n from './translations'; +import { UseActionProps } from '../types'; +import { statuses } from '../../status'; +import { useCasesContext } from '../../cases_context/use_cases_context'; + +const getStatusToasterMessage = (status: CaseStatuses, cases: Case[]): string => { + const totalCases = cases.length; + const caseTitle = totalCases === 1 ? cases[0].title : ''; + + if (status === CaseStatuses.open) { + return i18n.REOPENED_CASES({ totalCases, caseTitle }); + } else if (status === CaseStatuses['in-progress']) { + return i18n.MARK_IN_PROGRESS_CASES({ totalCases, caseTitle }); + } else if (status === CaseStatuses.closed) { + return i18n.CLOSED_CASES({ totalCases, caseTitle }); + } + + return ''; +}; + +interface UseStatusActionProps extends UseActionProps { + selectedStatus?: CaseStatuses; +} + +const shouldDisableStatus = (cases: Case[], status: CaseStatuses) => + cases.every((theCase) => theCase.status === status); + +export const useStatusAction = ({ + onAction, + onActionSuccess, + isDisabled, + selectedStatus, +}: UseStatusActionProps) => { + const { mutate: updateCases } = useUpdateCases(); + const { permissions } = useCasesContext(); + const canUpdateStatus = permissions.update; + const isActionDisabled = isDisabled || !canUpdateStatus; + + const handleUpdateCaseStatus = useCallback( + (selectedCases: Case[], status: CaseStatuses) => { + onAction(); + const casesToUpdate = selectedCases.map((theCase) => ({ + status, + id: theCase.id, + version: theCase.version, + })); + + updateCases( + { + cases: casesToUpdate, + successToasterTitle: getStatusToasterMessage(status, selectedCases), + }, + { onSuccess: onActionSuccess } + ); + }, + [onAction, updateCases, onActionSuccess] + ); + + const getStatusIcon = (status: CaseStatuses): string => + selectedStatus && selectedStatus === status ? 'check' : 'empty'; + + const getActions = (selectedCases: Case[]): EuiContextMenuPanelItemDescriptor[] => { + return [ + { + name: statuses[CaseStatuses.open].label, + icon: getStatusIcon(CaseStatuses.open), + onClick: () => handleUpdateCaseStatus(selectedCases, CaseStatuses.open), + disabled: isActionDisabled || shouldDisableStatus(selectedCases, CaseStatuses.open), + 'data-test-subj': 'cases-bulk-action-status-open', + key: 'cases-bulk-action-status-open', + }, + { + name: statuses[CaseStatuses['in-progress']].label, + icon: getStatusIcon(CaseStatuses['in-progress']), + onClick: () => handleUpdateCaseStatus(selectedCases, CaseStatuses['in-progress']), + disabled: + isActionDisabled || shouldDisableStatus(selectedCases, CaseStatuses['in-progress']), + 'data-test-subj': 'cases-bulk-action-status-in-progress', + key: 'cases-bulk-action-status-in-progress', + }, + { + name: statuses[CaseStatuses.closed].label, + icon: getStatusIcon(CaseStatuses.closed), + onClick: () => handleUpdateCaseStatus(selectedCases, CaseStatuses.closed), + disabled: isActionDisabled || shouldDisableStatus(selectedCases, CaseStatuses.closed), + 'data-test-subj': 'cases-bulk-action-status-closed', + key: 'cases-bulk-status-action', + }, + ]; + }; + + return { getActions, canUpdateStatus }; +}; + +export type UseStatusAction = ReturnType; diff --git a/x-pack/plugins/cases/public/components/actions/types.ts b/x-pack/plugins/cases/public/components/actions/types.ts new file mode 100644 index 0000000000000..20a566a20c670 --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/types.ts @@ -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. + */ + +export interface UseActionProps { + onAction: () => void; + onActionSuccess: () => void; + isDisabled: boolean; +} diff --git a/x-pack/plugins/cases/public/components/all_cases/actions.tsx b/x-pack/plugins/cases/public/components/all_cases/actions.tsx deleted file mode 100644 index f978cd1b3f205..0000000000000 --- a/x-pack/plugins/cases/public/components/all_cases/actions.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; - -import { Case } from '../../containers/types'; -import * as i18n from './translations'; - -interface GetActions { - deleteCaseOnClick: (deleteCase: Case) => void; -} - -export const getActions = ({ - deleteCaseOnClick, -}: GetActions): Array> => [ - { - description: i18n.DELETE_CASE(), - icon: 'trash', - color: 'danger', - name: i18n.DELETE_CASE(), - onClick: deleteCaseOnClick, - type: 'icon', - 'data-test-subj': 'action-delete', - }, -]; diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 1cc3a5f0263e5..30a2389b4f307 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -18,17 +18,18 @@ import { AppMockRenderer, createAppMockRenderer, noDeleteCasesPermissions, + readCasesPermissions, TestProviders, } from '../../common/mock'; import { useGetCasesMockState, connectorsMock } from '../../containers/mock'; import { StatusAll } from '../../../common/ui/types'; -import { CaseSeverity, CaseStatuses } from '../../../common/api'; +import { CaseStatuses } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { getEmptyTagValue } from '../empty_value'; import { useKibana } from '../../common/lib/kibana'; import { AllCasesList } from './all_cases_list'; -import { CasesColumns, GetCasesColumn, useCasesColumns } from './columns'; +import { GetCasesColumn, useCasesColumns, UseCasesColumnsReturnValue } from './use_cases_columns'; import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks'; import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors'; import { createStartServicesMock } from '../../common/lib/kibana/kibana_react.mock'; @@ -97,10 +98,6 @@ describe('AllCasesListGeneric', () => { }; const defaultColumnArgs = { - caseDetailsNavigation: { - href: jest.fn(), - onClick: jest.fn(), - }, filterStatus: CaseStatuses.open, handleIsLoading: jest.fn(), isLoadingCases: [], @@ -236,7 +233,7 @@ describe('AllCasesListGeneric', () => { expect(column.find('span').text()).toEqual(emptyTag); }; - const { result } = renderHook( + const { result } = renderHook( () => useCasesColumns(defaultColumnArgs), { wrapper: ({ children }) => {children}, @@ -244,26 +241,12 @@ describe('AllCasesListGeneric', () => { ); await waitFor(() => { - result.current.map( - (i, key) => - i.name != null && - !Object.prototype.hasOwnProperty.call(i, 'actions') && - checkIt(`${i.name}`, key) + result.current.columns.map( + (i, key) => i.name != null && i.name !== 'Actions' && checkIt(`${i.name}`, key) ); }); }); - it('should render delete actions for case', async () => { - const wrapper = mount( - - - - ); - await waitFor(() => { - expect(wrapper.find('[data-test-subj="action-delete"]').first().props().disabled).toBeFalsy(); - }); - }); - it('should tableHeaderSortButton AllCasesList', async () => { const wrapper = mount( @@ -285,27 +268,10 @@ describe('AllCasesListGeneric', () => { }); }); - it('Updates status when status context menu is updated', async () => { - const wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).first().simulate('click'); - wrapper - .find(`[data-test-subj="case-view-status-dropdown-closed"] button`) - .first() - .simulate('click'); + it('renders the status column', async () => { + const res = appMockRenderer.render(); - await waitFor(() => { - const firstCase = useGetCasesMockState.data.cases[0]; - expect(updateCaseProperty).toHaveBeenCalledWith({ - caseData: firstCase, - updateKey: 'status', - updateValue: CaseStatuses.closed, - onSuccess: expect.anything(), - }); - }); + expect(res.getByTestId('tableHeaderCell_Status_6')).toBeInTheDocument(); }); it('should render the case stats', () => { @@ -317,133 +283,6 @@ describe('AllCasesListGeneric', () => { expect(wrapper.find('[data-test-subj="cases-count-stats"]')).toBeTruthy(); }); - it('Bulk delete', async () => { - const deleteCasesSpy = jest.spyOn(api, 'deleteCases'); - const result = appMockRenderer.render(); - - act(() => { - userEvent.click(result.getByTestId('checkboxSelectAll')); - }); - - act(() => { - userEvent.click(result.getByText('Bulk actions')); - }); - - await waitForEuiPopoverOpen(); - - act(() => { - userEvent.click(result.getByTestId('cases-bulk-delete-button'), undefined, { - skipPointerEventsCheck: true, - }); - }); - - await waitFor(() => { - expect(result.getByTestId('confirm-delete-case-modal')).toBeInTheDocument(); - }); - - act(() => { - userEvent.click(result.getByTestId('confirmModalConfirmButton')); - }); - - await waitFor(() => { - expect(deleteCasesSpy).toHaveBeenCalledWith( - [ - 'basic-case-id', - '1', - '2', - '3', - '4', - 'case-with-alerts-id', - 'case-with-alerts-syncoff-id', - 'case-with-registered-attachment', - ], - expect.anything() - ); - }); - }); - - it('Renders only bulk delete on status all', async () => { - const wrapper = mount( - - - - ); - - wrapper.find('[data-test-subj*="checkboxSelectRow-"]').first().simulate('click'); - wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); - - await waitFor(() => { - expect(wrapper.find('[data-test-subj="cases-bulk-open-button"]').exists()).toEqual(false); - expect(wrapper.find('[data-test-subj="cases-bulk-in-progress-button"]').exists()).toEqual( - false - ); - expect(wrapper.find('[data-test-subj="cases-bulk-close-button"]').exists()).toEqual(false); - expect( - wrapper.find('[data-test-subj="cases-bulk-delete-button"]').first().props().disabled - ).toEqual(true); - }); - }); - - it('Bulk close status update', async () => { - const updateCasesSpy = jest.spyOn(api, 'updateCases'); - - const result = appMockRenderer.render(); - const theCase = useGetCasesMockState.data.cases[0]; - userEvent.click(result.getByTestId('case-status-filter')); - await waitForEuiPopoverOpen(); - userEvent.click(result.getByTestId('case-status-filter-in-progress')); - userEvent.click(result.getByTestId(`checkboxSelectRow-${theCase.id}`)); - userEvent.click(result.getByText('Bulk actions')); - await waitForEuiPopoverOpen(); - userEvent.click(result.getByTestId('cases-bulk-close-button')); - await waitFor(() => {}); - - expect(updateCasesSpy).toBeCalledWith( - [{ id: theCase.id, version: theCase.version, status: CaseStatuses.closed }], - expect.anything() - ); - }); - - it('Bulk open status update', async () => { - const updateCasesSpy = jest.spyOn(api, 'updateCases'); - - const result = appMockRenderer.render(); - const theCase = useGetCasesMockState.data.cases[0]; - userEvent.click(result.getByTestId('case-status-filter')); - await waitForEuiPopoverOpen(); - userEvent.click(result.getByTestId('case-status-filter-closed')); - userEvent.click(result.getByTestId(`checkboxSelectRow-${theCase.id}`)); - userEvent.click(result.getByText('Bulk actions')); - await waitForEuiPopoverOpen(); - userEvent.click(result.getByTestId('cases-bulk-open-button')); - await waitFor(() => {}); - - expect(updateCasesSpy).toBeCalledWith( - [{ id: theCase.id, version: theCase.version, status: CaseStatuses.open }], - expect.anything() - ); - }); - - it('Bulk in-progress status update', async () => { - const updateCasesSpy = jest.spyOn(api, 'updateCases'); - - const result = appMockRenderer.render(); - const theCase = useGetCasesMockState.data.cases[0]; - userEvent.click(result.getByTestId('case-status-filter')); - await waitForEuiPopoverOpen(); - userEvent.click(result.getByTestId('case-status-filter-closed')); - userEvent.click(result.getByTestId(`checkboxSelectRow-${theCase.id}`)); - userEvent.click(result.getByText('Bulk actions')); - await waitForEuiPopoverOpen(); - userEvent.click(result.getByTestId('cases-bulk-in-progress-button')); - await waitFor(() => {}); - - expect(updateCasesSpy).toBeCalledWith( - [{ id: theCase.id, version: theCase.version, status: CaseStatuses['in-progress'] }], - expect.anything() - ); - }); - it('should not render table utility bar when isSelectorView=true', async () => { const wrapper = mount( @@ -522,60 +361,21 @@ describe('AllCasesListGeneric', () => { }); it('should call onRowClick when clicking a case with modal=true', async () => { + const theCase = defaultGetCases.data.cases[0]; + const wrapper = mount( ); - wrapper.find('[data-test-subj="cases-table-row-select-1"]').first().simulate('click'); + wrapper + .find(`[data-test-subj="cases-table-row-select-${theCase.id}"]`) + .first() + .simulate('click'); + await waitFor(() => { - expect(onRowClick).toHaveBeenCalledWith({ - assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], - closedAt: null, - closedBy: null, - comments: [], - connector: { fields: null, id: '123', name: 'My Connector', type: '.jira' }, - createdAt: '2020-02-19T23:06:33.798Z', - createdBy: { - email: 'leslie.knope@elastic.co', - fullName: 'Leslie Knope', - username: 'lknope', - }, - description: 'Security banana Issue', - severity: CaseSeverity.LOW, - duration: null, - externalService: { - connectorId: '123', - connectorName: 'connector name', - externalId: 'external_id', - externalTitle: 'external title', - externalUrl: 'basicPush.com', - pushedAt: '2020-02-20T15:02:57.995Z', - pushedBy: { - email: 'leslie.knope@elastic.co', - fullName: 'Leslie Knope', - username: 'lknope', - }, - }, - id: '1', - owner: SECURITY_SOLUTION_OWNER, - status: 'open', - tags: ['coke', 'pepsi'], - title: 'Another horrible breach!!', - totalAlerts: 0, - totalComment: 0, - updatedAt: '2020-02-20T15:02:57.995Z', - updatedBy: { - email: 'leslie.knope@elastic.co', - fullName: 'Leslie Knope', - username: 'lknope', - }, - version: 'WzQ3LDFd', - settings: { - syncAlerts: true, - }, - }); + expect(onRowClick).toHaveBeenCalledWith(theCase); }); }); @@ -591,7 +391,7 @@ describe('AllCasesListGeneric', () => { }); }); - it('should change the status to closed', async () => { + it('should filter by status: closed', async () => { const result = appMockRenderer.render(); userEvent.click(result.getByTestId('case-status-filter')); await waitForEuiPopoverOpen(); @@ -610,7 +410,7 @@ describe('AllCasesListGeneric', () => { }); }); - it('should change the status to in-progress', async () => { + it('should filter by status: in-progress', async () => { const result = appMockRenderer.render(); userEvent.click(result.getByTestId('case-status-filter')); await waitForEuiPopoverOpen(); @@ -629,7 +429,7 @@ describe('AllCasesListGeneric', () => { }); }); - it('should change the status to open', async () => { + it('should filter by status: open', async () => { const result = appMockRenderer.render(); userEvent.click(result.getByTestId('case-status-filter')); await waitForEuiPopoverOpen(); @@ -668,34 +468,7 @@ describe('AllCasesListGeneric', () => { }); }); - it('should not render status when isSelectorView=true', async () => { - const wrapper = mount( - - - - ); - - const { result } = renderHook( - () => - useCasesColumns({ - ...defaultColumnArgs, - isSelectorView: true, - }), - { - wrapper: ({ children }) => {children}, - } - ); - - expect(result.current.find((i) => i.name === 'Status')).toBeFalsy(); - - await waitFor(() => { - expect(wrapper.find('[data-test-subj="cases-table"]').exists()).toBeTruthy(); - }); - - expect(wrapper.find('[data-test-subj="case-view-status-dropdown"]').exists()).toBeFalsy(); - }); - - it.skip('renders the first available status when hiddenStatus is given', async () => { + it('renders the first available status when hiddenStatus is given', async () => { const wrapper = mount( @@ -926,6 +699,249 @@ describe('AllCasesListGeneric', () => { }); }); }); + + describe('Actions', () => { + const updateCasesSpy = jest.spyOn(api, 'updateCases'); + const deleteCasesSpy = jest.spyOn(api, 'deleteCases'); + + describe('Bulk actions', () => { + it('Renders bulk action', async () => { + const result = appMockRenderer.render(); + + act(() => { + userEvent.click(result.getByTestId('checkboxSelectAll')); + }); + + act(() => { + userEvent.click(result.getByText('Bulk actions')); + }); + + await waitForEuiPopoverOpen(); + + expect(result.getByTestId('case-bulk-action-status')).toBeInTheDocument(); + expect(result.getByTestId('cases-bulk-action-delete')).toBeInTheDocument(); + }); + + it.each([[CaseStatuses.open], [CaseStatuses['in-progress']], [CaseStatuses.closed]])( + 'Bulk update status: %s', + async (status) => { + const result = appMockRenderer.render(); + + act(() => { + userEvent.click(result.getByTestId('checkboxSelectAll')); + }); + + act(() => { + userEvent.click(result.getByText('Bulk actions')); + }); + + await waitForEuiPopoverOpen(); + + act(() => { + userEvent.click(result.getByTestId('case-bulk-action-status')); + }); + + await waitFor(() => { + expect(result.getByTestId(`cases-bulk-action-status-${status}`)).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(result.getByTestId(`cases-bulk-action-status-${status}`)); + }); + + await waitForComponentToUpdate(); + + expect(updateCasesSpy).toBeCalledWith( + useGetCasesMockState.data.cases.map(({ id, version }) => ({ + id, + version, + status, + })), + expect.anything() + ); + } + ); + + it('Bulk delete', async () => { + const result = appMockRenderer.render(); + + act(() => { + userEvent.click(result.getByTestId('checkboxSelectAll')); + }); + + act(() => { + userEvent.click(result.getByText('Bulk actions')); + }); + + await waitForEuiPopoverOpen(); + + act(() => { + userEvent.click(result.getByTestId('cases-bulk-action-delete'), undefined, { + skipPointerEventsCheck: true, + }); + }); + + await waitFor(() => { + expect(result.getByTestId('confirm-delete-case-modal')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(result.getByTestId('confirmModalConfirmButton')); + }); + + await waitFor(() => { + expect(deleteCasesSpy).toHaveBeenCalledWith( + [ + 'basic-case-id', + '1', + '2', + '3', + '4', + 'case-with-alerts-id', + 'case-with-alerts-syncoff-id', + 'case-with-registered-attachment', + ], + expect.anything() + ); + }); + }); + + it('should disable the checkboxes when the user has read only permissions', async () => { + appMockRenderer = createAppMockRenderer({ permissions: readCasesPermissions() }); + const res = appMockRenderer.render(); + + expect(res.getByTestId('checkboxSelectAll')).toBeDisabled(); + + await waitFor(() => { + for (const theCase of defaultGetCases.data.cases) { + expect(res.getByTestId(`checkboxSelectRow-${theCase.id}`)).toBeDisabled(); + } + }); + }); + }); + + describe('Row actions', () => { + const statusTests = [ + [CaseStatuses.open], + [CaseStatuses['in-progress']], + [CaseStatuses.closed], + ]; + + it('should render row actions', async () => { + const res = appMockRenderer.render(); + + await waitFor(() => { + for (const theCase of defaultGetCases.data.cases) { + expect(res.getByTestId(`case-action-popover-button-${theCase.id}`)).toBeInTheDocument(); + } + }); + }); + + it.each(statusTests)('update the status of a case: %s', async (status) => { + const res = appMockRenderer.render(); + const openCase = useGetCasesMockState.data.cases[0]; + const inProgressCase = useGetCasesMockState.data.cases[1]; + const theCase = status === CaseStatuses.open ? inProgressCase : openCase; + + await waitFor(() => { + expect(res.getByTestId(`case-action-popover-button-${theCase.id}`)).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId(`case-action-popover-button-${theCase.id}`)); + }); + + await waitFor(() => { + expect(res.getByTestId(`case-action-status-panel-${theCase.id}`)).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId(`case-action-status-panel-${theCase.id}`), undefined, { + skipPointerEventsCheck: true, + }); + }); + + await waitFor(() => { + expect(res.getByTestId(`cases-bulk-action-status-${status}`)).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId(`cases-bulk-action-status-${status}`)); + }); + + await waitFor(() => { + expect(updateCasesSpy).toHaveBeenCalledWith( + [{ id: theCase.id, status, version: theCase.version }], + expect.anything() + ); + }); + }); + + it('should delete a case', async () => { + const res = appMockRenderer.render(); + const theCase = defaultGetCases.data.cases[0]; + + await waitFor(() => { + expect(res.getByTestId(`case-action-popover-button-${theCase.id}`)).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId(`case-action-popover-button-${theCase.id}`)); + }); + + await waitFor(() => { + expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId('cases-bulk-action-delete'), undefined, { + skipPointerEventsCheck: true, + }); + }); + + await waitFor(() => { + expect(res.getByTestId('confirm-delete-case-modal')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId('confirmModalConfirmButton')); + }); + + await waitFor(() => { + expect(deleteCasesSpy).toHaveBeenCalledWith(['basic-case-id'], expect.anything()); + }); + }); + + it('should disable row actions when bulk selecting all cases', async () => { + const res = appMockRenderer.render(); + + act(() => { + userEvent.click(res.getByTestId('checkboxSelectAll')); + }); + + await waitFor(() => { + for (const theCase of defaultGetCases.data.cases) { + expect(res.getByTestId(`case-action-popover-button-${theCase.id}`)).toBeDisabled(); + } + }); + }); + + it('should disable row actions when selecting a case', async () => { + const res = appMockRenderer.render(); + const caseToSelect = defaultGetCases.data.cases[0]; + + act(() => { + userEvent.click(res.getByTestId(`checkboxSelectRow-${caseToSelect.id}`)); + }); + + await waitFor(() => { + for (const theCase of defaultGetCases.data.cases) { + expect(res.getByTestId(`case-action-popover-button-${theCase.id}`)).toBeDisabled(); + } + }); + }); + }); + }); }); describe('Assignees', () => { diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index c7b2d4895f94a..0d2cff95c4919 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -21,7 +21,7 @@ import { import { CaseStatuses, caseStatuses } from '../../../common/api'; import { useAvailableCasesOwners } from '../app/use_available_owners'; -import { useCasesColumns } from './columns'; +import { useCasesColumns } from './use_cases_columns'; import { CasesTableFilters } from './table_filters'; import { EuiBasicTableOnChange } from './types'; @@ -37,7 +37,7 @@ import { } from '../../containers/use_get_cases'; import { useBulkGetUserProfiles } from '../../containers/user_profiles/use_bulk_get_user_profiles'; import { useGetCurrentUserProfile } from '../../containers/user_profiles/use_get_current_user_profile'; -import { getAllPermissionsExceptFrom } from '../../utils/permissions'; +import { getAllPermissionsExceptFrom, isReadOnlyPermissions } from '../../utils/permissions'; import { useIsLoadingCases } from './use_is_loading_cases'; const ProgressLoader = styled(EuiProgress)` @@ -196,13 +196,7 @@ export const AllCasesList = React.memo( [deselectCases, hasOwner, availableSolutions, owner] ); - /** - * At the time of changing this from all to delete the only bulk action we have is to delete. When we add more - * actions we'll need to revisit this to allow more granular checks around the bulk actions. - */ - const showActions = permissions.delete && !isSelectorView; - - const columns = useCasesColumns({ + const { columns } = useCasesColumns({ filterStatus: filterOptions.status ?? StatusAll, userProfiles: userProfiles ?? new Map(), currentUserProfile, @@ -210,6 +204,7 @@ export const AllCasesList = React.memo( connectors, onRowClick, showSolutionColumn: !hasOwner && availableSolutions.length > 1, + disableActions: selectedCases.length > 0, }); const pagination = useMemo( @@ -226,8 +221,9 @@ export const AllCasesList = React.memo( () => ({ onSelectionChange: setSelectedCases, initialSelected: selectedCases, + selectable: () => !isReadOnlyPermissions(permissions), }), - [selectedCases, setSelectedCases] + [permissions, selectedCases] ); const isDataEmpty = useMemo(() => data.total === 0, [data]); @@ -272,7 +268,6 @@ export const AllCasesList = React.memo( ( pagination={pagination} selectedCases={selectedCases} selection={euiBasicTableSelectionProps} - showActions={showActions} sorting={sorting} tableRef={tableRef} tableRowProps={tableRowProps} 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 deleted file mode 100644 index 7dec0d7937913..0000000000000 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { mount } from 'enzyme'; - -import '../../common/mock/match_media'; -import { ExternalServiceColumn } from './columns'; -import { useGetCasesMockState } from '../../containers/mock'; -import { connectors } from '../configure_cases/__mock__'; -import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; - -describe('ExternalServiceColumn ', () => { - let appMockRender: AppMockRenderer; - - beforeEach(() => { - jest.clearAllMocks(); - appMockRender = createAppMockRenderer(); - }); - - 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(); - }); - - it('shows the connectors icon if the user has read access to actions', async () => { - const result = appMockRender.render( - - ); - - expect(result.getByTestId('cases-table-connector-icon')).toBeInTheDocument(); - }); - - it('hides the connectors icon if the user does not have read access to actions', async () => { - appMockRender.coreStart.application.capabilities = { - ...appMockRender.coreStart.application.capabilities, - actions: { save: false, show: false }, - }; - - const result = appMockRender.render( - - ); - - expect(result.queryByTestId('cases-table-connector-icon')).toBe(null); - }); -}); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx index 98c89215aac21..e26831266d65f 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.test.tsx @@ -6,13 +6,14 @@ */ import React from 'react'; -import { mount } from 'enzyme'; import { AllCasesSelectorModal } from '.'; -import { TestProviders } from '../../../common/mock'; -import { AllCasesList } from '../all_cases_list'; +import { AppMockRenderer, createAppMockRenderer } from '../../../common/mock'; +import userEvent from '@testing-library/user-event'; +import { act, waitFor } from '@testing-library/react'; -jest.mock('../all_cases_list', () => ({ AllCasesList: jest.fn().mockReturnValue(<>) })); +jest.mock('../../../containers/api'); +jest.mock('../../../containers/user_profiles/api'); const onRowClick = jest.fn(); const defaultProps = { @@ -20,48 +21,73 @@ const defaultProps = { }; describe('AllCasesSelectorModal', () => { + let appMockRenderer: AppMockRenderer; + beforeEach(() => { jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); }); it('renders', () => { - const wrapper = mount( - - - - ); + const res = appMockRenderer.render(); + + expect(res.getByTestId('all-cases-modal')).toBeInTheDocument(); + }); + + it('Closing modal when pressing the x icon', () => { + const res = appMockRenderer.render(); + + act(() => { + userEvent.click(res.getByLabelText('Closes this modal window')); + }); + + expect(res.queryByTestId('all-cases-modal')).toBeFalsy(); + }); + + it('Closing modal when pressing the cancel button', () => { + const res = appMockRenderer.render(); - expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy(); + act(() => { + userEvent.click(res.getByTestId('all-cases-modal-cancel-button')); + }); + + expect(res.queryByTestId('all-cases-modal')).toBeFalsy(); + }); + + it('should not show bulk actions and row actions on the modal', async () => { + const res = appMockRenderer.render(); + await waitFor(() => { + expect(res.getByTestId('cases-table')).toBeInTheDocument(); + }); + + expect(res.queryByTestId('case-table-bulk-actions-link-icon')).toBeFalsy(); + expect(res.queryByText('Actions')).toBeFalsy(); }); - it('Closing modal calls onCloseCaseModal', () => { - const wrapper = mount( - - - - ); + it('should show the select button', async () => { + const res = appMockRenderer.render(); + await waitFor(() => { + expect(res.getByTestId('cases-table')).toBeInTheDocument(); + }); - wrapper.find('.euiModal__closeIcon').first().simulate('click'); - expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeFalsy(); + expect(res.getAllByTestId(/cases-table-row-select/).length).toBeGreaterThan(0); }); - it('pass the correct props to getAllCases method', () => { - const fullProps = { - ...defaultProps, - hiddenStatuses: [], - }; - - mount( - - - - ); - - expect((AllCasesList as unknown as jest.Mock).mock.calls[0][0]).toEqual( - expect.objectContaining({ - hiddenStatuses: fullProps.hiddenStatuses, - isSelectorView: true, - }) - ); + it('should hide the metrics', async () => { + const res = appMockRenderer.render(); + await waitFor(() => { + expect(res.getByTestId('cases-table')).toBeInTheDocument(); + }); + + expect(res.queryByTestId('cases-metrics-stats')).toBeFalsy(); + }); + + it('should show the create case button', async () => { + const res = appMockRenderer.render(); + await waitFor(() => { + expect(res.getByTestId('cases-table')).toBeInTheDocument(); + }); + + expect(res.getByTestId('cases-table-add-case-filter-bar')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx index fccdf76abd2c4..f5007fcd5a092 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx @@ -68,7 +68,11 @@ export const AllCasesSelectorModal = React.memo( /> - + {i18n.CANCEL} diff --git a/x-pack/plugins/cases/public/components/all_cases/table.tsx b/x-pack/plugins/cases/public/components/all_cases/table.tsx index 208c26e47648c..b85f4ae1826d5 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table.tsx @@ -19,7 +19,7 @@ import styled from 'styled-components'; import { CasesTableUtilityBar } from './utility_bar'; import { LinkButton } from '../links'; -import { Cases, Case, FilterOptions } from '../../../common/ui/types'; +import { Cases, Case } from '../../../common/ui/types'; import * as i18n from './translations'; import { useCreateCaseNavigation } from '../../common/navigation'; import { useCasesContext } from '../cases_context/use_cases_context'; @@ -27,7 +27,6 @@ import { useCasesContext } from '../cases_context/use_cases_context'; interface CasesTableProps { columns: EuiBasicTableProps['columns']; data: Cases; - filterOptions: FilterOptions; goToCreateCase?: () => void; isCasesLoading: boolean; isCommentUpdating: boolean; @@ -37,7 +36,6 @@ interface CasesTableProps { pagination: Pagination; selectedCases: Case[]; selection: EuiTableSelectionType; - showActions: boolean; sorting: EuiBasicTableProps['sorting']; tableRef: MutableRefObject; tableRowProps: EuiBasicTableProps['rowProps']; @@ -51,7 +49,6 @@ const Div = styled.div` export const CasesTable: FunctionComponent = ({ columns, data, - filterOptions, goToCreateCase, isCasesLoading, isCommentUpdating, @@ -61,7 +58,6 @@ export const CasesTable: FunctionComponent = ({ pagination, selectedCases, selection, - showActions, sorting, tableRef, tableRowProps, @@ -88,9 +84,8 @@ export const CasesTable: FunctionComponent = ({ ) : (
@@ -98,7 +93,7 @@ export const CasesTable: FunctionComponent = ({ className={classnames({ isSelectorView })} columns={columns} data-test-subj="cases-table" - isSelectable={showActions} + isSelectable={!isSelectorView} itemId="id" items={data.cases} loading={isCommentUpdating} @@ -128,8 +123,9 @@ export const CasesTable: FunctionComponent = ({ pagination={pagination} ref={tableRef} rowProps={tableRowProps} - selection={showActions ? selection : undefined} + selection={!isSelectorView ? selection : undefined} sorting={sorting} + hasActions={false} />
); diff --git a/x-pack/plugins/cases/public/components/all_cases/translations.ts b/x-pack/plugins/cases/public/components/all_cases/translations.ts index 96a683aee5077..332c0d493101b 100644 --- a/x-pack/plugins/cases/public/components/all_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/all_cases/translations.ts @@ -130,40 +130,3 @@ export const TOTAL_ASSIGNEES_FILTERED = (total: number) => defaultMessage: '{total, plural, one {# assignee} other {# assignees}} filtered', values: { total }, }); - -export const CLOSED_CASES = ({ - totalCases, - caseTitle, -}: { - totalCases: number; - caseTitle?: string; -}) => - i18n.translate('xpack.cases.containers.closedCases', { - values: { caseTitle, totalCases }, - defaultMessage: 'Closed {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}', - }); - -export const REOPENED_CASES = ({ - totalCases, - caseTitle, -}: { - totalCases: number; - caseTitle?: string; -}) => - i18n.translate('xpack.cases.containers.reopenedCases', { - values: { caseTitle, totalCases }, - defaultMessage: 'Opened {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}}', - }); - -export const MARK_IN_PROGRESS_CASES = ({ - totalCases, - caseTitle, -}: { - totalCases: number; - caseTitle?: string; -}) => - i18n.translate('xpack.cases.containers.markInProgressCases', { - values: { caseTitle, totalCases }, - defaultMessage: - 'Marked {totalCases, plural, =1 {"{caseTitle}"} other {{totalCases} cases}} as in progress', - }); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_actions.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_actions.test.tsx new file mode 100644 index 0000000000000..0d8bfab2a7a33 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/use_actions.test.tsx @@ -0,0 +1,275 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/dom'; +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useActions } from './use_actions'; +import { basicCase } from '../../containers/mock'; +import * as api from '../../containers/api'; +import { + AppMockRenderer, + createAppMockRenderer, + noDeleteCasesPermissions, + onlyDeleteCasesPermission, + allCasesPermissions, + readCasesPermissions, +} from '../../common/mock'; + +jest.mock('../../containers/api'); + +describe('useActions', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders column actions', async () => { + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "actions": Object { + "align": "right", + "name": "Actions", + "render": [Function], + }, + } + `); + }); + + it('renders the popover', async () => { + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const res = appMockRender.render(comp); + + expect(res.getByTestId(`case-action-popover-${basicCase.id}`)).toBeInTheDocument(); + }); + + it('open the action popover', async () => { + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const res = appMockRender.render(comp); + + act(() => { + userEvent.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`)); + }); + + await waitFor(() => { + expect(res.getByText('Actions')).toBeInTheDocument(); + expect(res.getByTestId(`case-action-status-panel-${basicCase.id}`)).toBeInTheDocument(); + expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument(); + }); + }); + + it('change the status of the case', async () => { + const updateCasesSpy = jest.spyOn(api, 'updateCases'); + + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const res = appMockRender.render(comp); + + act(() => { + userEvent.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`)); + }); + + await waitFor(() => { + expect(res.getByTestId(`case-action-status-panel-${basicCase.id}`)).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId(`case-action-status-panel-${basicCase.id}`)); + }); + + await waitFor(() => { + expect(res.getByTestId('cases-bulk-action-status-open')).toBeInTheDocument(); + expect(res.getByTestId('cases-bulk-action-status-in-progress')).toBeInTheDocument(); + expect(res.getByTestId('cases-bulk-action-status-closed')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId('cases-bulk-action-status-in-progress')); + }); + + await waitFor(() => { + expect(updateCasesSpy).toHaveBeenCalled(); + }); + }); + + describe('Modals', () => { + it('delete a case', async () => { + const deleteSpy = jest.spyOn(api, 'deleteCases'); + + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const res = appMockRender.render(comp); + + act(() => { + userEvent.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`)); + }); + + await waitFor(() => { + expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId('cases-bulk-action-delete'), undefined, { + skipPointerEventsCheck: true, + }); + }); + + await waitFor(() => { + expect(res.getByTestId('confirm-delete-case-modal')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId('confirmModalConfirmButton')); + }); + + await waitFor(() => { + expect(deleteSpy).toHaveBeenCalled(); + }); + }); + + it('closes the modal', async () => { + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const res = appMockRender.render(comp); + + act(() => { + userEvent.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`)); + }); + + await waitFor(() => { + expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId('cases-bulk-action-delete'), undefined, { + skipPointerEventsCheck: true, + }); + }); + + await waitFor(() => { + expect(res.getByTestId('confirm-delete-case-modal')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId('confirmModalCancelButton'), undefined, { + skipPointerEventsCheck: true, + }); + }); + + expect(res.queryByTestId('confirm-delete-case-modal')).toBeFalsy(); + }); + }); + + describe('Permissions', () => { + it('shows the correct actions with all permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: allCasesPermissions() }); + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const res = appMockRender.render(comp); + + act(() => { + userEvent.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`)); + }); + + await waitFor(() => { + expect(res.getByTestId(`case-action-status-panel-${basicCase.id}`)).toBeInTheDocument(); + expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument(); + expect(res.getByTestId(`actions-separator-${basicCase.id}`)).toBeInTheDocument(); + }); + }); + + it('shows the correct actions with no delete permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: noDeleteCasesPermissions() }); + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const res = appMockRender.render(comp); + + act(() => { + userEvent.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`)); + }); + + await waitFor(() => { + expect(res.getByTestId(`case-action-status-panel-${basicCase.id}`)).toBeInTheDocument(); + expect(res.queryByTestId('cases-bulk-action-delete')).toBeFalsy(); + expect(res.queryByTestId(`actions-separator-${basicCase.id}`)).toBeFalsy(); + }); + }); + + it('shows the correct actions with only delete permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: onlyDeleteCasesPermission() }); + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const res = appMockRender.render(comp); + + act(() => { + userEvent.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`)); + }); + + await waitFor(() => { + expect(res.queryByTestId(`case-action-status-panel-${basicCase.id}`)).toBeFalsy(); + expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument(); + expect(res.queryByTestId(`actions-separator-${basicCase.id}`)).toBeFalsy(); + }); + }); + + it('returns null if the user does not have update or delete permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: readCasesPermissions() }); + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.actions).toBe(null); + }); + + it('disables the action correctly', async () => { + appMockRender = createAppMockRenderer({ permissions: onlyDeleteCasesPermission() }); + const { result } = renderHook(() => useActions({ disableActions: true }), { + wrapper: appMockRender.AppWrapper, + }); + + const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const res = appMockRender.render(comp); + + await waitFor(() => { + expect(res.getByTestId(`case-action-popover-button-${basicCase.id}`)).toBeDisabled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx b/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx new file mode 100644 index 0000000000000..c397047ee98bf --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx @@ -0,0 +1,166 @@ +/* + * 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, { useCallback, useMemo, useState } from 'react'; +import { + EuiButtonIcon, + EuiContextMenu, + EuiContextMenuPanelDescriptor, + EuiContextMenuPanelItemDescriptor, + EuiPopover, + EuiTableComputedColumnType, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Case } from '../../containers/types'; +import { useDeleteAction } from '../actions/delete/use_delete_action'; +import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { useStatusAction } from '../actions/status/use_status_action'; +import { useRefreshCases } from './use_on_refresh_cases'; +import * as i18n from './translations'; +import { statuses } from '../status'; +import { useCasesContext } from '../cases_context/use_cases_context'; + +const ActionColumnComponent: React.FC<{ theCase: Case; disableActions: boolean }> = ({ + theCase, + disableActions, +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const tooglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const refreshCases = useRefreshCases(); + + const deleteAction = useDeleteAction({ + isDisabled: false, + onAction: closePopover, + onActionSuccess: refreshCases, + }); + + const statusAction = useStatusAction({ + isDisabled: false, + onAction: closePopover, + onActionSuccess: refreshCases, + selectedStatus: theCase.status, + }); + + const canDelete = deleteAction.canDelete; + const canUpdate = statusAction.canUpdateStatus; + + const panels = useMemo((): EuiContextMenuPanelDescriptor[] => { + const mainPanelItems: EuiContextMenuPanelItemDescriptor[] = []; + const panelsToBuild: EuiContextMenuPanelDescriptor[] = [ + { id: 0, items: mainPanelItems, title: i18n.ACTIONS }, + ]; + + if (canUpdate) { + mainPanelItems.push({ + name: ( + {statuses[theCase.status]?.label ?? '-'} }} + /> + ), + panel: 1, + disabled: !canUpdate, + key: `case-action-status-panel-${theCase.id}`, + 'data-test-subj': `case-action-status-panel-${theCase.id}`, + }); + } + + /** + * A separator is added if a) there is one item above + * and b) there is an item below. For this to happen the + * user has to have delete and update permissions + */ + if (canUpdate && canDelete) { + mainPanelItems.push({ + isSeparator: true, + key: `actions-separator-${theCase.id}`, + 'data-test-subj': `actions-separator-${theCase.id}`, + }); + } + + if (canDelete) { + mainPanelItems.push(deleteAction.getAction([theCase])); + } + + if (canUpdate) { + panelsToBuild.push({ + id: 1, + title: i18n.STATUS, + items: statusAction.getActions([theCase]), + }); + } + + return panelsToBuild; + }, [canDelete, canUpdate, deleteAction, statusAction, theCase]); + + return ( + <> + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + {deleteAction.isModalVisible ? ( + + ) : null} + + ); +}; + +ActionColumnComponent.displayName = 'ActionColumnComponent'; + +const ActionColumn = React.memo(ActionColumnComponent); + +interface UseBulkActionsReturnValue { + actions: EuiTableComputedColumnType | null; +} + +interface UseBulkActionsProps { + disableActions: boolean; +} + +export const useActions = ({ disableActions }: UseBulkActionsProps): UseBulkActionsReturnValue => { + const { permissions } = useCasesContext(); + const shouldShowActions = permissions.update || permissions.delete; + + return { + actions: shouldShowActions + ? { + name: i18n.ACTIONS, + align: 'right', + render: (theCase: Case) => { + return ( + + ); + }, + } + : null, + }; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx new file mode 100644 index 0000000000000..88d596af41fdf --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx @@ -0,0 +1,334 @@ +/* + * 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 { EuiContextMenu } from '@elastic/eui'; +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; + +import { + allCasesPermissions, + AppMockRenderer, + createAppMockRenderer, + noDeleteCasesPermissions, + onlyDeleteCasesPermission, +} from '../../common/mock'; +import { useBulkActions } from './use_bulk_actions'; +import * as api from '../../containers/api'; +import { basicCase } from '../../containers/mock'; + +jest.mock('../../containers/api'); + +describe('useBulkActions', () => { + let appMockRender: AppMockRenderer; + const onAction = jest.fn(); + const onActionSuccess = jest.fn(); + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + describe('Panels', () => { + it('renders bulk actions', async () => { + const { result } = renderHook( + () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCase] }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "modals": , + "panels": Array [ + Object { + "id": 0, + "items": Array [ + Object { + "data-test-subj": "case-bulk-action-status", + "disabled": false, + "key": "case-bulk-action-status", + "name": "Status", + "panel": 1, + }, + Object { + "data-test-subj": "bulk-actions-separator", + "isSeparator": true, + "key": "bulk-actions-separator", + }, + Object { + "data-test-subj": "cases-bulk-action-delete", + "disabled": false, + "icon": , + "key": "cases-bulk-action-delete", + "name": + Delete case + , + "onClick": [Function], + }, + ], + "title": "Actions", + }, + Object { + "id": 1, + "items": Array [ + Object { + "data-test-subj": "cases-bulk-action-status-open", + "disabled": true, + "icon": "empty", + "key": "cases-bulk-action-status-open", + "name": "Open", + "onClick": [Function], + }, + Object { + "data-test-subj": "cases-bulk-action-status-in-progress", + "disabled": false, + "icon": "empty", + "key": "cases-bulk-action-status-in-progress", + "name": "In progress", + "onClick": [Function], + }, + Object { + "data-test-subj": "cases-bulk-action-status-closed", + "disabled": false, + "icon": "empty", + "key": "cases-bulk-status-action", + "name": "Closed", + "onClick": [Function], + }, + ], + "title": "Status", + }, + ], + } + `); + }); + + it('change the status of cases', async () => { + const updateCasesSpy = jest.spyOn(api, 'updateCases'); + + const { result, waitFor: waitForHook } = renderHook( + () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCase] }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const modals = result.current.modals; + const panels = result.current.panels; + + const res = appMockRender.render( + <> + + {modals} + + ); + + act(() => { + userEvent.click(res.getByTestId('case-bulk-action-status')); + }); + + await waitFor(() => { + expect(res.getByTestId('cases-bulk-action-status-open')).toBeInTheDocument(); + expect(res.getByTestId('cases-bulk-action-status-in-progress')).toBeInTheDocument(); + expect(res.getByTestId('cases-bulk-action-status-closed')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId('cases-bulk-action-status-in-progress')); + }); + + await waitForHook(() => { + expect(updateCasesSpy).toHaveBeenCalled(); + }); + }); + + describe('Modals', () => { + it('delete a case', async () => { + const deleteSpy = jest.spyOn(api, 'deleteCases'); + + const { result, waitFor: waitForHook } = renderHook( + () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCase] }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + let modals = result.current.modals; + const panels = result.current.panels; + + const res = appMockRender.render( + <> + + {modals} + + ); + + act(() => { + userEvent.click(res.getByTestId('cases-bulk-action-delete')); + }); + + modals = result.current.modals; + res.rerender( + <> + + {modals} + + ); + + await waitFor(() => { + expect(res.getByTestId('confirm-delete-case-modal')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId('confirmModalConfirmButton')); + }); + + await waitForHook(() => { + expect(deleteSpy).toHaveBeenCalled(); + }); + }); + + it('closes the modal', async () => { + const { result } = renderHook( + () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCase] }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + let modals = result.current.modals; + const panels = result.current.panels; + + const res = appMockRender.render( + <> + + {modals} + + ); + + act(() => { + userEvent.click(res.getByTestId('cases-bulk-action-delete')); + }); + + modals = result.current.modals; + res.rerender( + <> + + {modals} + + ); + + await waitFor(() => { + expect(res.getByTestId('confirm-delete-case-modal')).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(res.getByTestId('confirmModalCancelButton')); + }); + + modals = result.current.modals; + res.rerender( + <> + + {modals} + + ); + + expect(res.queryByTestId('confirm-delete-case-modal')).toBeFalsy(); + }); + }); + }); + + describe('Permissions', () => { + it('shows the correct actions with all permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: allCasesPermissions() }); + const { result, waitFor: waitForHook } = renderHook( + () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCase] }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const modals = result.current.modals; + const panels = result.current.panels; + + const res = appMockRender.render( + <> + + {modals} + + ); + + await waitForHook(() => { + expect(res.getByTestId('case-bulk-action-status')).toBeInTheDocument(); + expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument(); + expect(res.getByTestId('bulk-actions-separator')).toBeInTheDocument(); + }); + }); + + it('shows the correct actions with no delete permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: noDeleteCasesPermissions() }); + const { result, waitFor: waitForHook } = renderHook( + () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCase] }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const modals = result.current.modals; + const panels = result.current.panels; + + const res = appMockRender.render( + <> + + {modals} + + ); + + await waitForHook(() => { + expect(res.getByTestId('case-bulk-action-status')).toBeInTheDocument(); + expect(res.queryByTestId('cases-bulk-action-delete')).toBeFalsy(); + expect(res.queryByTestId('bulk-actions-separator')).toBeFalsy(); + }); + }); + + it('shows the correct actions with only delete permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: onlyDeleteCasesPermission() }); + const { result, waitFor: waitForHook } = renderHook( + () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCase] }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const modals = result.current.modals; + const panels = result.current.panels; + + const res = appMockRender.render( + <> + + {modals} + + ); + + await waitForHook(() => { + expect(res.queryByTestId('case-bulk-action-status')).toBeFalsy(); + expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument(); + expect(res.queryByTestId('bulk-actions-separator')).toBeFalsy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx new file mode 100644 index 0000000000000..bef085ce6d8a0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx @@ -0,0 +1,108 @@ +/* + * 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 { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { Case } from '../../containers/types'; +import { useDeleteAction } from '../actions/delete/use_delete_action'; +import { useStatusAction } from '../actions/status/use_status_action'; +import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import * as i18n from './translations'; + +interface UseBulkActionsProps { + selectedCases: Case[]; + onAction: () => void; + onActionSuccess: () => void; +} + +interface UseBulkActionsReturnValue { + panels: EuiContextMenuPanelDescriptor[]; + modals: JSX.Element; +} + +export const useBulkActions = ({ + selectedCases, + onAction, + onActionSuccess, +}: UseBulkActionsProps): UseBulkActionsReturnValue => { + const isDisabled = selectedCases.length === 0; + + const deleteAction = useDeleteAction({ + isDisabled, + onAction, + onActionSuccess, + }); + + const statusAction = useStatusAction({ + isDisabled, + onAction, + onActionSuccess, + }); + + const canDelete = deleteAction.canDelete; + const canUpdate = statusAction.canUpdateStatus; + + const panels = useMemo((): EuiContextMenuPanelDescriptor[] => { + const mainPanelItems: EuiContextMenuPanelItemDescriptor[] = []; + const panelsToBuild: EuiContextMenuPanelDescriptor[] = [ + { id: 0, items: mainPanelItems, title: i18n.ACTIONS }, + ]; + + if (canUpdate) { + mainPanelItems.push({ + name: i18n.STATUS, + panel: 1, + disabled: isDisabled, + 'data-test-subj': 'case-bulk-action-status', + key: 'case-bulk-action-status', + }); + } + + /** + * A separator is added if a) there is one item above + * and b) there is an item below. For this to happen the + * user has to have delete and update permissions + */ + if (canUpdate && canDelete) { + mainPanelItems.push({ + isSeparator: true as const, + key: 'bulk-actions-separator', + 'data-test-subj': 'bulk-actions-separator', + }); + } + + if (canDelete) { + mainPanelItems.push(deleteAction.getAction(selectedCases)); + } + + if (canUpdate) { + panelsToBuild.push({ + id: 1, + title: i18n.STATUS, + items: statusAction.getActions(selectedCases), + }); + } + + return panelsToBuild; + }, [canDelete, canUpdate, deleteAction, isDisabled, selectedCases, statusAction]); + + return { + modals: ( + <> + {deleteAction.isModalVisible ? ( + + ) : null} + + ), + panels, + }; +}; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx new file mode 100644 index 0000000000000..85caa0b0348dc --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx @@ -0,0 +1,679 @@ +/* + * 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 { licensingMock } from '@kbn/licensing-plugin/public/mocks'; + +import '../../common/mock/match_media'; +import { ExternalServiceColumn, GetCasesColumn, useCasesColumns } from './use_cases_columns'; +import { useGetCasesMockState } from '../../containers/mock'; +import { connectors } from '../configure_cases/__mock__'; +import { + AppMockRenderer, + createAppMockRenderer, + readCasesPermissions, + TestProviders, +} from '../../common/mock'; +import { renderHook } from '@testing-library/react-hooks'; +import { CaseStatuses } from '../../../common'; +import { userProfilesMap, userProfiles } from '../../containers/user_profiles/api.mock'; + +describe('useCasesColumns ', () => { + let appMockRender: AppMockRenderer; + const useCasesColumnsProps: GetCasesColumn = { + filterStatus: CaseStatuses.open, + userProfiles: userProfilesMap, + currentUserProfile: userProfiles[0], + isSelectorView: false, + showSolutionColumn: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('return all columns correctly', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMockRender = createAppMockRenderer({ license }); + + const { result } = renderHook(() => useCasesColumns(useCasesColumnsProps), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "name": "Name", + "render": [Function], + }, + Object { + "field": "assignees", + "name": "Assignees", + "render": [Function], + }, + Object { + "field": "tags", + "name": "Tags", + "render": [Function], + "truncateText": true, + }, + Object { + "align": "right", + "field": "totalAlerts", + "name": "Alerts", + "render": [Function], + }, + Object { + "align": "right", + "field": "owner", + "name": "Solution", + "render": [Function], + }, + Object { + "align": "right", + "field": "totalComment", + "name": "Comments", + "render": [Function], + }, + Object { + "field": "createdAt", + "name": "Created on", + "render": [Function], + "sortable": true, + }, + Object { + "name": "External Incident", + "render": [Function], + }, + Object { + "name": "Status", + "render": [Function], + }, + Object { + "name": "Severity", + "render": [Function], + }, + Object { + "align": "right", + "name": "Actions", + "render": [Function], + }, + ], + } + `); + }); + + it('does not render the solution columns', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMockRender = createAppMockRenderer({ license }); + + const { result } = renderHook( + () => useCasesColumns({ ...useCasesColumnsProps, showSolutionColumn: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "name": "Name", + "render": [Function], + }, + Object { + "field": "assignees", + "name": "Assignees", + "render": [Function], + }, + Object { + "field": "tags", + "name": "Tags", + "render": [Function], + "truncateText": true, + }, + Object { + "align": "right", + "field": "totalAlerts", + "name": "Alerts", + "render": [Function], + }, + Object { + "align": "right", + "field": "totalComment", + "name": "Comments", + "render": [Function], + }, + Object { + "field": "createdAt", + "name": "Created on", + "render": [Function], + "sortable": true, + }, + Object { + "name": "External Incident", + "render": [Function], + }, + Object { + "name": "Status", + "render": [Function], + }, + Object { + "name": "Severity", + "render": [Function], + }, + Object { + "align": "right", + "name": "Actions", + "render": [Function], + }, + ], + } + `); + }); + + it('does not return the alerts column', async () => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + + appMockRender = createAppMockRenderer({ license, features: { alerts: { enabled: false } } }); + + const { result } = renderHook(() => useCasesColumns(useCasesColumnsProps), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "name": "Name", + "render": [Function], + }, + Object { + "field": "assignees", + "name": "Assignees", + "render": [Function], + }, + Object { + "field": "tags", + "name": "Tags", + "render": [Function], + "truncateText": true, + }, + Object { + "align": "right", + "field": "owner", + "name": "Solution", + "render": [Function], + }, + Object { + "align": "right", + "field": "totalComment", + "name": "Comments", + "render": [Function], + }, + Object { + "field": "createdAt", + "name": "Created on", + "render": [Function], + "sortable": true, + }, + Object { + "name": "External Incident", + "render": [Function], + }, + Object { + "name": "Status", + "render": [Function], + }, + Object { + "name": "Severity", + "render": [Function], + }, + Object { + "align": "right", + "name": "Actions", + "render": [Function], + }, + ], + } + `); + }); + + it('does not return the assignees column', async () => { + const { result } = renderHook(() => useCasesColumns(useCasesColumnsProps), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "name": "Name", + "render": [Function], + }, + Object { + "field": "tags", + "name": "Tags", + "render": [Function], + "truncateText": true, + }, + Object { + "align": "right", + "field": "totalAlerts", + "name": "Alerts", + "render": [Function], + }, + Object { + "align": "right", + "field": "owner", + "name": "Solution", + "render": [Function], + }, + Object { + "align": "right", + "field": "totalComment", + "name": "Comments", + "render": [Function], + }, + Object { + "field": "createdAt", + "name": "Created on", + "render": [Function], + "sortable": true, + }, + Object { + "name": "External Incident", + "render": [Function], + }, + Object { + "name": "Status", + "render": [Function], + }, + Object { + "name": "Severity", + "render": [Function], + }, + Object { + "align": "right", + "name": "Actions", + "render": [Function], + }, + ], + } + `); + }); + + it('shows the closedAt column if the filterStatus=closed', async () => { + appMockRender = createAppMockRenderer(); + + const { result } = renderHook( + () => useCasesColumns({ ...useCasesColumnsProps, filterStatus: CaseStatuses.closed }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "name": "Name", + "render": [Function], + }, + Object { + "field": "tags", + "name": "Tags", + "render": [Function], + "truncateText": true, + }, + Object { + "align": "right", + "field": "totalAlerts", + "name": "Alerts", + "render": [Function], + }, + Object { + "align": "right", + "field": "owner", + "name": "Solution", + "render": [Function], + }, + Object { + "align": "right", + "field": "totalComment", + "name": "Comments", + "render": [Function], + }, + Object { + "field": "closedAt", + "name": "Closed on", + "render": [Function], + "sortable": true, + }, + Object { + "name": "External Incident", + "render": [Function], + }, + Object { + "name": "Status", + "render": [Function], + }, + Object { + "name": "Severity", + "render": [Function], + }, + Object { + "align": "right", + "name": "Actions", + "render": [Function], + }, + ], + } + `); + }); + + it('shows the select button if isSelectorView=true', async () => { + const { result } = renderHook( + () => useCasesColumns({ ...useCasesColumnsProps, isSelectorView: true }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "name": "Name", + "render": [Function], + }, + Object { + "field": "tags", + "name": "Tags", + "render": [Function], + "truncateText": true, + }, + Object { + "align": "right", + "field": "totalAlerts", + "name": "Alerts", + "render": [Function], + }, + Object { + "align": "right", + "field": "owner", + "name": "Solution", + "render": [Function], + }, + Object { + "align": "right", + "field": "totalComment", + "name": "Comments", + "render": [Function], + }, + Object { + "field": "createdAt", + "name": "Created on", + "render": [Function], + "sortable": true, + }, + Object { + "name": "External Incident", + "render": [Function], + }, + Object { + "name": "Status", + "render": [Function], + }, + Object { + "name": "Severity", + "render": [Function], + }, + Object { + "align": "right", + "render": [Function], + }, + ], + } + `); + }); + + it('does not shows the actions if isSelectorView=true', async () => { + const { result } = renderHook( + () => useCasesColumns({ ...useCasesColumnsProps, isSelectorView: true }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "name": "Name", + "render": [Function], + }, + Object { + "field": "tags", + "name": "Tags", + "render": [Function], + "truncateText": true, + }, + Object { + "align": "right", + "field": "totalAlerts", + "name": "Alerts", + "render": [Function], + }, + Object { + "align": "right", + "field": "owner", + "name": "Solution", + "render": [Function], + }, + Object { + "align": "right", + "field": "totalComment", + "name": "Comments", + "render": [Function], + }, + Object { + "field": "createdAt", + "name": "Created on", + "render": [Function], + "sortable": true, + }, + Object { + "name": "External Incident", + "render": [Function], + }, + Object { + "name": "Status", + "render": [Function], + }, + Object { + "name": "Severity", + "render": [Function], + }, + Object { + "align": "right", + "render": [Function], + }, + ], + } + `); + }); + + it('does not shows the actions if the user does not have the right permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: readCasesPermissions() }); + + const { result } = renderHook(() => useCasesColumns(useCasesColumnsProps), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "columns": Array [ + Object { + "name": "Name", + "render": [Function], + }, + Object { + "field": "tags", + "name": "Tags", + "render": [Function], + "truncateText": true, + }, + Object { + "align": "right", + "field": "totalAlerts", + "name": "Alerts", + "render": [Function], + }, + Object { + "align": "right", + "field": "owner", + "name": "Solution", + "render": [Function], + }, + Object { + "align": "right", + "field": "totalComment", + "name": "Comments", + "render": [Function], + }, + Object { + "field": "createdAt", + "name": "Created on", + "render": [Function], + "sortable": true, + }, + Object { + "name": "External Incident", + "render": [Function], + }, + Object { + "name": "Status", + "render": [Function], + }, + Object { + "name": "Severity", + "render": [Function], + }, + ], + } + `); + }); + + describe('ExternalServiceColumn ', () => { + 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(); + }); + + it('shows the connectors icon if the user has read access to actions', async () => { + const result = appMockRender.render( + + ); + + expect(result.getByTestId('cases-table-connector-icon')).toBeInTheDocument(); + }); + + it('hides the connectors icon if the user does not have read access to actions', async () => { + appMockRender.coreStart.application.capabilities = { + ...appMockRender.coreStart.application.capabilities, + actions: { save: false, show: false }, + }; + + const result = appMockRender.render( + + ); + + expect(result.queryByTestId('cases-table-connector-icon')).toBe(null); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx similarity index 51% rename from x-pack/plugins/cases/public/components/all_cases/columns.tsx rename to x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx index 948abdbbcd2f3..b996e219c17e7 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback } from 'react'; import { EuiBadgeGroup, EuiBadge, @@ -24,7 +24,7 @@ import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; import { UserProfileWithAvatar } from '@kbn/user-profile-components'; -import { Case, UpdateByKey } from '../../../common/ui/types'; +import { Case } from '../../../common/ui/types'; import { CaseStatuses, ActionConnector, CaseSeverity } from '../../../common/api'; import { OWNER_INFO } from '../../../common/constants'; import { getEmptyTagValue } from '../empty_value'; @@ -32,26 +32,21 @@ import { FormattedRelativePreferenceDate } from '../formatted_date'; import { CaseDetailsLink } from '../links'; import * as i18n from './translations'; import { ALERTS } from '../../common/translations'; -import { getActions } from './actions'; -import { useDeleteCases } from '../../containers/use_delete_cases'; -import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { useActions } from './use_actions'; import { useApplicationCapabilities, useKibana } from '../../common/lib/kibana'; -import { StatusContextMenu } from '../case_action_bar/status_context_menu'; import { TruncatedText } from '../truncated_text'; import { getConnectorIcon } from '../utils'; import type { CasesOwners } from '../../client/helpers/can_use_cases'; import { severities } from '../severity/config'; -import { useUpdateCase } from '../../containers/use_update_case'; -import { useCasesContext } from '../cases_context/use_cases_context'; import { UserToolTip } from '../user_profiles/user_tooltip'; import { useAssignees } from '../../containers/user_profiles/use_assignees'; import { getUsernameDataTestSubj } from '../user_profiles/data_test_subject'; import { CurrentUserProfile } from '../types'; import { SmallUserAvatar } from '../user_profiles/small_user_avatar'; import { useCasesFeatures } from '../../common/use_cases_features'; -import { useRefreshCases } from './use_on_refresh_cases'; +import { Status } from '../status'; -export type CasesColumns = +type CasesColumns = | EuiTableActionsColumnType | EuiTableComputedColumnType | EuiTableFieldDataColumnType; @@ -107,9 +102,14 @@ export interface GetCasesColumn { isSelectorView: boolean; connectors?: ActionConnector[]; onRowClick?: (theCase: Case) => void; - showSolutionColumn?: boolean; + disableActions?: boolean; +} + +export interface UseCasesColumnsReturnValue { + columns: CasesColumns[]; } + export const useCasesColumns = ({ filterStatus, userProfiles, @@ -118,57 +118,10 @@ export const useCasesColumns = ({ connectors = [], onRowClick, showSolutionColumn, -}: GetCasesColumn): CasesColumns[] => { - const [isModalVisible, setIsModalVisible] = useState(false); - const { mutate: deleteCases } = useDeleteCases(); - const refreshCases = useRefreshCases(); + disableActions = false, +}: GetCasesColumn): UseCasesColumnsReturnValue => { const { isAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures(); - const { permissions } = useCasesContext(); - const [caseToBeDeleted, setCaseToBeDeleted] = useState(); - const { updateCaseProperty, isLoading: isLoadingUpdateCase } = useUpdateCase(); - - const closeModal = useCallback(() => setIsModalVisible(false), []); - const openModal = useCallback(() => setIsModalVisible(true), []); - - const onDeleteAction = useCallback( - (theCase: Case) => { - openModal(); - setCaseToBeDeleted(theCase.id); - }, - [openModal] - ); - - const onConfirmDeletion = useCallback(() => { - closeModal(); - if (caseToBeDeleted) { - deleteCases({ - caseIds: [caseToBeDeleted], - successToasterTitle: i18n.DELETED_CASES(1), - }); - } - }, [caseToBeDeleted, closeModal, deleteCases]); - - const handleDispatchUpdate = useCallback( - ({ updateKey, updateValue, caseData }: UpdateByKey) => { - updateCaseProperty({ - updateKey, - updateValue, - caseData, - onSuccess: () => { - refreshCases(); - }, - }); - }, - [refreshCases, updateCaseProperty] - ); - - const actions = useMemo( - () => - getActions({ - deleteCaseOnClick: onDeleteAction, - }), - [onDeleteAction] - ); + const { actions } = useActions({ disableActions }); const assignCaseAction = useCallback( async (theCase: Case) => { @@ -179,7 +132,7 @@ export const useCasesColumns = ({ [onRowClick] ); - return [ + const columns: CasesColumns[] = [ { name: i18n.NAME, render: (theCase: Case) => { @@ -205,129 +158,134 @@ export const useCasesColumns = ({ return getEmptyTagValue(); }, }, - ...(caseAssignmentAuthorized - ? [ - { - field: 'assignees', - name: i18n.ASSIGNEES, - render: (assignees: Case['assignees']) => ( - - ), - }, - ] - : []), - { - field: 'tags', - name: i18n.TAGS, - render: (tags: Case['tags']) => { - if (tags != null && tags.length > 0) { - const badges = ( - - {tags.map((tag: string, i: number) => ( - - {tag} - - ))} - - ); + ]; + + if (caseAssignmentAuthorized) { + columns.push({ + field: 'assignees', + name: i18n.ASSIGNEES, + render: (assignees: Case['assignees']) => ( + + ), + }); + } + + columns.push({ + field: 'tags', + name: i18n.TAGS, + render: (tags: Case['tags']) => { + if (tags != null && tags.length > 0) { + const badges = ( + + {tags.map((tag: string, i: number) => ( + + {tag} + + ))} + + ); + + return ( + + {badges} + + ); + } + return getEmptyTagValue(); + }, + truncateText: true, + }); + + if (isAlertsEnabled) { + columns.push({ + align: RIGHT_ALIGNMENT, + field: 'totalAlerts', + name: ALERTS, + render: (totalAlerts: Case['totalAlerts']) => + totalAlerts != null + ? renderStringField(`${totalAlerts}`, `case-table-column-alertsCount`) + : getEmptyTagValue(), + }); + } + + if (showSolutionColumn) { + columns.push({ + align: RIGHT_ALIGNMENT, + field: 'owner', + name: i18n.SOLUTION, + render: (caseOwner: CasesOwners) => { + const ownerInfo = OWNER_INFO[caseOwner]; + return ownerInfo ? ( + + ) : ( + getEmptyTagValue() + ); + }, + }); + } + + columns.push({ + align: RIGHT_ALIGNMENT, + field: 'totalComment', + name: i18n.COMMENTS, + render: (totalComment: Case['totalComment']) => + totalComment != null + ? renderStringField(`${totalComment}`, `case-table-column-commentCount`) + : getEmptyTagValue(), + }); + if (filterStatus === CaseStatuses.closed) { + columns.push({ + field: 'closedAt', + name: i18n.CLOSED_ON, + sortable: true, + render: (closedAt: Case['closedAt']) => { + if (closedAt != null) { return ( - - {badges} - + + + ); } return getEmptyTagValue(); }, - truncateText: true, - }, - ...(isAlertsEnabled - ? [ - { - align: RIGHT_ALIGNMENT, - field: 'totalAlerts', - name: ALERTS, - render: (totalAlerts: Case['totalAlerts']) => - totalAlerts != null - ? renderStringField(`${totalAlerts}`, `case-table-column-alertsCount`) - : getEmptyTagValue(), - }, - ] - : []), - ...(showSolutionColumn - ? [ - { - align: RIGHT_ALIGNMENT, - field: 'owner', - name: i18n.SOLUTION, - render: (caseOwner: CasesOwners) => { - const ownerInfo = OWNER_INFO[caseOwner]; - return ownerInfo ? ( - - ) : ( - getEmptyTagValue() - ); - }, - }, - ] - : []), - { - align: RIGHT_ALIGNMENT, - field: 'totalComment', - name: i18n.COMMENTS, - render: (totalComment: Case['totalComment']) => - totalComment != null - ? renderStringField(`${totalComment}`, `case-table-column-commentCount`) - : getEmptyTagValue(), - }, - filterStatus === CaseStatuses.closed - ? { - field: 'closedAt', - name: i18n.CLOSED_ON, - sortable: true, - render: (closedAt: Case['closedAt']) => { - if (closedAt != null) { - return ( - - - - ); - } - return getEmptyTagValue(); - }, + }); + } else { + columns.push({ + field: 'createdAt', + name: i18n.CREATED_ON, + sortable: true, + render: (createdAt: Case['createdAt']) => { + if (createdAt != null) { + return ( + + + + ); } - : { - field: 'createdAt', - name: i18n.CREATED_ON, - sortable: true, - render: (createdAt: Case['createdAt']) => { - if (createdAt != null) { - return ( - - - - ); - } - return getEmptyTagValue(); - }, - }, + return getEmptyTagValue(); + }, + }); + } + + columns.push( { name: i18n.EXTERNAL_INCIDENT, render: (theCase: Case) => { @@ -337,32 +295,16 @@ export const useCasesColumns = ({ return getEmptyTagValue(); }, }, - ...(!isSelectorView - ? [ - { - name: i18n.STATUS, - render: (theCase: Case) => { - if (theCase.status === null || theCase.status === undefined) { - return getEmptyTagValue(); - } - - return ( - - handleDispatchUpdate({ - updateKey: 'status', - updateValue: status, - caseData: theCase, - }) - } - /> - ); - }, - }, - ] - : []), + { + name: i18n.STATUS, + render: (theCase: Case) => { + if (theCase.status === null || theCase.status === undefined) { + return getEmptyTagValue(); + } + + return ; + }, + }, { name: i18n.SEVERITY, render: (theCase: Case) => { @@ -376,52 +318,37 @@ export const useCasesColumns = ({ } return getEmptyTagValue(); }, - }, + } + ); - ...(isSelectorView - ? [ - { - align: RIGHT_ALIGNMENT, - render: (theCase: Case) => { - if (theCase.id != null) { - return ( - { - assignCaseAction(theCase); - }} - size="s" - fill={true} - > - {i18n.SELECT} - - ); - } - return getEmptyTagValue(); - }, - }, - ] - : []), - ...(permissions.delete && !isSelectorView - ? [ - { - name: ( - <> - {i18n.ACTIONS} - {isModalVisible ? ( - - ) : null} - - ), - actions, - }, - ] - : []), - ]; + if (isSelectorView) { + columns.push({ + align: RIGHT_ALIGNMENT, + render: (theCase: Case) => { + if (theCase.id != null) { + return ( + { + assignCaseAction(theCase); + }} + size="s" + fill={true} + > + {i18n.SELECT} + + ); + } + return getEmptyTagValue(); + }, + }); + } + + if (!isSelectorView && actions) { + columns.push(actions); + } + + return { columns }; }; interface Props { diff --git a/x-pack/plugins/cases/public/components/all_cases/utility_bar.test.tsx b/x-pack/plugins/cases/public/components/all_cases/utility_bar.test.tsx new file mode 100644 index 0000000000000..3a8769460656d --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/utility_bar.test.tsx @@ -0,0 +1,119 @@ +/* + * 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 { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { + noCasesPermissions, + onlyDeleteCasesPermission, + AppMockRenderer, + createAppMockRenderer, + writeCasesPermissions, +} from '../../common/mock'; +import { casesQueriesKeys } from '../../containers/constants'; +import { basicCase } from '../../containers/mock'; +import { CasesTableUtilityBar } from './utility_bar'; + +describe('Severity form field', () => { + let appMockRender: AppMockRenderer; + const deselectCases = jest.fn(); + + const props = { + totalCases: 5, + selectedCases: [basicCase], + deselectCases, + }; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + + it('renders', async () => { + const result = appMockRender.render(); + expect(result.getByText('Showing 5 cases')).toBeInTheDocument(); + expect(result.getByText('Selected 1 case')).toBeInTheDocument(); + expect(result.getByTestId('case-table-bulk-actions-link-icon')).toBeInTheDocument(); + expect(result.getByTestId('all-cases-refresh-link-icon')).toBeInTheDocument(); + }); + + it('opens the bulk actions correctly', async () => { + const result = appMockRender.render(); + + act(() => { + userEvent.click(result.getByTestId('case-table-bulk-actions-link-icon')); + }); + + await waitFor(() => { + expect(result.getByTestId('case-table-bulk-actions-context-menu')); + }); + }); + + it('closes the bulk actions correctly', async () => { + const result = appMockRender.render(); + + act(() => { + userEvent.click(result.getByTestId('case-table-bulk-actions-link-icon')); + }); + + await waitFor(() => { + expect(result.getByTestId('case-table-bulk-actions-context-menu')); + }); + + act(() => { + userEvent.click(result.getByTestId('case-table-bulk-actions-link-icon')); + }); + + await waitFor(() => { + expect(result.queryByTestId('case-table-bulk-actions-context-menu')).toBeFalsy(); + }); + }); + + it('refresh correctly', async () => { + const result = appMockRender.render(); + const queryClientSpy = jest.spyOn(appMockRender.queryClient, 'invalidateQueries'); + + act(() => { + userEvent.click(result.getByTestId('all-cases-refresh-link-icon')); + }); + + await waitFor(() => { + expect(deselectCases).toHaveBeenCalled(); + expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.casesList()); + expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.tags()); + expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.userProfiles()); + }); + }); + + it('does not show the bulk actions without update & delete permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: noCasesPermissions() }); + const result = appMockRender.render(); + + expect(result.queryByTestId('case-table-bulk-actions-link-icon')).toBeFalsy(); + }); + + it('does show the bulk actions with only delete permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: onlyDeleteCasesPermission() }); + const result = appMockRender.render(); + + expect(result.getByTestId('case-table-bulk-actions-link-icon')).toBeInTheDocument(); + }); + + it('does show the bulk actions with update permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: writeCasesPermissions() }); + const result = appMockRender.render(); + + expect(result.getByTestId('case-table-bulk-actions-link-icon')).toBeInTheDocument(); + }); + + it('does not show the bulk actions if there are not selected cases', async () => { + const result = appMockRender.render(); + + expect(result.queryByTestId('case-table-bulk-actions-link-icon')).toBeFalsy(); + expect(result.queryByText('Showing 0 cases')).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx index fdfcdc17d472c..415472574f25b 100644 --- a/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx @@ -6,8 +6,7 @@ */ import React, { FunctionComponent, useCallback, useState } from 'react'; -import { EuiContextMenuPanel } from '@elastic/eui'; -import { CaseStatuses } from '../../../common'; +import { EuiContextMenu } from '@elastic/eui'; import { UtilityBar, UtilityBarAction, @@ -16,142 +15,92 @@ import { UtilityBarText, } from '../utility_bar'; import * as i18n from './translations'; -import { Cases, Case, FilterOptions } from '../../../common/ui/types'; -import { getBulkItems } from '../bulk_actions'; -import { useDeleteCases } from '../../containers/use_delete_cases'; -import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; -import { useUpdateCases } from '../../containers/use_bulk_update_case'; +import { Case } from '../../../common/ui/types'; import { useRefreshCases } from './use_on_refresh_cases'; +import { UtilityBarBulkActions } from '../utility_bar/utility_bar_bulk_actions'; +import { useBulkActions } from './use_bulk_actions'; +import { useCasesContext } from '../cases_context/use_cases_context'; interface Props { - data: Cases; - enableBulkActions: boolean; - filterOptions: FilterOptions; + isSelectorView?: boolean; + totalCases: number; selectedCases: Case[]; deselectCases: () => void; } -export const getStatusToasterMessage = (status: CaseStatuses, cases: Case[]): string => { - const totalCases = cases.length; - const caseTitle = totalCases === 1 ? cases[0].title : ''; +export const CasesTableUtilityBar: FunctionComponent = React.memo( + ({ isSelectorView, totalCases, selectedCases, deselectCases }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const refreshCases = useRefreshCases(); + const { permissions } = useCasesContext(); - if (status === CaseStatuses.open) { - return i18n.REOPENED_CASES({ totalCases, caseTitle }); - } else if (status === CaseStatuses['in-progress']) { - return i18n.MARK_IN_PROGRESS_CASES({ totalCases, caseTitle }); - } else if (status === CaseStatuses.closed) { - return i18n.CLOSED_CASES({ totalCases, caseTitle }); - } - - return ''; -}; - -export const CasesTableUtilityBar: FunctionComponent = ({ - data, - enableBulkActions = false, - filterOptions, - selectedCases, - deselectCases, -}) => { - const [isModalVisible, setIsModalVisible] = useState(false); - const onCloseModal = useCallback(() => setIsModalVisible(false), []); - const refreshCases = useRefreshCases(); - - const { mutate: deleteCases } = useDeleteCases(); - const { mutate: updateCases } = useUpdateCases(); - - const toggleBulkDeleteModal = useCallback((cases: Case[]) => { - setIsModalVisible(true); - }, []); - - const handleUpdateCaseStatus = useCallback( - (status: CaseStatuses) => { - const casesToUpdate = selectedCases.map((theCase) => ({ - status, - id: theCase.id, - version: theCase.version, - })); + const onRefresh = useCallback(() => { + deselectCases(); + refreshCases(); + }, [deselectCases, refreshCases]); - updateCases({ - cases: casesToUpdate, - successToasterTitle: getStatusToasterMessage(status, selectedCases), - }); - }, - [selectedCases, updateCases] - ); - - const getBulkItemsPopoverContent = useCallback( - (closePopover: () => void) => ( - - ), - [selectedCases, filterOptions.status, toggleBulkDeleteModal, handleUpdateCaseStatus] - ); - - const onConfirmDeletion = useCallback(() => { - setIsModalVisible(false); - deleteCases({ - caseIds: selectedCases.map(({ id }) => id), - successToasterTitle: i18n.DELETED_CASES(selectedCases.length), + const { panels, modals } = useBulkActions({ + selectedCases, + onAction: closePopover, + onActionSuccess: onRefresh, }); - }, [deleteCases, selectedCases]); - const onRefresh = useCallback(() => { - deselectCases(); - refreshCases(); - }, [deselectCases, refreshCases]); + /** + * At least update or delete permissions needed to show bulk actions. + * Granular permission check for each action is performed + * in the useBulkActions hook. + */ + const showBulkActions = (permissions.update || permissions.delete) && selectedCases.length > 0; - return ( - - - - - {i18n.SHOWING_CASES(data.total ?? 0)} - - - - {enableBulkActions && ( - <> - - {i18n.SHOWING_SELECTED_CASES(selectedCases.length)} + return ( + <> + + + + + {i18n.SHOWING_CASES(totalCases)} - + + + {!isSelectorView && showBulkActions && ( + <> + + {i18n.SHOWING_SELECTED_CASES(selectedCases.length)} + + + + + + )} - {i18n.BULK_ACTIONS} + {i18n.REFRESH} - - )} - - {i18n.REFRESH} - - - - {isModalVisible ? ( - - ) : null} - - ); -}; + + + + {modals} + + ); + } +); + CasesTableUtilityBar.displayName = 'CasesTableUtilityBar'; diff --git a/x-pack/plugins/cases/public/components/bulk_actions/index.tsx b/x-pack/plugins/cases/public/components/bulk_actions/index.tsx deleted file mode 100644 index fcf2002f8882c..0000000000000 --- a/x-pack/plugins/cases/public/components/bulk_actions/index.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiContextMenuItem } from '@elastic/eui'; - -import { CaseStatusWithAllStatus } from '../../../common/ui/types'; -import { CaseStatuses } from '../../../common/api'; -import { statuses } from '../status'; -import * as i18n from './translations'; -import { Case } from '../../containers/types'; - -interface GetBulkItems { - caseStatus: CaseStatusWithAllStatus; - closePopover: () => void; - deleteCasesAction: (cases: Case[]) => void; - selectedCases: Case[]; - updateCaseStatus: (status: CaseStatuses) => void; -} - -export const getBulkItems = ({ - caseStatus, - closePopover, - deleteCasesAction, - selectedCases, - updateCaseStatus, -}: GetBulkItems) => { - let statusMenuItems: JSX.Element[] = []; - - const openMenuItem = ( - { - closePopover(); - updateCaseStatus(CaseStatuses.open); - }} - > - {statuses[CaseStatuses.open].actions.bulk.title} - - ); - - const inProgressMenuItem = ( - { - closePopover(); - updateCaseStatus(CaseStatuses['in-progress']); - }} - > - {statuses[CaseStatuses['in-progress']].actions.bulk.title} - - ); - - const closeMenuItem = ( - { - closePopover(); - updateCaseStatus(CaseStatuses.closed); - }} - > - {statuses[CaseStatuses.closed].actions.bulk.title} - - ); - - switch (caseStatus) { - case CaseStatuses.open: - statusMenuItems = [inProgressMenuItem, closeMenuItem]; - break; - - case CaseStatuses['in-progress']: - statusMenuItems = [openMenuItem, closeMenuItem]; - break; - - case CaseStatuses.closed: - statusMenuItems = [openMenuItem, inProgressMenuItem]; - break; - - default: - break; - } - - return [ - ...statusMenuItems, - { - closePopover(); - deleteCasesAction(selectedCases); - }} - > - {i18n.BULK_ACTION_DELETE_SELECTED} - , - ]; -}; diff --git a/x-pack/plugins/cases/public/components/link_icon/index.tsx b/x-pack/plugins/cases/public/components/link_icon/index.tsx index b33529399db90..6285eceed0dd4 100644 --- a/x-pack/plugins/cases/public/components/link_icon/index.tsx +++ b/x-pack/plugins/cases/public/components/link_icon/index.tsx @@ -79,6 +79,7 @@ export const LinkIcon = React.memo( } return theChild != null && Object.keys(theChild).length > 0 ? (theChild as string) : ''; }, []); + const aria = useMemo(() => { if (ariaLabel) { return ariaLabel; diff --git a/x-pack/plugins/cases/public/components/status/config.ts b/x-pack/plugins/cases/public/components/status/config.ts index 6c5ff18ad977a..520759991605b 100644 --- a/x-pack/plugins/cases/public/components/status/config.ts +++ b/x-pack/plugins/cases/public/components/status/config.ts @@ -19,9 +19,6 @@ export const statuses: Statuses = { label: i18n.OPEN, icon: 'folderOpen' as const, actions: { - bulk: { - title: i18n.BULK_ACTION_OPEN_SELECTED, - }, single: { title: i18n.OPEN_CASE, }, @@ -41,9 +38,6 @@ export const statuses: Statuses = { label: i18n.IN_PROGRESS, icon: 'folderExclamation' as const, actions: { - bulk: { - title: i18n.BULK_ACTION_MARK_IN_PROGRESS, - }, single: { title: i18n.MARK_CASE_IN_PROGRESS, }, @@ -63,9 +57,6 @@ export const statuses: Statuses = { label: i18n.CLOSED, icon: 'folderCheck' as const, actions: { - bulk: { - title: i18n.BULK_ACTION_CLOSE_SELECTED, - }, single: { title: i18n.CLOSE_CASE, }, diff --git a/x-pack/plugins/cases/public/components/status/translations.ts b/x-pack/plugins/cases/public/components/status/translations.ts index 4fe75bbcfac7a..9401209c51c08 100644 --- a/x-pack/plugins/cases/public/components/status/translations.ts +++ b/x-pack/plugins/cases/public/components/status/translations.ts @@ -40,30 +40,9 @@ export const CASE_CLOSED = i18n.translate('xpack.cases.caseView.caseClosed', { defaultMessage: 'Case closed', }); -export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( - 'xpack.cases.caseTable.bulkActions.closeSelectedTitle', - { - defaultMessage: 'Close selected', - } -); - -export const BULK_ACTION_OPEN_SELECTED = i18n.translate( - 'xpack.cases.caseTable.bulkActions.openSelectedTitle', - { - defaultMessage: 'Open selected', - } -); - export const BULK_ACTION_DELETE_SELECTED = i18n.translate( 'xpack.cases.caseTable.bulkActions.deleteSelectedTitle', { defaultMessage: 'Delete selected', } ); - -export const BULK_ACTION_MARK_IN_PROGRESS = i18n.translate( - 'xpack.cases.caseTable.bulkActions.markInProgressTitle', - { - defaultMessage: 'Mark in progress', - } -); diff --git a/x-pack/plugins/cases/public/components/status/types.ts b/x-pack/plugins/cases/public/components/status/types.ts index 0b4a1184633e1..1df8eb781ecc0 100644 --- a/x-pack/plugins/cases/public/components/status/types.ts +++ b/x-pack/plugins/cases/public/components/status/types.ts @@ -18,9 +18,6 @@ export type Statuses = Record< label: string; icon: EuiIconType; actions: { - bulk: { - title: string; - }; single: { title: string; description?: string; diff --git a/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar.test.tsx.snap b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar.test.tsx.snap index f082dc4023e7a..83c8a16ea0290 100644 --- a/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar.test.tsx.snap +++ b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar.test.tsx.snap @@ -11,7 +11,6 @@ exports[`UtilityBar it renders 1`] = ` Test action diff --git a/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_action.test.tsx.snap b/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_action.test.tsx.snap deleted file mode 100644 index eb20ac217b300..0000000000000 --- a/x-pack/plugins/cases/public/components/utility_bar/__snapshots__/utility_bar_action.test.tsx.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`UtilityBarAction it renders 1`] = ` - - Test action - -`; diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar.test.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar.test.tsx index 62988a7a9dd76..52486e32905db 100644 --- a/x-pack/plugins/cases/public/components/utility_bar/utility_bar.test.tsx +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar.test.tsx @@ -29,9 +29,7 @@ describe('UtilityBar', () => { -

{'Test popover'}

}> - {'Test action'} -
+ {'Test action'}
@@ -57,9 +55,7 @@ describe('UtilityBar', () => { -

{'Test popover'}

}> - {'Test action'} -
+ {'Test action'}
@@ -87,9 +83,7 @@ describe('UtilityBar', () => { -

{'Test popover'}

}> - {'Test action'} -
+ {'Test action'}
diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.test.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.test.tsx index 88977fa9bc587..881f4e922bcab 100644 --- a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.test.tsx +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.test.tsx @@ -5,32 +5,38 @@ * 2.0. */ -import { mount, shallow } from 'enzyme'; import React from 'react'; -import { TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; import { UtilityBarAction } from '.'; describe('UtilityBarAction', () => { + let appMockRenderer: AppMockRenderer; + const dataTestSubj = 'test-bar-action'; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + test('it renders', () => { - const wrapper = shallow( - - {'Test action'} - + const res = appMockRenderer.render( + + {'Test action'} + ); - expect(wrapper.find('UtilityBarAction')).toMatchSnapshot(); + expect(res.getByTestId(dataTestSubj)).toBeInTheDocument(); + expect(res.getByText('Test action')).toBeInTheDocument(); }); test('it renders a popover', () => { - const wrapper = mount( - -

{'Test popover'}

}> - {'Test action'} -
-
+ const res = appMockRenderer.render( + + {'Test action'} + ); - expect(wrapper.find('.euiPopover').first().exists()).toBe(true); + expect(res.getByTestId(`${dataTestSubj}-link-icon`)).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.tsx index e5bed87021491..b0748f1dd7c9f 100644 --- a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.tsx +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.tsx @@ -5,79 +5,19 @@ * 2.0. */ -import { EuiPopover } from '@elastic/eui'; -import React, { useCallback, useState } from 'react'; +import React from 'react'; import { LinkIcon, LinkIconProps } from '../link_icon'; import { BarAction } from './styles'; -const Popover = React.memo( - ({ children, color, iconSide, iconSize, iconType, popoverContent, disabled, ownFocus }) => { - const [popoverState, setPopoverState] = useState(false); - - const closePopover = useCallback(() => setPopoverState(false), [setPopoverState]); - - return ( - setPopoverState(!popoverState)} - disabled={disabled} - > - {children} - - } - closePopover={() => setPopoverState(false)} - isOpen={popoverState} - repositionOnScroll - > - {popoverContent?.(closePopover)} - - ); - } -); - -Popover.displayName = 'Popover'; - export interface UtilityBarActionProps extends LinkIconProps { - popoverContent?: (closePopover: () => void) => React.ReactNode; - ownFocus?: boolean; dataTestSubj?: string; } export const UtilityBarAction = React.memo( - ({ - dataTestSubj, - children, - color, - disabled, - href, - iconSide, - iconSize, - iconType, - ownFocus, - onClick, - popoverContent, - }) => ( - - {popoverContent ? ( - - {children} - - ) : ( + ({ dataTestSubj, children, color, disabled, href, iconSide, iconSize, iconType, onClick }) => { + return ( + ( iconSize={iconSize} iconType={iconType} onClick={onClick} + dataTestSubj={dataTestSubj ? `${dataTestSubj}-link-icon` : 'utility-bar-action-link-icon'} > {children} - )} - - ) + + ); + } ); UtilityBarAction.displayName = 'UtilityBarAction'; diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_bulk_actions.test.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_bulk_actions.test.tsx new file mode 100644 index 0000000000000..fa3372cf52331 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_bulk_actions.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; +import { UtilityBarBulkActions } from './utility_bar_bulk_actions'; + +describe('UtilityBarBulkActions', () => { + let appMockRenderer: AppMockRenderer; + const closePopover = jest.fn(); + const onButtonClick = jest.fn(); + const dataTestSubj = 'test-bar-action'; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders', () => { + const res = appMockRenderer.render( + + + {'Test bulk actions'} + + + ); + + expect(res.getByTestId(dataTestSubj)).toBeInTheDocument(); + expect(res.getByText('button title')).toBeInTheDocument(); + }); + + it('renders a popover', async () => { + const res = appMockRenderer.render( + + + {'Test bulk actions'} + + + ); + + expect(res.getByText('Test bulk actions')).toBeInTheDocument(); + }); + + it('calls onButtonClick', async () => { + const res = appMockRenderer.render( + + + {'Test bulk actions'} + + + ); + + expect(res.getByText('Test bulk actions')).toBeInTheDocument(); + + act(() => { + userEvent.click(res.getByText('button title')); + }); + + expect(onButtonClick).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_bulk_actions.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_bulk_actions.tsx new file mode 100644 index 0000000000000..afeb93cc221ea --- /dev/null +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_bulk_actions.tsx @@ -0,0 +1,67 @@ +/* + * 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 { EuiPopover } from '@elastic/eui'; +import React from 'react'; +import { LinkIcon, LinkIconProps } from '../link_icon'; + +import { BarAction } from './styles'; + +export interface UtilityBarActionProps extends Omit { + isPopoverOpen: boolean; + buttonTitle: string; + closePopover: () => void; + onButtonClick: () => void; + dataTestSubj?: string; +} + +export const UtilityBarBulkActions = React.memo( + ({ + dataTestSubj, + children, + color, + disabled, + href, + iconSide, + iconSize, + iconType, + isPopoverOpen, + onButtonClick, + buttonTitle, + closePopover, + }) => { + return ( + + + {buttonTitle} + + } + > + {children} + + + ); + } +); + +UtilityBarBulkActions.displayName = 'UtilityBarBulkActions'; diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 2c0ee9bdc2b03..7f90187cd6075 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -399,7 +399,7 @@ const basicAction = { export const cases: Case[] = [ basicCase, - { ...pushedCase, id: '1', totalComment: 0, comments: [] }, + { ...pushedCase, id: '1', totalComment: 0, comments: [], status: CaseStatuses['in-progress'] }, { ...pushedCase, updatedAt: laterTime, id: '2', totalComment: 0, comments: [] }, { ...basicCase, id: '3', totalComment: 0, comments: [] }, { ...basicCase, id: '4', totalComment: 0, comments: [] }, @@ -557,7 +557,13 @@ export const pushedCaseSnake = { export const casesSnake: CasesResponse = [ basicCaseSnake, - { ...pushedCaseSnake, id: '1', totalComment: 0, comments: [] }, + { + ...pushedCaseSnake, + id: '1', + totalComment: 0, + comments: [], + status: CaseStatuses['in-progress'], + }, { ...pushedCaseSnake, updated_at: laterTime, id: '2', totalComment: 0, comments: [] }, { ...basicCaseSnake, id: '3', totalComment: 0, comments: [] }, { ...basicCaseSnake, id: '4', totalComment: 0, comments: [] }, diff --git a/x-pack/plugins/cases/server/client/cases/types.ts b/x-pack/plugins/cases/server/client/cases/types.ts index c14cc66210614..6d56fa28dca59 100644 --- a/x-pack/plugins/cases/server/client/cases/types.ts +++ b/x-pack/plugins/cases/server/client/cases/types.ts @@ -17,7 +17,7 @@ import { PushToServiceApiParamsITSM as ServiceNowITSMPushToServiceApiParams, PushToServiceApiParamsSIR as ServiceNowSIRPushToServiceApiParams, ServiceNowITSMIncident, -} from '@kbn/stack-connectors-plugin/server/connector_types/cases/servicenow/types'; +} from '@kbn/stack-connectors-plugin/server/connector_types/lib/servicenow/types'; import { UserProfile } from '@kbn/security-plugin/common'; import { CaseResponse, ConnectorMappingsAttributes } from '../../../common/api'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines_card.tsx index c7a3872ce6e3e..9202e6690408c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ingest_pipelines_card.tsx @@ -85,7 +85,7 @@ export const IngestPipelinesCard: React.FC = () => { -

{pipelineState.name}

+

{pipelineName}

diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx index bd895dcf45704..99be659cbac36 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx @@ -22,11 +22,29 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../../shared/doc_links'; import { MLInferenceLogic } from './ml_inference_logic'; +const NoSourceFieldsError: React.FC = () => ( + + {i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.sourceField.error.docLink', + { defaultMessage: 'Learn more about field mapping' } + )} + + ), + }} + /> +); + export const ConfigurePipeline: React.FC = () => { const { addInferencePipelineModal: { configuration }, @@ -39,6 +57,7 @@ export const ConfigurePipeline: React.FC = () => { const { destinationField, modelID, pipelineName, sourceField } = configuration; const models = supportedMLModels ?? []; const nameError = formErrors.pipelineName !== undefined && pipelineName.length > 0; + const emptySourceFields = (sourceFields?.length ?? 0) === 0; return ( <> @@ -143,6 +162,8 @@ export const ConfigurePipeline: React.FC = () => { defaultMessage: 'Source field', } )} + error={emptySourceFields && } + isInvalid={emptySourceFields} > HttpError; @@ -76,6 +83,7 @@ interface MLInferenceProcessorsValues { formErrors: AddInferencePipelineFormErrors; isLoading: boolean; isPipelineDataValid: boolean; + index: FetchIndexApiResponse; mappingData: typeof MappingsApiLogic.values.data; mappingStatus: Status; mlInferencePipeline?: MlInferencePipeline; @@ -113,6 +121,8 @@ export const MLInferenceLogic = kea< ], ], values: [ + FetchIndexApiLogic, + ['data as index'], MappingsApiLogic, ['data as mappingData', 'status as mappingStatus'], MLModelsApiLogic, @@ -210,10 +220,19 @@ export const MLInferenceLogic = kea< }, ], sourceFields: [ - () => [selectors.mappingStatus, selectors.mappingData], - (status: Status, mapping: IndicesGetMappingIndexMappingRecord) => { + () => [selectors.mappingStatus, selectors.mappingData, selectors.index], + ( + status: Status, + mapping: IndicesGetMappingIndexMappingRecord, + index: FetchIndexApiResponse + ) => { if (status !== Status.SUCCESS) return; - if (mapping?.mappings?.properties === undefined) return []; + if (mapping?.mappings?.properties === undefined) { + if (isConnectorIndex(index)) { + return DEFAULT_CONNECTOR_FIELDS; + } + return []; + } return Object.entries(mapping.mappings.properties) .reduce((fields, [key, value]) => { if (value.type === 'text' || value.type === 'keyword') { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines.tsx index f695b7c541c5a..18190cef67ca3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines.tsx @@ -33,12 +33,8 @@ import { PipelinesJSONConfigurations } from './pipelines_json_configurations'; import { PipelinesLogic } from './pipelines_logic'; export const SearchIndexPipelines: React.FC = () => { - const { - showAddMlInferencePipelineModal, - hasIndexIngestionPipeline, - index, - pipelineState: { name: pipelineName }, - } = useValues(PipelinesLogic); + const { showAddMlInferencePipelineModal, hasIndexIngestionPipeline, index, pipelineName } = + useValues(PipelinesLogic); const { closeAddMlInferencePipelineModal, openAddMlInferencePipelineModal } = useActions(PipelinesLogic); const apiIndex = isApiIndex(index); @@ -133,7 +129,7 @@ export const SearchIndexPipelines: React.FC = () => { 'xpack.enterpriseSearch.content.indices.pipelines.mlInferencePipelines.subtitleAPIindex', { defaultMessage: - "Inference pipelines will be run as processors from the Enterprise Search Ingest Pipeline. In order to use these pipeline on API-based indices you'll need to reference the {pipelineName} pipeline in your API requests.", + "Inference pipelines will be run as processors from the Enterprise Search Ingest Pipeline. In order to use these pipelines on API-based indices you'll need to reference the {pipelineName} pipeline in your API requests.", values: { pipelineName, }, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.test.ts index b847b2fdc6b8c..ff3b779d61e29 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.test.ts @@ -4,11 +4,13 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { LogicMounter, mockFlashMessageHelpers } from '../../../../__mocks__/kea_logic'; -import { connectorIndex } from '../../../__mocks__/view_index.mock'; +import { apiIndex, connectorIndex } from '../../../__mocks__/view_index.mock'; + +import { IngestPipeline } from '@elastic/elasticsearch/lib/api/types'; import { UpdatePipelineApiLogic } from '../../../api/connector/update_pipeline_api_logic'; +import { FetchCustomPipelineApiLogic } from '../../../api/index/fetch_custom_pipeline_api_logic'; import { FetchIndexApiLogic } from '../../../api/index/fetch_index_api_logic'; import { PipelinesLogic } from './pipelines_logic'; @@ -40,6 +42,7 @@ describe('PipelinesLogic', () => { const { mount } = new LogicMounter(PipelinesLogic); const { mount: mountFetchIndexApiLogic } = new LogicMounter(FetchIndexApiLogic); const { mount: mountUpdatePipelineLogic } = new LogicMounter(UpdatePipelineApiLogic); + const { mount: mountFetchCustomPipelineApiLogic } = new LogicMounter(FetchCustomPipelineApiLogic); const { clearFlashMessages, flashAPIErrors, flashSuccessToast } = mockFlashMessageHelpers; const newPipeline = { @@ -51,6 +54,7 @@ describe('PipelinesLogic', () => { beforeEach(() => { jest.clearAllMocks(); mountFetchIndexApiLogic(); + mountFetchCustomPipelineApiLogic(); mountUpdatePipelineLogic(); mount(); }); @@ -195,5 +199,41 @@ describe('PipelinesLogic', () => { }); }); }); + describe('fetchCustomPipelineSuccess', () => { + it('should support api indices with custom ingest pipelines', () => { + PipelinesLogic.actions.fetchIndexApiSuccess({ + ...apiIndex, + }); + const indexName = apiIndex.name; + const indexPipelines: Record = { + [indexName]: { + processors: [], + version: 1, + }, + [`${indexName}@custom`]: { + processors: [], + version: 1, + }, + [`${indexName}@ml-inference`]: { + processors: [], + version: 1, + }, + }; + PipelinesLogic.actions.fetchCustomPipelineSuccess(indexPipelines); + + expect(PipelinesLogic.values).toEqual({ + ...DEFAULT_VALUES, + customPipelineData: indexPipelines, + index: { + ...apiIndex, + }, + indexName, + pipelineName: indexName, + canSetPipeline: false, + hasIndexIngestionPipeline: true, + canUseMlInferencePipeline: true, + }); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts index 952c5baf77553..dca18863cde02 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/pipelines_logic.ts @@ -90,6 +90,10 @@ type PipelinesActions = Pick< FetchCustomPipelineApiLogicArgs, FetchCustomPipelineApiLogicResponse >['makeRequest']; + fetchCustomPipelineSuccess: Actions< + FetchCustomPipelineApiLogicArgs, + FetchCustomPipelineApiLogicResponse + >['apiSuccess']; fetchDefaultPipeline: Actions['makeRequest']; fetchDefaultPipelineSuccess: Actions['apiSuccess']; fetchIndexApiSuccess: Actions['apiSuccess']; @@ -143,7 +147,7 @@ export const PipelinesLogic = kea !isApiIndex(index), ], canUseMlInferencePipeline: [ - () => [ - selectors.canSetPipeline, - selectors.hasIndexIngestionPipeline, - selectors.pipelineState, - ], + () => [selectors.hasIndexIngestionPipeline, selectors.pipelineState, selectors.index], ( - canSetPipeline: boolean, hasIndexIngestionPipeline: boolean, - pipelineState: IngestPipelineParams - ) => canSetPipeline && hasIndexIngestionPipeline && pipelineState.run_ml_inference, + pipelineState: IngestPipelineParams, + index: ElasticsearchIndexWithIngestion + ) => hasIndexIngestionPipeline && (pipelineState.run_ml_inference || isApiIndex(index)), ], defaultPipelineValues: [ () => [selectors.defaultPipelineValuesData], diff --git a/x-pack/plugins/fleet/.storybook/context/index.tsx b/x-pack/plugins/fleet/.storybook/context/index.tsx index 1d5416cf0483d..411f0030ccb6c 100644 --- a/x-pack/plugins/fleet/.storybook/context/index.tsx +++ b/x-pack/plugins/fleet/.storybook/context/index.tsx @@ -15,6 +15,7 @@ import { I18nProvider } from '@kbn/i18n-react'; import { CoreScopedHistory } from '@kbn/core/public'; import { getStorybookContextProvider } from '@kbn/custom-integrations-plugin/storybook'; +import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks'; import { IntegrationsAppContext } from '../../public/applications/integrations/app'; import type { FleetConfigType, FleetStartServices } from '../../public/plugin'; @@ -110,6 +111,7 @@ export const StorybookContext: React.FC<{ storyContext?: Parameters writeIntegrationPolicies: true, }, }, + guidedOnboarding: guidedOnboardingMock.createStart(), }), [isCloudEnabled] ); diff --git a/x-pack/plugins/fleet/dev_docs/data_model.md b/x-pack/plugins/fleet/dev_docs/data_model.md index 483eabb4ed569..a36cda76fffb6 100644 --- a/x-pack/plugins/fleet/dev_docs/data_model.md +++ b/x-pack/plugins/fleet/dev_docs/data_model.md @@ -117,6 +117,15 @@ Contains configuration for ingest outputs that can be shared across multiple `in only exposes a single Elasticsearch output that will be used for all package policies, but in the future this may be used for other types of outputs like separate monitoring clusters, Logstash, etc. +### `ingest-download-sources` +- Constant in code: `DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE` +- Introduced in ? +- [Code Link](../server/saved_objects/index.ts#329) +- Migrations: 8.4.0, 8.5.0 + +Contains configuration for the download source objects that allow users to configure a custom registry +for downloading the Elastic Agent. The default value is for the registry is `https://artifacts.elastic.co/downloads/`. The UI exposes this configuration in Settings. + ### `epm-packages` - Constant in code: `PACKAGES_SAVED_OBJECT_TYPE` diff --git a/x-pack/plugins/fleet/kibana.json b/x-pack/plugins/fleet/kibana.json index 79d8bbd40644e..c11cd0d9cdaed 100644 --- a/x-pack/plugins/fleet/kibana.json +++ b/x-pack/plugins/fleet/kibana.json @@ -8,7 +8,7 @@ "server": true, "ui": true, "configPath": ["xpack", "fleet"], - "requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation", "customIntegrations", "share", "spaces", "security", "unifiedSearch", "savedObjectsTagging", "taskManager"], + "requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation", "customIntegrations", "share", "spaces", "security", "unifiedSearch", "savedObjectsTagging", "taskManager", "guidedOnboarding"], "optionalPlugins": ["features", "cloud", "usageCollection", "home", "globalSearch", "telemetry", "discover", "ingestPipelines"], "extraPublicDirs": ["common"], "requiredBundles": ["kibanaReact", "cloudChat", "esUiShared", "infra", "kibanaUtils", "usageCollection", "unifiedSearch"] diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/confirm_incoming_data_with_preview.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/confirm_incoming_data_with_preview.tsx index 011134151cbd7..772d90ada7dc1 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/confirm_incoming_data_with_preview.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/multi_page_layout/components/confirm_incoming_data_with_preview.tsx @@ -27,6 +27,8 @@ import type { SearchHit } from '@kbn/es-types'; import styled from 'styled-components'; +import { useStartServices, useIsGuidedOnboardingActive } from '../../../../../../../hooks'; + import type { PackageInfo } from '../../../../../../../../common'; import { @@ -136,8 +138,15 @@ export const ConfirmIncomingDataWithPreview: React.FunctionComponent = ({ ); const { enrolledAgents, numAgentsWithData } = useGetAgentIncomingData(incomingData, packageInfo); + const isGuidedOnboardingActive = useIsGuidedOnboardingActive(packageInfo?.name); + const { guidedOnboarding } = useStartServices(); if (!isLoading && enrolledAgents > 0 && numAgentsWithData > 0) { setAgentDataConfirmed(true); + if (isGuidedOnboardingActive) { + guidedOnboarding.guidedOnboardingApi?.completeGuidedOnboardingForIntegration( + packageInfo?.name + ); + } } if (!agentDataConfirmed) { return ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_action_status.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_action_status.test.tsx index a2e9c7c7c55fa..d8c320a3c101c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_action_status.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_action_status.test.tsx @@ -82,7 +82,23 @@ describe('useActionStatus', () => { }); expect(mockSendPostCancelAction).toHaveBeenCalledWith('action1'); expect(mockOnAbortSuccess).toHaveBeenCalled(); - expect(mockOpenConfirm).toHaveBeenCalledWith('This action will abort upgrade of 1 agents', { + expect(mockOpenConfirm).toHaveBeenCalledWith('This action will abort upgrade of 1 agent', { + title: 'Abort upgrade?', + }); + }); + + it('should post abort and invoke callback on abort upgrade - plural', async () => { + mockSendPostCancelAction.mockResolvedValue({}); + let result: any | undefined; + await act(async () => { + ({ result } = renderHook(() => useActionStatus(mockOnAbortSuccess, false))); + }); + await act(async () => { + await result.current.abortUpgrade({ ...mockActionStatuses[0], nbAgentsAck: 0 }); + }); + expect(mockSendPostCancelAction).toHaveBeenCalledWith('action1'); + expect(mockOnAbortSuccess).toHaveBeenCalled(); + expect(mockOpenConfirm).toHaveBeenCalledWith('This action will abort upgrade of 2 agents', { title: 'Abort upgrade?', }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_action_status.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_action_status.tsx index bc8e20b17ee00..3a6e1b2e54292 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_action_status.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_action_status.tsx @@ -54,7 +54,8 @@ export function useActionStatus(onAbortSuccess: () => void, refreshAgentActivity try { const confirmRes = await overlays.openConfirm( i18n.translate('xpack.fleet.currentUpgrade.confirmDescription', { - defaultMessage: 'This action will abort upgrade of {nbAgents} agents', + defaultMessage: + 'This action will abort upgrade of {nbAgents, plural, one {# agent} other {# agents}}', values: { nbAgents: action.nbAgentsActioned - action.nbAgentsAck, }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.test.tsx index d5d72c3deaeeb..89059d5a17971 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.test.tsx @@ -45,7 +45,7 @@ describe('useUpdateTags', () => { await act(() => result.current.updateTags('agent1', ['tag1'], mockOnSuccess)); expect(mockOnSuccess).toHaveBeenCalled(); expect(useStartServices().notifications.toasts.addSuccess as jest.Mock).toHaveBeenCalledWith( - 'Tags updated' + 'Tag(s) updated' ); }); @@ -57,7 +57,7 @@ describe('useUpdateTags', () => { expect(mockOnSuccess).not.toHaveBeenCalled(); expect(useStartServices().notifications.toasts.addError as jest.Mock).toHaveBeenCalledWith( 'error', - { title: 'Tags update failed' } + { title: 'Tag(s) update failed' } ); }); @@ -68,7 +68,7 @@ describe('useUpdateTags', () => { await act(() => result.current.bulkUpdateTags('query', ['tag1'], [], mockOnSuccess)); expect(mockOnSuccess).toHaveBeenCalled(); expect(useStartServices().notifications.toasts.addSuccess as jest.Mock).toHaveBeenCalledWith( - 'Tags updated' + 'Tag(s) updated' ); }); @@ -80,7 +80,7 @@ describe('useUpdateTags', () => { expect(mockOnSuccess).not.toHaveBeenCalled(); expect(useStartServices().notifications.toasts.addError as jest.Mock).toHaveBeenCalledWith( 'error', - { title: 'Tags update failed' } + { title: 'Tag(s) update failed' } ); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.tsx index 969b9caa4d02f..96e619db12f09 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/hooks/use_update_tags.tsx @@ -34,7 +34,7 @@ export const useUpdateTags = () => { const message = successMessage ?? i18n.translate('xpack.fleet.updateAgentTags.successNotificationTitle', { - defaultMessage: 'Tags updated', + defaultMessage: 'Tag(s) updated', }); notifications.toasts.addSuccess(message); @@ -43,7 +43,7 @@ export const useUpdateTags = () => { const errorTitle = errorMessage ?? i18n.translate('xpack.fleet.updateAgentTags.errorNotificationTitle', { - defaultMessage: 'Tags update failed', + defaultMessage: 'Tag(s) update failed', }); notifications.toasts.addError(error, { title: errorTitle }); } diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/with_guided_onboarding_tour.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/with_guided_onboarding_tour.tsx new file mode 100644 index 0000000000000..2ea4a6775d6e6 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/with_guided_onboarding_tour.tsx @@ -0,0 +1,64 @@ +/* + * 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, useState } from 'react'; +import type { FunctionComponent, ReactElement } from 'react'; +import { EuiButton, EuiText, EuiTourStep } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +type TourType = 'addIntegrationButton' | 'integrationsList'; +const getTourConfig = (packageKey: string, tourType: TourType) => { + if (packageKey.startsWith('endpoint') && tourType === 'addIntegrationButton') { + return { + title: i18n.translate('xpack.fleet.guidedOnboardingTour.endpointButton.title', { + defaultMessage: 'Add Elastic Defend', + }), + description: i18n.translate('xpack.fleet.guidedOnboardingTour.endpointButton.description', { + defaultMessage: + 'In just a few steps, configure your data with our recommended defaults. You can change this later.', + }), + }; + } + return null; +}; +export const WithGuidedOnboardingTour: FunctionComponent<{ + packageKey: string; + isGuidedOnboardingActive: boolean; + tourType: TourType; + children: ReactElement; +}> = ({ packageKey, isGuidedOnboardingActive, tourType, children }) => { + const [isGuidedOnboardingTourOpen, setIsGuidedOnboardingTourOpen] = + useState(isGuidedOnboardingActive); + useEffect(() => { + setIsGuidedOnboardingTourOpen(isGuidedOnboardingActive); + }, [isGuidedOnboardingActive]); + const config = getTourConfig(packageKey, tourType); + + return config ? ( + {config.description}} + isStepOpen={isGuidedOnboardingTourOpen} + maxWidth={350} + onFinish={() => setIsGuidedOnboardingTourOpen(false)} + step={1} + stepsTotal={1} + title={config.title} + anchorPosition="rightUp" + footerAction={ + setIsGuidedOnboardingTourOpen(false)} size="s" color="success"> + {i18n.translate('xpack.fleet.guidedOnboardingTour.nextButtonLabel', { + defaultMessage: 'Next', + })} + + } + > + {children} + + ) : ( + <>{children} + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index d43afbb28835c..caefad75ad7a1 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -39,7 +39,12 @@ import { } from '../../../../hooks'; import { INTEGRATIONS_ROUTING_PATHS } from '../../../../constants'; import { ExperimentalFeaturesService } from '../../../../services'; -import { useGetPackageInfoByKey, useLink, useAgentPolicyContext } from '../../../../hooks'; +import { + useGetPackageInfoByKey, + useLink, + useAgentPolicyContext, + useIsGuidedOnboardingActive, +} from '../../../../hooks'; import { pkgKeyFromPackageInfo } from '../../../../services'; import type { DetailViewPanelName, PackageInfo } from '../../../../types'; import { InstallStatus } from '../../../../types'; @@ -47,6 +52,8 @@ import { Error, Loading, HeaderReleaseBadge } from '../../../../components'; import type { WithHeaderLayoutProps } from '../../../../layouts'; import { WithHeaderLayout } from '../../../../layouts'; +import { WithGuidedOnboardingTour } from './components/with_guided_onboarding_tour'; + import { useIsFirstTimeAgentUser } from './hooks'; import { getInstallPkgRouteOptions } from './utils'; import { @@ -154,6 +161,7 @@ export function Detail() { const { isFirstTimeAgentUser = false, isLoading: firstTimeUserLoading } = useIsFirstTimeAgentUser(); + const isGuidedOnboardingActive = useIsGuidedOnboardingActive(pkgName); // Refresh package info when status change const [oldPackageInstallStatus, setOldPackageStatus] = useState(packageInstallStatus); @@ -292,6 +300,7 @@ export function Detail() { isCloud, isExperimentalAddIntegrationPageEnabled, isFirstTimeAgentUser, + isGuidedOnboardingActive, pkgkey, }); @@ -305,6 +314,7 @@ export function Detail() { isCloud, isExperimentalAddIntegrationPageEnabled, isFirstTimeAgentUser, + isGuidedOnboardingActive, pathname, pkgkey, search, @@ -349,19 +359,25 @@ export function Detail() { { isDivider: true }, { content: ( - + + + ), }, ].map((item, index) => ( @@ -385,6 +401,7 @@ export function Detail() { packageInfo, updateAvailable, isInstalled, + isGuidedOnboardingActive, userCanInstallPackages, getHref, pkgkey, diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.test.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.test.ts index e085b9034235b..7d233f0977b8a 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.test.ts +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.test.ts @@ -22,6 +22,7 @@ describe('getInstallPkgRouteOptions', () => { integration: 'myintegration', pkgkey: 'myintegration-1.0.0', isFirstTimeAgentUser: false, + isGuidedOnboardingActive: false, isCloud: false, isExperimentalAddIntegrationPageEnabled: false, }; @@ -51,6 +52,7 @@ describe('getInstallPkgRouteOptions', () => { pkgkey: 'myintegration-1.0.0', agentPolicyId: '12345', isFirstTimeAgentUser: false, + isGuidedOnboardingActive: false, isCloud: false, isExperimentalAddIntegrationPageEnabled: false, }; @@ -78,6 +80,7 @@ describe('getInstallPkgRouteOptions', () => { integration: 'myintegration', pkgkey: 'myintegration-1.0.0', isFirstTimeAgentUser: true, + isGuidedOnboardingActive: false, isCloud: true, isExperimentalAddIntegrationPageEnabled: true, }; @@ -105,6 +108,7 @@ describe('getInstallPkgRouteOptions', () => { integration: 'myintegration', pkgkey: 'apm-1.0.0', isFirstTimeAgentUser: true, + isGuidedOnboardingActive: false, isCloud: true, isExperimentalAddIntegrationPageEnabled: true, }; @@ -137,6 +141,7 @@ describe('getInstallPkgRouteOptions', () => { integration: 'myintegration', pkgkey: 'endpoint-1.0.0', isFirstTimeAgentUser: true, + isGuidedOnboardingActive: false, isCloud: true, isExperimentalAddIntegrationPageEnabled: true, }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.ts b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.ts index 6a8612a44f42f..f4ac18057dbbf 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.ts +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/utils/get_install_route_options.ts @@ -29,6 +29,7 @@ interface GetInstallPkgRouteOptionsParams { isCloud: boolean; isExperimentalAddIntegrationPageEnabled: boolean; isFirstTimeAgentUser: boolean; + isGuidedOnboardingActive: boolean; } const isPackageExemptFromStepsLayout = (pkgkey: string) => @@ -45,13 +46,14 @@ export const getInstallPkgRouteOptions = ({ isFirstTimeAgentUser, isCloud, isExperimentalAddIntegrationPageEnabled, + isGuidedOnboardingActive, }: GetInstallPkgRouteOptionsParams): [string, { path: string; state: unknown }] => { const integrationOpts: { integration?: string } = integration ? { integration } : {}; const packageExemptFromStepsLayout = isPackageExemptFromStepsLayout(pkgkey); const useMultiPageLayout = isExperimentalAddIntegrationPageEnabled && isCloud && - isFirstTimeAgentUser && + (isFirstTimeAgentUser || isGuidedOnboardingActive) && !packageExemptFromStepsLayout; const path = pagePathGetters.add_integration_to_policy({ pkgkey, diff --git a/x-pack/plugins/fleet/public/hooks/index.ts b/x-pack/plugins/fleet/public/hooks/index.ts index 579d1ab5bc3de..b155ccf63a0db 100644 --- a/x-pack/plugins/fleet/public/hooks/index.ts +++ b/x-pack/plugins/fleet/public/hooks/index.ts @@ -28,3 +28,4 @@ export * from './use_agent_policy_refresh'; export * from './use_package_installations'; export * from './use_agent_enrollment_flyout_data'; export * from './use_flyout_context'; +export * from './use_is_guided_onboarding_active'; diff --git a/x-pack/plugins/fleet/public/hooks/use_is_guided_onboarding_active.ts b/x-pack/plugins/fleet/public/hooks/use_is_guided_onboarding_active.ts new file mode 100644 index 0000000000000..22b8ab9b1a231 --- /dev/null +++ b/x-pack/plugins/fleet/public/hooks/use_is_guided_onboarding_active.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; + +import { of } from 'rxjs'; + +import { useStartServices } from '.'; + +export const useIsGuidedOnboardingActive = (packageName?: string): boolean => { + const [result, setResult] = useState(false); + const { guidedOnboarding } = useStartServices(); + const isGuidedOnboardingActiveForIntegration = useObservable( + // if guided onboarding is not available, return false + guidedOnboarding.guidedOnboardingApi + ? guidedOnboarding.guidedOnboardingApi.isGuidedOnboardingActiveForIntegration$(packageName) + : of(false) + ); + useEffect(() => { + setResult(!!isGuidedOnboardingActiveForIntegration); + }, [isGuidedOnboardingActiveForIntegration]); + + return result; +}; diff --git a/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx b/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx index c5d9b50111569..86816e296dde3 100644 --- a/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx +++ b/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx @@ -13,6 +13,8 @@ import { coreMock } from '@kbn/core/public/mocks'; import type { IStorage } from '@kbn/kibana-utils-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { guidedOnboardingMock } from '@kbn/guided-onboarding-plugin/public/mocks'; + import { setHttpClient } from '../hooks/use_request'; import type { FleetAuthz } from '../../common'; @@ -90,6 +92,7 @@ export const createStartServices = (basePath: string = '/mock'): MockedFleetStar }, storage: new Storage(createMockStore()) as jest.Mocked, authz: fleetAuthzMock, + guidedOnboarding: guidedOnboardingMock.createStart(), }; configureStartServices(startServices); diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index b1d845aa9e52f..3590a80037b1f 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -44,6 +44,7 @@ import type { CloudSetup } from '@kbn/cloud-plugin/public'; import type { GlobalSearchPluginSetup } from '@kbn/global-search-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public'; import { PLUGIN_ID, INTEGRATIONS_PLUGIN_ID, setupRouteService, appRoutesService } from '../common'; import { calculateAuthz, calculatePackagePrivilegesFromCapabilities } from '../common/authz'; @@ -101,6 +102,7 @@ export interface FleetStartDeps { share: SharePluginStart; cloud?: CloudStart; usageCollection?: UsageCollectionStart; + guidedOnboarding: GuidedOnboardingPluginStart; } export interface FleetStartServices extends CoreStart, Exclude { @@ -110,6 +112,7 @@ export interface FleetStartServices extends CoreStart, Exclude { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts index 8c908ecc9ef87..4999c07a2aec1 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts @@ -261,9 +261,10 @@ const installTransformsAssets = async ( await Promise.all( destinationIndexTemplates .map((destinationIndexTemplate) => { - const customMappings = transformsSpecifications - .get(destinationIndexTemplate.transformModuleId) - ?.get('mappings'); + const customMappings = + transformsSpecifications + .get(destinationIndexTemplate.transformModuleId) + ?.get('mappings') ?? {}; const registryElasticsearch: RegistryElasticsearch = { 'index_template.settings': destinationIndexTemplate.template.settings, 'index_template.mappings': destinationIndexTemplate.template.mappings, diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/transform.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/legacy_transforms.test.ts similarity index 99% rename from x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/transform.test.ts rename to x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/legacy_transforms.test.ts index 97fa1e94ca218..124004dee94ac 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/transform.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/legacy_transforms.test.ts @@ -37,7 +37,7 @@ import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../constants'; import { getAsset } from './common'; import { installTransforms } from './install'; -describe('test transform install', () => { +describe('test transform install with legacy schema', () => { let esClient: ReturnType; let savedObjectsClient: jest.Mocked; beforeEach(() => { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/transforms.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/transforms.test.ts new file mode 100644 index 0000000000000..aeeeb59e12b38 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/transforms.test.ts @@ -0,0 +1,672 @@ +/* + * 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-next-line import/order +import { createAppContextStartContractMock } from '../../../../mocks'; + +jest.mock('../../packages/get', () => { + return { getInstallation: jest.fn(), getInstallationObject: jest.fn() }; +}); + +jest.mock('./common', () => { + return { + getAsset: jest.fn(), + }; +}); + +import type { SavedObject, SavedObjectsClientContract } from '@kbn/core/server'; +import { loggerMock } from '@kbn/logging-mocks'; + +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; + +import { getInstallation, getInstallationObject } from '../../packages'; +import type { Installation, RegistryPackage } from '../../../../types'; +import { ElasticsearchAssetType } from '../../../../types'; +import { appContextService } from '../../../app_context'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../constants'; + +import { getESAssetMetadata } from '../meta'; + +import { installTransforms } from './install'; +import { getAsset } from './common'; + +const meta = getESAssetMetadata({ packageName: 'endpoint' }); + +describe('test transform install', () => { + let esClient: ReturnType; + let savedObjectsClient: jest.Mocked; + + const getYamlTestData = (autoStart: boolean | undefined = undefined) => { + const start = + autoStart === undefined + ? '' + : ` +start: ${autoStart}`; + return { + MANIFEST: + `destination_index_template: + settings: + index: + codec: best_compression + refresh_interval: 5s + number_of_shards: 1 + number_of_routing_shards: 30 + hidden: true + mappings: + dynamic: false + _meta: {} + dynamic_templates: + - strings_as_keyword: + match_mapping_type: string + mapping: + ignore_above: 1024 + type: keyword + date_detection: false` + start, + TRANSFORM: `source: + index: + - metrics-endpoint.metadata_current_default* + - ".fleet-agents*" +dest: + index: ".metrics-endpoint.metadata_united_default" +frequency: 1s +sync: + time: + delay: 4s + field: updated_at +pivot: + aggs: + united: + scripted_metric: + init_script: state.docs = [] + map_script: state.docs.add(new HashMap(params['_source'])) + combine_script: return state.docs + reduce_script: def ret = new HashMap(); for (s in states) { for (d in s) { if (d.containsKey('Endpoint')) { ret.endpoint = d } else { ret.agent = d } }} return ret + group_by: + agent.id: + terms: + field: agent.id +description: Merges latest endpoint and Agent metadata documents. +_meta: + managed: true`, + FIELDS: `- name: '@timestamp' + type: date +- name: updated_at + type: alias + path: event.ingested`, + }; + }; + const getExpectedData = () => { + return { + TRANSFORM: { + transform_id: 'logs-endpoint.metadata_current-default-0.16.0-dev.0', + defer_validation: true, + body: { + description: 'Merges latest endpoint and Agent metadata documents.', + dest: { + index: '.metrics-endpoint.metadata_united_default', + }, + frequency: '1s', + pivot: { + aggs: { + united: { + scripted_metric: { + combine_script: 'return state.docs', + init_script: 'state.docs = []', + map_script: "state.docs.add(new HashMap(params['_source']))", + reduce_script: + "def ret = new HashMap(); for (s in states) { for (d in s) { if (d.containsKey('Endpoint')) { ret.endpoint = d } else { ret.agent = d } }} return ret", + }, + }, + }, + group_by: { + 'agent.id': { + terms: { + field: 'agent.id', + }, + }, + }, + }, + source: { + index: ['metrics-endpoint.metadata_current_default*', '.fleet-agents*'], + }, + sync: { + time: { + delay: '4s', + field: 'updated_at', + }, + }, + _meta: meta, + }, + }, + }; + }; + + beforeEach(() => { + appContextService.start(createAppContextStartContractMock()); + esClient = elasticsearchClientMock.createClusterClient().asInternalUser; + (getInstallation as jest.MockedFunction).mockReset(); + (getInstallationObject as jest.MockedFunction).mockReset(); + savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.update.mockImplementation(async (type, id, attributes) => ({ + type: PACKAGES_SAVED_OBJECT_TYPE, + id: 'endpoint', + attributes, + references: [], + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('can install new versions and removes older version when start is not defined', async () => { + const sourceData = getYamlTestData(); + const expectedData = getExpectedData(); + + const previousInstallation: Installation = { + installed_es: [ + { + id: 'metrics-endpoint.policy-0.16.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.15.0-dev.0', + type: ElasticsearchAssetType.transform, + }, + ], + } as unknown as Installation; + + const currentInstallation: Installation = { + installed_es: [ + { + id: 'metrics-endpoint.policy-0.16.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.15.0-dev.0', + type: ElasticsearchAssetType.transform, + }, + { + id: 'endpoint.metadata_current-default-0.16.0-dev.0', + type: ElasticsearchAssetType.transform, + }, + { + id: 'endpoint.metadata-default-0.16.0-dev.0', + type: ElasticsearchAssetType.transform, + }, + ], + } as unknown as Installation; + (getAsset as jest.MockedFunction) + .mockReturnValueOnce(Buffer.from(sourceData.MANIFEST, 'utf8')) + .mockReturnValueOnce(Buffer.from(sourceData.TRANSFORM, 'utf8')); + + (getInstallation as jest.MockedFunction) + .mockReturnValueOnce(Promise.resolve(previousInstallation)) + .mockReturnValueOnce(Promise.resolve(currentInstallation)); + + ( + getInstallationObject as jest.MockedFunction + ).mockReturnValueOnce( + Promise.resolve({ + attributes: { + installed_es: previousInstallation.installed_es, + }, + } as unknown as SavedObject) + ); + + // Mock transform from old version + esClient.transform.getTransform.mockResponseOnce({ + count: 1, + transforms: [ + // @ts-expect-error incomplete data + { + dest: { + index: 'mock-old-destination-index', + }, + }, + ], + }); + + await installTransforms( + { + name: 'endpoint', + version: '0.16.0-dev.0', + } as unknown as RegistryPackage, + [ + 'endpoint-0.16.0-dev.0/elasticsearch/transform/metadata_current/manifest.yml', + 'endpoint-0.16.0-dev.0/elasticsearch/transform/metadata_current/transform.yml', + ], + esClient, + savedObjectsClient, + loggerMock.create(), + previousInstallation.installed_es + ); + + expect(esClient.transform.getTransform.mock.calls).toEqual([ + [ + { + transform_id: 'endpoint.metadata_current-default-0.15.0-dev.0', + }, + { ignore: [404] }, + ], + ]); + // Stop and delete previously installed transforms + expect(esClient.transform.stopTransform.mock.calls).toEqual([ + [ + { + transform_id: 'endpoint.metadata_current-default-0.15.0-dev.0', + force: true, + }, + { ignore: [404] }, + ], + ]); + expect(esClient.transform.deleteTransform.mock.calls).toEqual([ + [ + { + transform_id: 'endpoint.metadata_current-default-0.15.0-dev.0', + force: true, + }, + { ignore: [404] }, + ], + ]); + + // Delete destination index + expect(esClient.transport.request.mock.calls).toEqual([ + [ + { + method: 'DELETE', + path: '/mock-old-destination-index', + }, + { ignore: [404] }, + ], + ]); + + // Create a @package component template and an empty @custom component template + expect(esClient.cluster.putComponentTemplate.mock.calls).toEqual([ + [ + { + body: { + _meta: meta, + template: { + mappings: { + _meta: {}, + date_detection: false, + dynamic: false, + dynamic_templates: [ + { + strings_as_keyword: { + mapping: { ignore_above: 1024, type: 'keyword' }, + match_mapping_type: 'string', + }, + }, + ], + properties: {}, + }, + settings: { + index: { + codec: 'best_compression', + hidden: true, + mapping: { total_fields: { limit: '10000' } }, + number_of_routing_shards: 30, + number_of_shards: 1, + refresh_interval: '5s', + }, + }, + }, + }, + create: false, + name: 'logs-endpoint.metadata_current-template@package', + }, + { ignore: [404] }, + ], + [ + { + body: { + _meta: meta, + template: { settings: {} }, + }, + create: true, + name: 'logs-endpoint.metadata_current-template@custom', + }, + { ignore: [404] }, + ], + ]); + + // Index template composed of the two component templates created + // with index pattern matching the destination index + expect(esClient.indices.putIndexTemplate.mock.calls).toEqual([ + [ + { + body: { + _meta: meta, + composed_of: [ + 'logs-endpoint.metadata_current-template@package', + 'logs-endpoint.metadata_current-template@custom', + ], + index_patterns: ['.metrics-endpoint.metadata_united_default'], + priority: 250, + template: { mappings: undefined, settings: undefined }, + }, + name: 'logs-endpoint.metadata_current-template', + }, + { ignore: [404] }, + ], + ]); + + // Destination index is created before transform is created + expect(esClient.indices.create.mock.calls).toEqual([ + [{ index: '.metrics-endpoint.metadata_united_default' }, { ignore: [400] }], + ]); + + expect(esClient.transform.putTransform.mock.calls).toEqual([[expectedData.TRANSFORM]]); + expect(esClient.transform.startTransform.mock.calls).toEqual([ + [ + { + transform_id: 'logs-endpoint.metadata_current-default-0.16.0-dev.0', + }, + { ignore: [409] }, + ], + ]); + + // Saved object is updated with newly created index templates, component templates, transform + expect(savedObjectsClient.update.mock.calls).toEqual([ + [ + 'epm-packages', + 'endpoint', + { + installed_es: [ + { + id: 'metrics-endpoint.policy-0.16.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'logs-endpoint.metadata_current-template', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: 'logs-endpoint.metadata_current-template@custom', + type: ElasticsearchAssetType.componentTemplate, + }, + { + id: 'logs-endpoint.metadata_current-template@package', + type: ElasticsearchAssetType.componentTemplate, + }, + { + id: 'logs-endpoint.metadata_current-default-0.16.0-dev.0', + type: ElasticsearchAssetType.transform, + }, + ], + }, + { + refresh: false, + }, + ], + ]); + }); + + test('can install new version when no older version', async () => { + const sourceData = getYamlTestData(true); + const expectedData = getExpectedData(); + + const previousInstallation: Installation = { + installed_es: [], + } as unknown as Installation; + + const currentInstallation: Installation = { + installed_es: [ + { + id: 'metrics-endpoint.metadata-current-default-0.16.0-dev.0', + type: ElasticsearchAssetType.transform, + }, + ], + } as unknown as Installation; + (getAsset as jest.MockedFunction).mockReturnValueOnce( + Buffer.from(sourceData.TRANSFORM, 'utf8') + ); + (getInstallation as jest.MockedFunction) + .mockReturnValueOnce(Promise.resolve(previousInstallation)) + .mockReturnValueOnce(Promise.resolve(currentInstallation)); + + ( + getInstallationObject as jest.MockedFunction + ).mockReturnValueOnce( + Promise.resolve({ + attributes: { installed_es: [] }, + } as unknown as SavedObject) + ); + + await installTransforms( + { + name: 'endpoint', + version: '0.16.0-dev.0', + } as unknown as RegistryPackage, + ['endpoint-0.16.0-dev.0/elasticsearch/transform/metadata_current/transform.yml'], + esClient, + savedObjectsClient, + loggerMock.create(), + previousInstallation.installed_es + ); + + expect(esClient.transform.putTransform.mock.calls).toEqual([[expectedData.TRANSFORM]]); + expect(esClient.transform.startTransform.mock.calls).toEqual([ + [ + { + transform_id: 'logs-endpoint.metadata_current-default-0.16.0-dev.0', + }, + { ignore: [409] }, + ], + ]); + + expect(savedObjectsClient.update.mock.calls).toEqual([ + [ + 'epm-packages', + 'endpoint', + { + installed_es: [ + { id: 'logs-endpoint.metadata_current-default-0.16.0-dev.0', type: 'transform' }, + ], + }, + { + refresh: false, + }, + ], + ]); + }); + + test('can combine settings fields.yml & manifest.yml and not start transform automatically', async () => { + const sourceData = getYamlTestData(false); + const expectedData = getExpectedData(); + + const previousInstallation: Installation = { + installed_es: [ + { + id: 'endpoint.metadata-current-default-0.15.0-dev.0', + type: ElasticsearchAssetType.transform, + }, + { + id: 'logs-endpoint.metadata_current-template', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: 'logs-endpoint.metadata_current-template@custom', + type: ElasticsearchAssetType.componentTemplate, + }, + { + id: 'logs-endpoint.metadata_current-template@package', + type: ElasticsearchAssetType.componentTemplate, + }, + ], + } as unknown as Installation; + + const currentInstallation: Installation = { + installed_es: [], + } as unknown as Installation; + + (getAsset as jest.MockedFunction) + .mockReturnValueOnce(Buffer.from(sourceData.FIELDS, 'utf8')) + .mockReturnValueOnce(Buffer.from(sourceData.MANIFEST, 'utf8')) + .mockReturnValueOnce(Buffer.from(sourceData.TRANSFORM, 'utf8')); + + (getInstallation as jest.MockedFunction) + .mockReturnValueOnce(Promise.resolve(previousInstallation)) + .mockReturnValueOnce(Promise.resolve(currentInstallation)); + + ( + getInstallationObject as jest.MockedFunction + ).mockReturnValueOnce( + Promise.resolve({ + attributes: { installed_es: currentInstallation.installed_es }, + } as unknown as SavedObject) + ); + + esClient.transform.getTransform.mockResponseOnce({ + count: 1, + transforms: [ + // @ts-expect-error incomplete data + { + dest: { + index: 'mock-old-destination-index', + }, + }, + ], + }); + + await installTransforms( + { + name: 'endpoint', + version: '0.16.0-dev.0', + } as unknown as RegistryPackage, + [ + 'endpoint-0.16.0-dev.0/elasticsearch/transform/metadata_current/fields/fields.yml', + 'endpoint-0.16.0-dev.0/elasticsearch/transform/metadata_current/manifest.yml', + 'endpoint-0.16.0-dev.0/elasticsearch/transform/metadata_current/transform.yml', + ], + esClient, + savedObjectsClient, + loggerMock.create(), + previousInstallation.installed_es + ); + + expect(esClient.transform.getTransform.mock.calls).toEqual([ + [ + { + transform_id: 'endpoint.metadata-current-default-0.15.0-dev.0', + }, + { ignore: [404] }, + ], + ]); + + // Transform from old version is stopped & deleted + expect(esClient.transform.stopTransform.mock.calls).toEqual([ + [ + { + transform_id: 'endpoint.metadata-current-default-0.15.0-dev.0', + force: true, + }, + { ignore: [404] }, + ], + ]); + + expect(esClient.transform.deleteTransform.mock.calls).toEqual([ + [ + { + transform_id: 'endpoint.metadata-current-default-0.15.0-dev.0', + force: true, + }, + { ignore: [404] }, + ], + ]); + + // Destination index from old version is also deleted + expect(esClient.transport.request.mock.calls).toEqual([ + [{ method: 'DELETE', path: '/mock-old-destination-index' }, { ignore: [404] }], + ]); + + // Component templates are created with mappings from fields.yml + // and template from manifest + expect(esClient.cluster.putComponentTemplate.mock.calls).toEqual([ + [ + { + name: 'logs-endpoint.metadata_current-template@package', + body: { + template: { + settings: { + index: { + codec: 'best_compression', + refresh_interval: '5s', + number_of_shards: 1, + number_of_routing_shards: 30, + hidden: true, + mapping: { total_fields: { limit: '10000' } }, + }, + }, + mappings: { + properties: { '@timestamp': { type: 'date' } }, + dynamic_templates: [ + { + strings_as_keyword: { + match_mapping_type: 'string', + mapping: { ignore_above: 1024, type: 'keyword' }, + }, + }, + ], + dynamic: false, + _meta: {}, + date_detection: false, + }, + }, + _meta: meta, + }, + create: false, + }, + { ignore: [404] }, + ], + [ + { + name: 'logs-endpoint.metadata_current-template@custom', + body: { + template: { settings: {} }, + _meta: meta, + }, + create: true, + }, + { ignore: [404] }, + ], + ]); + // Index template composed of the two component templates created + // with index pattern matching the destination index + expect(esClient.indices.putIndexTemplate.mock.calls).toEqual([ + [ + { + body: { + _meta: meta, + composed_of: [ + 'logs-endpoint.metadata_current-template@package', + 'logs-endpoint.metadata_current-template@custom', + ], + index_patterns: ['.metrics-endpoint.metadata_united_default'], + priority: 250, + template: { mappings: undefined, settings: undefined }, + }, + name: 'logs-endpoint.metadata_current-template', + }, + { ignore: [404] }, + ], + ]); + + // Destination index is created before transform is created + expect(esClient.indices.create.mock.calls).toEqual([ + [{ index: '.metrics-endpoint.metadata_united_default' }, { ignore: [400] }], + ]); + + // New transform created but not not started automatically if start: false in manifest.yml + expect(esClient.transform.putTransform.mock.calls).toEqual([[expectedData.TRANSFORM]]); + expect(esClient.transform.startTransform.mock.calls).toEqual([]); + }); +}); diff --git a/x-pack/plugins/fleet/tsconfig.json b/x-pack/plugins/fleet/tsconfig.json index 320843546a305..c9c730b6a170c 100644 --- a/x-pack/plugins/fleet/tsconfig.json +++ b/x-pack/plugins/fleet/tsconfig.json @@ -27,6 +27,7 @@ { "path": "../licensing/tsconfig.json" }, { "path": "../../../src/plugins/data/tsconfig.json" }, { "path": "../encrypted_saved_objects/tsconfig.json" }, + {"path": "../../../src/plugins/guided_onboarding/tsconfig.json"}, // optionalPlugins from ./kibana.json { "path": "../security/tsconfig.json" }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index cd74119bbe315..9d71e74eff473 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -64,17 +64,20 @@ export function DimensionContainer({ }, [handleClose]); useEffect(() => { - if (isOpen) { - document.body.classList.add('lnsBody--overflowHidden'); - } else { - document.body.classList.remove('lnsBody--overflowHidden'); - } + document.body.classList.toggle('lnsBody--overflowHidden', isOpen); return () => { + if (isOpen) { + setFocusTrapIsEnabled(false); + } document.body.classList.remove('lnsBody--overflowHidden'); }; - }); + }, [isOpen]); + + if (!isOpen) { + return null; + } - return isOpen ? ( + return (
- ) : null; + ); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index af653425e88e4..84c56b0f914fc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -605,7 +605,15 @@ export function DimensionEditor(props: DimensionEditorProps) { ...services, }; - const helpButton = ; + const helpButton = ( + + ); const columnsSidebar = [ { diff --git a/x-pack/plugins/profiling/common/callee.test.ts b/x-pack/plugins/profiling/common/callee.test.ts index 0ae26e6d848e7..4e3ef4b286e31 100644 --- a/x-pack/plugins/profiling/common/callee.test.ts +++ b/x-pack/plugins/profiling/common/callee.test.ts @@ -10,15 +10,28 @@ import { createCalleeTree } from './callee'; import { events, stackTraces, stackFrames, executables } from './__fixtures__/stacktraces'; +const totalSamples = sum([...events.values()]); +const totalFrames = sum([...stackTraces.values()].map((trace) => trace.FrameIDs.length)); +const tree = createCalleeTree(events, stackTraces, stackFrames, executables, totalFrames); + describe('Callee operations', () => { - test('1', () => { - const totalSamples = sum([...events.values()]); - const totalFrames = sum([...stackTraces.values()].map((trace) => trace.FrameIDs.length)); + test('inclusive count of root equals total sampled stacktraces', () => { + expect(tree.CountInclusive[0]).toEqual(totalSamples); + }); - const tree = createCalleeTree(events, stackTraces, stackFrames, executables, totalFrames); + test('inclusive count for each node should be greater than or equal to its children', () => { + const allGreaterThanOrEqual = tree.Edges.map( + (children, i) => + tree.CountInclusive[i] >= sum([...children.values()].map((j) => tree.CountInclusive[j])) + ); + expect(allGreaterThanOrEqual).toBeTruthy(); + }); - expect(tree.Samples[0]).toEqual(totalSamples); - expect(tree.CountInclusive[0]).toEqual(totalSamples); + test('exclusive count of root is zero', () => { expect(tree.CountExclusive[0]).toEqual(0); }); + + test('tree de-duplicates sibling nodes', () => { + expect(tree.Size).toEqual(totalFrames - 2); + }); }); diff --git a/x-pack/plugins/profiling/common/callee.ts b/x-pack/plugins/profiling/common/callee.ts index 63db0640513c3..5c347f034760d 100644 --- a/x-pack/plugins/profiling/common/callee.ts +++ b/x-pack/plugins/profiling/common/callee.ts @@ -5,20 +5,15 @@ * 2.0. */ -import fnv from 'fnv-plus'; - import { createFrameGroupID, FrameGroupID } from './frame_group'; import { - createStackFrameMetadata, emptyExecutable, emptyStackFrame, emptyStackTrace, Executable, FileID, - getCalleeLabel, StackFrame, StackFrameID, - StackFrameMetadata, StackTrace, StackTraceID, } from './profiling'; @@ -29,93 +24,56 @@ export interface CalleeTree { Size: number; Edges: Array>; - ID: string[]; + FileID: string[]; FrameType: number[]; - FrameID: StackFrameID[]; - FileID: FileID[]; - Label: string[]; + ExeFilename: string[]; + AddressOrLine: number[]; + FunctionName: string[]; + FunctionOffset: number[]; + SourceFilename: string[]; + SourceLine: number[]; - Samples: number[]; CountInclusive: number[]; CountExclusive: number[]; } -function initCalleeTree(capacity: number): CalleeTree { - const metadata = createStackFrameMetadata(); - const frameGroupID = createFrameGroupID( - metadata.FileID, - metadata.AddressOrLine, - metadata.ExeFileName, - metadata.SourceFilename, - metadata.FunctionName - ); +export function createCalleeTree( + events: Map, + stackTraces: Map, + stackFrames: Map, + executables: Map, + totalFrames: number +): CalleeTree { const tree: CalleeTree = { Size: 1, - Edges: new Array(capacity), - ID: new Array(capacity), - FrameType: new Array(capacity), - FrameID: new Array(capacity), - FileID: new Array(capacity), - Label: new Array(capacity), - Samples: new Array(capacity), - CountInclusive: new Array(capacity), - CountExclusive: new Array(capacity), + Edges: new Array(totalFrames), + FileID: new Array(totalFrames), + FrameType: new Array(totalFrames), + ExeFilename: new Array(totalFrames), + AddressOrLine: new Array(totalFrames), + FunctionName: new Array(totalFrames), + FunctionOffset: new Array(totalFrames), + SourceFilename: new Array(totalFrames), + SourceLine: new Array(totalFrames), + + CountInclusive: new Array(totalFrames), + CountExclusive: new Array(totalFrames), }; tree.Edges[0] = new Map(); - tree.ID[0] = fnv.fast1a64utf(frameGroupID).toString(); - tree.FrameType[0] = metadata.FrameType; - tree.FrameID[0] = metadata.FrameID; - tree.FileID[0] = metadata.FileID; - tree.Label[0] = 'root: Represents 100% of CPU time.'; - tree.Samples[0] = 0; + tree.FileID[0] = ''; + tree.FrameType[0] = 0; + tree.ExeFilename[0] = ''; + tree.AddressOrLine[0] = 0; + tree.FunctionName[0] = ''; + tree.FunctionOffset[0] = 0; + tree.SourceFilename[0] = ''; + tree.SourceLine[0] = 0; + tree.CountInclusive[0] = 0; tree.CountExclusive[0] = 0; - return tree; -} - -function insertNode( - tree: CalleeTree, - parent: NodeID, - metadata: StackFrameMetadata, - frameGroupID: FrameGroupID, - samples: number -) { - const node = tree.Size; - - tree.Edges[parent].set(frameGroupID, node); - tree.Edges[node] = new Map(); - - tree.ID[node] = fnv.fast1a64utf(`${tree.ID[parent]}${frameGroupID}`).toString(); - tree.FrameType[node] = metadata.FrameType; - tree.FrameID[node] = metadata.FrameID; - tree.FileID[node] = metadata.FileID; - tree.Label[node] = getCalleeLabel(metadata); - tree.Samples[node] = samples; - tree.CountInclusive[node] = 0; - tree.CountExclusive[node] = 0; - - tree.Size++; - - return node; -} - -// createCalleeTree creates a tree from the trace results, the number of -// times that the trace has been seen, and the respective metadata. -// -// The resulting data structure contains all of the data, but is not yet in the -// form most easily digestible by others. -export function createCalleeTree( - events: Map, - stackTraces: Map, - stackFrames: Map, - executables: Map, - totalFrames: number -): CalleeTree { - const tree = initCalleeTree(totalFrames); - const sortedStackTraceIDs = new Array(); for (const trace of stackTraces.keys()) { sortedStackTraceIDs.push(trace); @@ -139,7 +97,9 @@ export function createCalleeTree( const samples = events.get(stackTraceID) ?? 0; let currentNode = 0; - tree.Samples[currentNode] += samples; + + tree.CountInclusive[currentNode] += samples; + tree.CountExclusive[currentNode] = 0; for (let i = 0; i < lenStackTrace; i++) { const frameID = stackTrace.FrameIDs[i]; @@ -159,25 +119,27 @@ export function createCalleeTree( let node = tree.Edges[currentNode].get(frameGroupID); if (node === undefined) { - const metadata = createStackFrameMetadata({ - FrameID: frameID, - FileID: fileID, - AddressOrLine: addressOrLine, - FrameType: stackTrace.Types[i], - FunctionName: frame.FunctionName, - FunctionOffset: frame.FunctionOffset, - SourceLine: frame.LineNumber, - SourceFilename: frame.FileName, - ExeFileName: executable.FileName, - }); - - node = insertNode(tree, currentNode, metadata, frameGroupID, samples); + node = tree.Size; + + tree.FileID[node] = fileID; + tree.FrameType[node] = stackTrace.Types[i]; + tree.ExeFilename[node] = executable.FileName; + tree.AddressOrLine[node] = addressOrLine; + tree.FunctionName[node] = frame.FunctionName; + tree.FunctionOffset[node] = frame.FunctionOffset; + tree.SourceLine[node] = frame.LineNumber; + tree.SourceFilename[node] = frame.FileName; + tree.CountInclusive[node] = samples; + tree.CountExclusive[node] = 0; + + tree.Edges[currentNode].set(frameGroupID, node); + tree.Edges[node] = new Map(); + + tree.Size++; } else { - tree.Samples[node] += samples; + tree.CountInclusive[node] += samples; } - tree.CountInclusive[node] += samples; - if (i === lenStackTrace - 1) { // Leaf frame: sum up counts for exclusive CPU. tree.CountExclusive[node] += samples; @@ -186,8 +148,5 @@ export function createCalleeTree( } } - tree.CountExclusive[0] = 0; - tree.CountInclusive[0] = tree.Samples[0]; - return tree; } diff --git a/x-pack/plugins/profiling/common/columnar_view_model.test.ts b/x-pack/plugins/profiling/common/columnar_view_model.test.ts new file mode 100644 index 0000000000000..c41e2b0aef4ea --- /dev/null +++ b/x-pack/plugins/profiling/common/columnar_view_model.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { sum } from 'lodash'; +import { createCalleeTree } from './callee'; +import { createColumnarViewModel } from './columnar_view_model'; +import { createBaseFlameGraph, createFlameGraph } from './flamegraph'; + +import { events, stackTraces, stackFrames, executables } from './__fixtures__/stacktraces'; + +const totalFrames = sum([...stackTraces.values()].map((trace) => trace.FrameIDs.length)); + +const tree = createCalleeTree(events, stackTraces, stackFrames, executables, totalFrames); +const graph = createFlameGraph(createBaseFlameGraph(tree, 60)); + +describe('Columnar view model operations', () => { + test('color values are generated by default', () => { + const viewModel = createColumnarViewModel(graph); + + expect(sum(viewModel.color)).toBeGreaterThan(0); + }); + + test('color values are not generated when disabled', () => { + const viewModel = createColumnarViewModel(graph, false); + + expect(sum(viewModel.color)).toEqual(0); + }); +}); diff --git a/x-pack/plugins/profiling/common/columnar_view_model.ts b/x-pack/plugins/profiling/common/columnar_view_model.ts new file mode 100644 index 0000000000000..21e520b1f9942 --- /dev/null +++ b/x-pack/plugins/profiling/common/columnar_view_model.ts @@ -0,0 +1,136 @@ +/* + * 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 { ColumnarViewModel } from '@elastic/charts'; + +import { ElasticFlameGraph } from './flamegraph'; + +/* + * Helper to calculate the color of a given block to be drawn. The desirable outcomes of this are: + * Each of the following frame types should get a different set of color hues: + * + * 0 = Unsymbolized frame + * 1 = Python + * 2 = PHP + * 3 = Native + * 4 = Kernel + * 5 = JVM/Hotspot + * 6 = Ruby + * 7 = Perl + * 8 = JavaScript + * + * This is most easily achieved by mapping frame types to different color variations, using + * the x-position we can use different colors for adjacent blocks while keeping a similar hue + * + * Taken originally from prodfiler_ui/src/helpers/Pixi/frameTypeToColors.tsx + */ +const frameTypeToColors = [ + [0xfd8484, 0xfd9d9d, 0xfeb5b5, 0xfecece], + [0xfcae6b, 0xfdbe89, 0xfdcea6, 0xfedfc4], + [0xfcdb82, 0xfde29b, 0xfde9b4, 0xfef1cd], + [0x6dd0dc, 0x8ad9e3, 0xa7e3ea, 0xc5ecf1], + [0x7c9eff, 0x96b1ff, 0xb0c5ff, 0xcbd8ff], + [0x65d3ac, 0x84dcbd, 0xa3e5cd, 0xc1edde], + [0xd79ffc, 0xdfb2fd, 0xe7c5fd, 0xefd9fe], + [0xf98bb9, 0xfaa2c7, 0xfbb9d5, 0xfdd1e3], + [0xcbc3e3, 0xd5cfe8, 0xdfdbee, 0xeae7f3], +]; + +function frameTypeToRGB(frameType: number, x: number): number { + return frameTypeToColors[frameType][x % 4]; +} + +export function rgbToRGBA(rgb: number): number[] { + return [ + Math.floor(rgb / 65536) / 255, + (Math.floor(rgb / 256) % 256) / 255, + (rgb % 256) / 255, + 1.0, + ]; +} + +function normalize(n: number, lower: number, upper: number): number { + return (n - lower) / (upper - lower); +} + +// createColumnarViewModel normalizes the columnar representation into a form +// consumed by the flamegraph in the UI. +export function createColumnarViewModel( + flamegraph: ElasticFlameGraph, + assignColors: boolean = true +): ColumnarViewModel { + const numNodes = flamegraph.Size; + const xs = new Float32Array(numNodes); + const ys = new Float32Array(numNodes); + + const queue = [{ x: 0, depth: 1, node: 0 }]; + + while (queue.length > 0) { + const { x, depth, node } = queue.pop()!; + + xs[node] = x; + ys[node] = depth; + + // For a deterministic result we have to walk the callees in a deterministic + // order. A deterministic result allows deterministic UI views, something + // that users expect. + const children = flamegraph.Edges[node].sort((n1, n2) => { + if (flamegraph.CountInclusive[n1] > flamegraph.CountInclusive[n2]) { + return -1; + } + if (flamegraph.CountInclusive[n1] < flamegraph.CountInclusive[n2]) { + return 1; + } + return flamegraph.ID[n1].localeCompare(flamegraph.ID[n2]); + }); + + let delta = 0; + for (const child of children) { + delta += flamegraph.CountInclusive[child]; + } + + for (let i = children.length - 1; i >= 0; i--) { + delta -= flamegraph.CountInclusive[children[i]]; + queue.push({ x: x + delta, depth: depth + 1, node: children[i] }); + } + } + + const colors = new Float32Array(numNodes * 4); + + if (assignColors) { + for (let i = 0; i < numNodes; i++) { + const rgba = rgbToRGBA(frameTypeToRGB(flamegraph.FrameType[i], xs[i])); + colors.set(rgba, 4 * i); + } + } + + const position = new Float32Array(numNodes * 2); + const maxX = flamegraph.CountInclusive[0]; + const maxY = ys.reduce((max, n) => (n > max ? n : max), 0); + + for (let i = 0; i < numNodes; i++) { + const j = 2 * i; + position[j] = normalize(xs[i], 0, maxX); + position[j + 1] = normalize(maxY - ys[i], 0, maxY); + } + + const size = new Float32Array(numNodes); + + for (let i = 0; i < numNodes; i++) { + size[i] = normalize(flamegraph.CountInclusive[i], 0, maxX); + } + + return { + label: flamegraph.Label.slice(0, numNodes), + value: Float64Array.from(flamegraph.CountInclusive.slice(0, numNodes)), + color: colors, + position0: position, + position1: position, + size0: size, + size1: size, + } as ColumnarViewModel; +} diff --git a/x-pack/plugins/profiling/common/flamegraph.test.ts b/x-pack/plugins/profiling/common/flamegraph.test.ts index 3852d0152bf12..5f13d8f9db89b 100644 --- a/x-pack/plugins/profiling/common/flamegraph.test.ts +++ b/x-pack/plugins/profiling/common/flamegraph.test.ts @@ -7,26 +7,36 @@ import { sum } from 'lodash'; import { createCalleeTree } from './callee'; -import { createColumnarViewModel, createFlameGraph } from './flamegraph'; +import { createBaseFlameGraph, createFlameGraph } from './flamegraph'; import { events, stackTraces, stackFrames, executables } from './__fixtures__/stacktraces'; +const totalFrames = sum([...stackTraces.values()].map((trace) => trace.FrameIDs.length)); +const tree = createCalleeTree(events, stackTraces, stackFrames, executables, totalFrames); +const baseFlamegraph = createBaseFlameGraph(tree, 60); +const flamegraph = createFlameGraph(baseFlamegraph); + describe('Flamegraph operations', () => { - test('1', () => { - const totalSamples = sum([...events.values()]); - const totalFrames = sum([...stackTraces.values()].map((trace) => trace.FrameIDs.length)); + test('base flamegraph has non-zero total seconds', () => { + expect(baseFlamegraph.TotalSeconds).toEqual(60); + }); - const tree = createCalleeTree(events, stackTraces, stackFrames, executables, totalFrames); - const graph = createFlameGraph(tree, 60, totalSamples, totalSamples); + test('base flamegraph has one more node than the number of edges', () => { + const numEdges = baseFlamegraph.Edges.flatMap((edge) => edge).length; - expect(graph.Size).toEqual(totalFrames - 2); + expect(numEdges).toEqual(baseFlamegraph.Size - 1); + }); - const viewModel1 = createColumnarViewModel(graph); + test('all flamegraph IDs are the same non-zero length', () => { + // 16 is the length of a 64-bit FNV-1a hash encoded to a hex string + const allSameLengthIDs = flamegraph.ID.every((id) => id.length === 16); - expect(sum(viewModel1.color)).toBeGreaterThan(0); + expect(allSameLengthIDs).toBeTruthy(); + }); - const viewModel2 = createColumnarViewModel(graph, false); + test('all flamegraph labels are non-empty', () => { + const allNonEmptyLabels = flamegraph.Label.every((id) => id.length > 0); - expect(sum(viewModel2.color)).toEqual(0); + expect(allNonEmptyLabels).toBeTruthy(); }); }); diff --git a/x-pack/plugins/profiling/common/flamegraph.ts b/x-pack/plugins/profiling/common/flamegraph.ts index e392022a18fcf..eacd4a34322c5 100644 --- a/x-pack/plugins/profiling/common/flamegraph.ts +++ b/x-pack/plugins/profiling/common/flamegraph.ts @@ -5,106 +5,54 @@ * 2.0. */ -import { ColumnarViewModel } from '@elastic/charts'; - import { CalleeTree } from './callee'; +import { createFrameGroupID } from './frame_group'; +import { fnv1a64 } from './hash'; +import { createStackFrameMetadata, getCalleeLabel } from './profiling'; + +export enum FlameGraphComparisonMode { + Absolute = 'absolute', + Relative = 'relative', +} -export interface ElasticFlameGraph { +export interface BaseFlameGraph { Size: number; Edges: number[][]; - ID: string[]; + FileID: string[]; FrameType: number[]; - FrameID: string[]; - ExecutableID: string[]; - Label: string[]; + ExeFilename: string[]; + AddressOrLine: number[]; + FunctionName: string[]; + FunctionOffset: number[]; + SourceFilename: string[]; + SourceLine: number[]; - Samples: number[]; CountInclusive: number[]; CountExclusive: number[]; TotalSeconds: number; - TotalTraces: number; - SampledTraces: number; -} - -export enum FlameGraphComparisonMode { - Absolute = 'absolute', - Relative = 'relative', -} - -/* - * Helper to calculate the color of a given block to be drawn. The desirable outcomes of this are: - * Each of the following frame types should get a different set of color hues: - * - * 0 = Unsymbolized frame - * 1 = Python - * 2 = PHP - * 3 = Native - * 4 = Kernel - * 5 = JVM/Hotspot - * 6 = Ruby - * 7 = Perl - * 8 = JavaScript - * - * This is most easily achieved by mapping frame types to different color variations, using - * the x-position we can use different colors for adjacent blocks while keeping a similar hue - * - * Taken originally from prodfiler_ui/src/helpers/Pixi/frameTypeToColors.tsx - */ -const frameTypeToColors = [ - [0xfd8484, 0xfd9d9d, 0xfeb5b5, 0xfecece], - [0xfcae6b, 0xfdbe89, 0xfdcea6, 0xfedfc4], - [0xfcdb82, 0xfde29b, 0xfde9b4, 0xfef1cd], - [0x6dd0dc, 0x8ad9e3, 0xa7e3ea, 0xc5ecf1], - [0x7c9eff, 0x96b1ff, 0xb0c5ff, 0xcbd8ff], - [0x65d3ac, 0x84dcbd, 0xa3e5cd, 0xc1edde], - [0xd79ffc, 0xdfb2fd, 0xe7c5fd, 0xefd9fe], - [0xf98bb9, 0xfaa2c7, 0xfbb9d5, 0xfdd1e3], - [0xcbc3e3, 0xd5cfe8, 0xdfdbee, 0xeae7f3], -]; - -function frameTypeToRGB(frameType: number, x: number): number { - return frameTypeToColors[frameType][x % 4]; } -export function rgbToRGBA(rgb: number): number[] { - return [ - Math.floor(rgb / 65536) / 255, - (Math.floor(rgb / 256) % 256) / 255, - (rgb % 256) / 255, - 1.0, - ]; -} - -function normalize(n: number, lower: number, upper: number): number { - return (n - lower) / (upper - lower); -} - -// createFlameGraph encapsulates the tree representation into a serialized form. -export function createFlameGraph( - tree: CalleeTree, - totalSeconds: number, - totalTraces: number, - sampledTraces: number -): ElasticFlameGraph { - const graph: ElasticFlameGraph = { +// createBaseFlameGraph encapsulates the tree representation into a serialized form. +export function createBaseFlameGraph(tree: CalleeTree, totalSeconds: number): BaseFlameGraph { + const graph: BaseFlameGraph = { Size: tree.Size, Edges: new Array(tree.Size), - ID: tree.ID.slice(0, tree.Size), - Label: tree.Label.slice(0, tree.Size), - FrameID: tree.FrameID.slice(0, tree.Size), + FileID: tree.FileID.slice(0, tree.Size), FrameType: tree.FrameType.slice(0, tree.Size), - ExecutableID: tree.FileID.slice(0, tree.Size), + ExeFilename: tree.ExeFilename.slice(0, tree.Size), + AddressOrLine: tree.AddressOrLine.slice(0, tree.Size), + FunctionName: tree.FunctionName.slice(0, tree.Size), + FunctionOffset: tree.FunctionOffset.slice(0, tree.Size), + SourceFilename: tree.SourceFilename.slice(0, tree.Size), + SourceLine: tree.SourceLine.slice(0, tree.Size), - Samples: tree.Samples.slice(0, tree.Size), CountInclusive: tree.CountInclusive.slice(0, tree.Size), CountExclusive: tree.CountExclusive.slice(0, tree.Size), TotalSeconds: totalSeconds, - TotalTraces: totalTraces, - SampledTraces: sampledTraces, }; for (let i = 0; i < tree.Size; i++) { @@ -120,80 +68,79 @@ export function createFlameGraph( return graph; } -// createColumnarViewModel normalizes the columnar representation into a form -// consumed by the flamegraph in the UI. -export function createColumnarViewModel( - flamegraph: ElasticFlameGraph, - assignColors: boolean = true -): ColumnarViewModel { - const numNodes = flamegraph.Size; - const xs = new Float32Array(numNodes); - const ys = new Float32Array(numNodes); +export interface ElasticFlameGraph extends BaseFlameGraph { + ID: string[]; + Label: string[]; +} - const queue = [{ x: 0, depth: 1, node: 0 }]; +// createFlameGraph combines the base flamegraph with CPU-intensive values. +// This allows us to create a flamegraph in two steps (e.g. first on the server +// and finally in the browser). +export function createFlameGraph(base: BaseFlameGraph): ElasticFlameGraph { + const graph: ElasticFlameGraph = { + Size: base.Size, + Edges: base.Edges, - while (queue.length > 0) { - const { x, depth, node } = queue.pop()!; - - xs[node] = x; - ys[node] = depth; - - // For a deterministic result we have to walk the callees in a deterministic - // order. A deterministic result allows deterministic UI views, something - // that users expect. - const children = flamegraph.Edges[node].sort((n1, n2) => { - if (flamegraph.Samples[n1] > flamegraph.Samples[n2]) { - return -1; - } - if (flamegraph.Samples[n1] < flamegraph.Samples[n2]) { - return 1; - } - return flamegraph.ID[n1].localeCompare(flamegraph.ID[n2]); - }); + FileID: base.FileID, + FrameType: base.FrameType, + ExeFilename: base.ExeFilename, + AddressOrLine: base.AddressOrLine, + FunctionName: base.FunctionName, + FunctionOffset: base.FunctionOffset, + SourceFilename: base.SourceFilename, + SourceLine: base.SourceLine, - let delta = 0; - for (const child of children) { - delta += flamegraph.Samples[child]; - } + CountInclusive: base.CountInclusive, + CountExclusive: base.CountExclusive, - for (let i = children.length - 1; i >= 0; i--) { - delta -= flamegraph.Samples[children[i]]; - queue.push({ x: x + delta, depth: depth + 1, node: children[i] }); - } - } + ID: new Array(base.Size), + Label: new Array(base.Size), - const colors = new Float32Array(numNodes * 4); + TotalSeconds: base.TotalSeconds, + }; - if (assignColors) { - for (let i = 0; i < numNodes; i++) { - const rgba = rgbToRGBA(frameTypeToRGB(flamegraph.FrameType[i], xs[i])); - colors.set(rgba, 4 * i); - } - } + const rootFrameGroupID = createFrameGroupID( + graph.FileID[0], + graph.AddressOrLine[0], + graph.ExeFilename[0], + graph.SourceFilename[0], + graph.FunctionName[0] + ); - const position = new Float32Array(numNodes * 2); - const maxX = flamegraph.Samples[0]; - const maxY = ys.reduce((max, n) => (n > max ? n : max), 0); + graph.ID[0] = fnv1a64(new TextEncoder().encode(rootFrameGroupID)); - for (let i = 0; i < numNodes; i++) { - const j = 2 * i; - position[j] = normalize(xs[i], 0, maxX); - position[j + 1] = normalize(maxY - ys[i], 0, maxY); + const queue = [0]; + while (queue.length > 0) { + const parent = queue.pop()!; + for (const child of graph.Edges[parent]) { + const frameGroupID = createFrameGroupID( + graph.FileID[child], + graph.AddressOrLine[child], + graph.ExeFilename[child], + graph.SourceFilename[child], + graph.FunctionName[child] + ); + const bytes = new TextEncoder().encode(graph.ID[parent] + frameGroupID); + graph.ID[child] = fnv1a64(bytes); + queue.push(child); + } } - const size = new Float32Array(numNodes); - - for (let i = 0; i < numNodes; i++) { - size[i] = normalize(flamegraph.Samples[i], 0, maxX); + graph.Label[0] = 'root: Represents 100% of CPU time.'; + + for (let i = 1; i < graph.Size; i++) { + const metadata = createStackFrameMetadata({ + FileID: graph.FileID[i], + FrameType: graph.FrameType[i], + ExeFileName: graph.ExeFilename[i], + AddressOrLine: graph.AddressOrLine[i], + FunctionName: graph.FunctionName[i], + FunctionOffset: graph.FunctionOffset[i], + SourceFilename: graph.SourceFilename[i], + SourceLine: graph.SourceLine[i], + }); + graph.Label[i] = getCalleeLabel(metadata); } - return { - label: flamegraph.Label.slice(0, numNodes), - value: Float64Array.from(flamegraph.Samples.slice(0, numNodes)), - color: colors, - position0: position, - position1: position, - size0: size, - size1: size, - } as ColumnarViewModel; + return graph; } diff --git a/x-pack/plugins/profiling/common/hash.test.ts b/x-pack/plugins/profiling/common/hash.test.ts new file mode 100644 index 0000000000000..eaec348caa0ea --- /dev/null +++ b/x-pack/plugins/profiling/common/hash.test.ts @@ -0,0 +1,49 @@ +/* + * 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 { fnv1a64 } from './hash'; + +function toUint8Array(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +describe('FNV-1a hashing operations', () => { + test('empty', () => { + const input = toUint8Array(''); + const expected = 'cbf29ce484222325'; + + expect(fnv1a64(input)).toEqual(expected); + }); + + test('simple', () => { + const input = toUint8Array('hello world'); + const expected = '779a65e7023cd2e7'; + + expect(fnv1a64(input)).toEqual(expected); + }); + + test('long', () => { + const input = toUint8Array('Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch'); + const expected = '7673401f09f26b0d'; + + expect(fnv1a64(input)).toEqual(expected); + }); + + test('unicode double quotation marks', () => { + const input = toUint8Array('trace:comm = “hello”'); + const expected = '8dada3d28d75245c'; + + expect(fnv1a64(input)).toEqual(expected); + }); + + test('unicode spaces', () => { + const input = toUint8Array('trace:comm\u2000=\u2001"hello"\u3000'); + const expected = '2cdcbb43ff62f74f'; + + expect(fnv1a64(input)).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/profiling/common/hash.ts b/x-pack/plugins/profiling/common/hash.ts new file mode 100644 index 0000000000000..3eab4bde871e0 --- /dev/null +++ b/x-pack/plugins/profiling/common/hash.ts @@ -0,0 +1,79 @@ +/* + * 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. + */ + +// prettier-ignore +const lowerHex = [ + '00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '0a', '0b', '0c', '0d', '0e', '0f', + '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '1a', '1b', '1c', '1d', '1e', '1f', + '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '2a', '2b', '2c', '2d', '2e', '2f', + '30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '3a', '3b', '3c', '3d', '3e', '3f', + '40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '4a', '4b', '4c', '4d', '4e', '4f', + '50', '51', '52', '53', '54', '55', '56', '57', '58', '59', '5a', '5b', '5c', '5d', '5e', '5f', + '60', '61', '62', '63', '64', '65', '66', '67', '68', '69', '6a', '6b', '6c', '6d', '6e', '6f', + '70', '71', '72', '73', '74', '75', '76', '77', '78', '79', '7a', '7b', '7c', '7d', '7e', '7f', + '80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '8a', '8b', '8c', '8d', '8e', '8f', + '90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '9a', '9b', '9c', '9d', '9e', '9f', + 'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'aa', 'ab', 'ac', 'ad', 'ae', 'af', + 'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'b7', 'b8', 'b9', 'ba', 'bb', 'bc', 'bd', 'be', 'bf', + 'c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9', 'ca', 'cb', 'cc', 'cd', 'ce', 'cf', + 'd0', 'd1', 'd2', 'd3', 'd4', 'd5', 'd6', 'd7', 'd8', 'd9', 'da', 'db', 'dc', 'dd', 'de', 'df', + 'e0', 'e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7', 'e8', 'e9', 'ea', 'eb', 'ec', 'ed', 'ee', 'ef', + 'f0', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'fa', 'fb', 'fc', 'fd', 'fe', 'ff', +]; + +// fnv1a64 computes a 64-bit hash of a byte array using the FNV-1a hash function [1]. +// +// Due to the lack of a native uint64 in JavaScript, we operate on 64-bit values using an array +// of 4 uint16s instead. This method follows Knuth's Algorithm M in section 4.3.1 [2] using a +// modified multiword multiplication implementation described in [3]. The modifications include: +// +// * rewrite default algorithm for the special case m = n = 4 +// * unroll loops +// * simplify expressions +// * create pre-computed lookup table for serialization to hexadecimal +// +// 1. https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function +// 2. Knuth, Donald E. The Art of Computer Programming, Volume 2, Third Edition: Seminumerical +// Algorithms. Addison-Wesley, 1998. +// 3. Warren, Henry S. Hacker's Delight. Upper Saddle River, NJ: Addison-Wesley, 2013. + +/* eslint no-bitwise: ["error", { "allow": ["^=", ">>", "&"] }] */ +export function fnv1a64(bytes: Uint8Array): string { + const n = bytes.length; + let [h0, h1, h2, h3] = [0x2325, 0x8422, 0x9ce4, 0xcbf2]; + let [t0, t1, t2, t3] = [0, 0, 0, 0]; + + for (let i = 0; i < n; i++) { + h0 ^= bytes[i]; + + t0 = h0 * 0x01b3; + t1 = h1 * 0x01b3; + t2 = h2 * 0x01b3; + t3 = h3 * 0x01b3; + + t1 += t0 >> 16; + t2 += t1 >> 16; + t2 += h0 * 0x0100; + t3 += h1 * 0x0100; + + h0 = t0 & 0xffff; + h1 = t1 & 0xffff; + h2 = t2 & 0xffff; + h3 = (t3 + (t2 >> 16)) & 0xffff; + } + + return ( + lowerHex[h3 >> 8] + + lowerHex[h3 & 0xff] + + lowerHex[h2 >> 8] + + lowerHex[h2 & 0xff] + + lowerHex[h1 >> 8] + + lowerHex[h1 & 0xff] + + lowerHex[h0 >> 8] + + lowerHex[h0 & 0xff] + ); +} diff --git a/x-pack/plugins/profiling/common/index.ts b/x-pack/plugins/profiling/common/index.ts index 871bc9ee1cd91..01994865abafe 100644 --- a/x-pack/plugins/profiling/common/index.ts +++ b/x-pack/plugins/profiling/common/index.ts @@ -27,7 +27,6 @@ export function getRoutePaths() { TopNThreads: `${BASE_ROUTE_PATH}/topn/threads`, TopNTraces: `${BASE_ROUTE_PATH}/topn/traces`, Flamechart: `${BASE_ROUTE_PATH}/flamechart`, - FrameInformation: `${BASE_ROUTE_PATH}/frame_information`, }; } diff --git a/x-pack/plugins/profiling/common/profiling.ts b/x-pack/plugins/profiling/common/profiling.ts index 6779a16774959..f9d882e97c286 100644 --- a/x-pack/plugins/profiling/common/profiling.ts +++ b/x-pack/plugins/profiling/common/profiling.ts @@ -187,7 +187,7 @@ export function getCalleeLabel(metadata: StackFrameMetadata) { if (metadata.FunctionName !== '') { const sourceFilename = metadata.SourceFilename; const sourceURL = sourceFilename ? sourceFilename.split('/').pop() : ''; - return `${getExeFileName(metadata)}: ${getFunctionName(metadata)} in ${sourceURL} #${ + return `${getExeFileName(metadata)}: ${getFunctionName(metadata)} in ${sourceURL}#${ metadata.SourceLine }`; } diff --git a/x-pack/plugins/profiling/public/components/flame_graphs_view/flamegraph_information_window.tsx b/x-pack/plugins/profiling/public/components/flame_graphs_view/flamegraph_information_window.tsx index 39795474763be..ba33c196b453a 100644 --- a/x-pack/plugins/profiling/public/components/flame_graphs_view/flamegraph_information_window.tsx +++ b/x-pack/plugins/profiling/public/components/flame_graphs_view/flamegraph_information_window.tsx @@ -9,7 +9,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, - EuiLoadingSpinner, EuiPanel, EuiText, EuiTitle, @@ -17,7 +16,6 @@ import { import { i18n } from '@kbn/i18n'; import React from 'react'; import { NOT_AVAILABLE_LABEL } from '../../../common'; -import { AsyncStatus } from '../../hooks/use_async'; import { getImpactRows } from './get_impact_rows'; interface Props { @@ -31,7 +29,6 @@ interface Props { totalSamples: number; totalSeconds: number; onClose: () => void; - status: AsyncStatus; } function KeyValueList({ rows }: { rows: Array<{ label: string; value: React.ReactNode }> }) { @@ -61,11 +58,9 @@ function KeyValueList({ rows }: { rows: Array<{ label: string; value: React.Reac function FlamegraphFrameInformationPanel({ children, onClose, - status, }: { children: React.ReactNode; onClose: () => void; - status: AsyncStatus; }) { return ( @@ -83,11 +78,6 @@ function FlamegraphFrameInformationPanel({
- {status === AsyncStatus.Loading ? ( - - - - ) : undefined}
@@ -101,16 +91,10 @@ function FlamegraphFrameInformationPanel({ ); } -export function FlamegraphInformationWindow({ - onClose, - frame, - totalSamples, - totalSeconds, - status, -}: Props) { +export function FlamegraphInformationWindow({ onClose, frame, totalSamples, totalSeconds }: Props) { if (!frame) { return ( - + {i18n.translate('xpack.profiling.flamegraphInformationWindow.selectFrame', { defaultMessage: 'Click on a frame to display more information', @@ -130,7 +114,7 @@ export function FlamegraphInformationWindow({ }); return ( - + = ({ }) => { const theme = useEuiTheme(); - const { - services: { fetchFrameInformation }, - } = useProfilingDependencies(); - const columnarData = useMemo(() => { return getFlamegraphModel({ primaryFlamegraph, @@ -193,38 +187,13 @@ export const FlameGraph: React.FC = ({ const [highlightedVmIndex, setHighlightedVmIndex] = useState(undefined); - const highlightedFrameQueryParams = useMemo(() => { - if (!primaryFlamegraph || highlightedVmIndex === undefined || highlightedVmIndex === 0) { - return undefined; - } - - const frameID = primaryFlamegraph.FrameID[highlightedVmIndex]; - const executableID = primaryFlamegraph.ExecutableID[highlightedVmIndex]; - - return { - frameID, - executableID, - }; - }, [primaryFlamegraph, highlightedVmIndex]); - - const { data: highlightedFrame, status: highlightedFrameStatus } = useAsync(() => { - if (!highlightedFrameQueryParams) { - return Promise.resolve(undefined); - } - - return fetchFrameInformation({ - frameID: highlightedFrameQueryParams.frameID, - executableID: highlightedFrameQueryParams.executableID, - }); - }, [highlightedFrameQueryParams, fetchFrameInformation]); - const selected: undefined | React.ComponentProps['frame'] = - primaryFlamegraph && highlightedFrame && highlightedVmIndex !== undefined + primaryFlamegraph && highlightedVmIndex !== undefined ? { - exeFileName: highlightedFrame.ExeFileName, - sourceFileName: highlightedFrame.SourceFilename, - functionName: highlightedFrame.FunctionName, - countInclusive: primaryFlamegraph.Samples[highlightedVmIndex], + exeFileName: primaryFlamegraph.ExeFilename[highlightedVmIndex], + sourceFileName: primaryFlamegraph.SourceFilename[highlightedVmIndex], + functionName: primaryFlamegraph.FunctionName[highlightedVmIndex], + countInclusive: primaryFlamegraph.CountInclusive[highlightedVmIndex], countExclusive: primaryFlamegraph.CountExclusive[highlightedVmIndex], } : undefined; @@ -271,7 +240,7 @@ export const FlameGraph: React.FC = ({ const valueIndex = props.values[0].valueAccessor as number; const label = primaryFlamegraph.Label[valueIndex]; - const samples = primaryFlamegraph.Samples[valueIndex]; + const samples = primaryFlamegraph.CountInclusive[valueIndex]; const countInclusive = primaryFlamegraph.CountInclusive[valueIndex]; const countExclusive = primaryFlamegraph.CountExclusive[valueIndex]; const nodeID = primaryFlamegraph.ID[valueIndex]; @@ -287,8 +256,8 @@ export const FlameGraph: React.FC = ({ comparisonCountInclusive={comparisonNode?.CountInclusive} comparisonCountExclusive={comparisonNode?.CountExclusive} totalSamples={totalSamples} - comparisonTotalSamples={comparisonFlamegraph?.Samples[0]} - comparisonSamples={comparisonNode?.Samples} + comparisonTotalSamples={comparisonFlamegraph?.CountInclusive[0]} + comparisonSamples={comparisonNode?.CountInclusive} /> ); }, @@ -309,7 +278,6 @@ export const FlameGraph: React.FC = ({ { diff --git a/x-pack/plugins/profiling/public/services.ts b/x-pack/plugins/profiling/public/services.ts index 07234ca124b36..a1f345dc96a2c 100644 --- a/x-pack/plugins/profiling/public/services.ts +++ b/x-pack/plugins/profiling/public/services.ts @@ -7,9 +7,8 @@ import { CoreStart, HttpFetchQuery } from '@kbn/core/public'; import { getRoutePaths } from '../common'; -import { ElasticFlameGraph } from '../common/flamegraph'; +import { BaseFlameGraph, createFlameGraph, ElasticFlameGraph } from '../common/flamegraph'; import { TopNFunctions } from '../common/functions'; -import { StackFrameMetadata } from '../common/profiling'; import { TopNResponse } from '../common/topn'; export interface Services { @@ -31,10 +30,6 @@ export interface Services { timeTo: number; kuery: string; }) => Promise; - fetchFrameInformation: (params: { - frameID: string; - executableID: string; - }) => Promise; } export function getServices(core: CoreStart): Services { @@ -96,24 +91,8 @@ export function getServices(core: CoreStart): Services { timeTo, kuery, }; - return await core.http.get(paths.Flamechart, { query }); - } catch (e) { - return e; - } - }, - fetchFrameInformation: async ({ - frameID, - executableID, - }: { - frameID: string; - executableID: string; - }) => { - try { - const query: HttpFetchQuery = { - frameID, - executableID, - }; - return await core.http.get(paths.FrameInformation, { query }); + const baseFlamegraph: BaseFlameGraph = await core.http.get(paths.Flamechart, { query }); + return createFlameGraph(baseFlamegraph); } catch (e) { return e; } diff --git a/x-pack/plugins/profiling/public/utils/get_flamegraph_model/index.ts b/x-pack/plugins/profiling/public/utils/get_flamegraph_model/index.ts index c63a73185d26b..4cda7befe44c9 100644 --- a/x-pack/plugins/profiling/public/utils/get_flamegraph_model/index.ts +++ b/x-pack/plugins/profiling/public/utils/get_flamegraph_model/index.ts @@ -6,12 +6,8 @@ */ import d3 from 'd3'; import { sum, uniqueId } from 'lodash'; -import { - createColumnarViewModel, - ElasticFlameGraph, - FlameGraphComparisonMode, - rgbToRGBA, -} from '../../../common/flamegraph'; +import { createColumnarViewModel, rgbToRGBA } from '../../../common/columnar_view_model'; +import { ElasticFlameGraph, FlameGraphComparisonMode } from '../../../common/flamegraph'; import { getInterpolationValue } from './get_interpolation_value'; const nullColumnarViewModel = { @@ -39,10 +35,8 @@ export function getFlamegraphModel({ colorNeutral: string; comparisonMode: FlameGraphComparisonMode; }) { - const comparisonNodesById: Record< - string, - { Samples: number; CountInclusive: number; CountExclusive: number } - > = {}; + const comparisonNodesById: Record = + {}; if (!primaryFlamegraph || !primaryFlamegraph.Label || primaryFlamegraph.Label.length === 0) { return { key: uniqueId(), viewModel: nullColumnarViewModel, comparisonNodesById }; @@ -53,7 +47,6 @@ export function getFlamegraphModel({ if (comparisonFlamegraph) { comparisonFlamegraph.ID.forEach((nodeID, index) => { comparisonNodesById[nodeID] = { - Samples: comparisonFlamegraph.Samples[index], CountInclusive: comparisonFlamegraph.CountInclusive[index], CountExclusive: comparisonFlamegraph.CountExclusive[index], }; @@ -88,8 +81,8 @@ export function getFlamegraphModel({ : primaryFlamegraph.TotalSeconds / comparisonFlamegraph.TotalSeconds; primaryFlamegraph.ID.forEach((nodeID, index) => { - const samples = primaryFlamegraph.Samples[index]; - const comparisonSamples = comparisonNodesById[nodeID]?.Samples as number | undefined; + const samples = primaryFlamegraph.CountInclusive[index]; + const comparisonSamples = comparisonNodesById[nodeID]?.CountInclusive as number | undefined; const foreground = comparisonMode === FlameGraphComparisonMode.Absolute ? samples : samples / totalSamples; diff --git a/x-pack/plugins/profiling/server/routes/flamechart.ts b/x-pack/plugins/profiling/server/routes/flamechart.ts index 6d27305a82c69..772d505817484 100644 --- a/x-pack/plugins/profiling/server/routes/flamechart.ts +++ b/x-pack/plugins/profiling/server/routes/flamechart.ts @@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema'; import { RouteRegisterParameters } from '.'; import { getRoutePaths } from '../../common'; import { createCalleeTree } from '../../common/callee'; -import { createFlameGraph } from '../../common/flamegraph'; +import { createBaseFlameGraph } from '../../common/flamegraph'; import { createProfilingEsClient } from '../utils/create_profiling_es_client'; import { withProfilingSpan } from '../utils/with_profiling_span'; import { getClient } from './compat'; @@ -42,20 +42,13 @@ export function registerFlameChartSearchRoute({ router, logger }: RouteRegisterP }); const totalSeconds = timeTo - timeFrom; - const { - stackTraces, - executables, - stackFrames, - eventsIndex, - totalCount, - totalFrames, - stackTraceEvents, - } = await getExecutablesAndStackTraces({ - logger, - client: createProfilingEsClient({ request, esClient }), - filter, - sampleSize: targetSampleSize, - }); + const { stackTraceEvents, stackTraces, executables, stackFrames, totalFrames } = + await getExecutablesAndStackTraces({ + logger, + client: createProfilingEsClient({ request, esClient }), + filter, + sampleSize: targetSampleSize, + }); const flamegraph = await withProfilingSpan('create_flamegraph', async () => { const t0 = Date.now(); @@ -68,23 +61,8 @@ export function registerFlameChartSearchRoute({ router, logger }: RouteRegisterP ); logger.info(`creating callee tree took ${Date.now() - t0} ms`); - // sampleRate is 1/5^N, with N being the downsampled index the events were fetched from. - // N=0: full events table (sampleRate is 1) - // N=1: downsampled by 5 (sampleRate is 0.2) - // ... - - // totalCount is the sum(Count) of all events in the filter range in the - // downsampled index we were looking at. - // To estimate how many events we have in the full events index: totalCount / sampleRate. - // Do the same for single entries in the events array. - const t1 = Date.now(); - const fg = createFlameGraph( - tree, - totalSeconds, - Math.floor(totalCount / eventsIndex.sampleRate), - totalCount - ); + const fg = createBaseFlameGraph(tree, totalSeconds); logger.info(`creating flamegraph took ${Date.now() - t1} ms`); return fg; diff --git a/x-pack/plugins/profiling/server/routes/frames.ts b/x-pack/plugins/profiling/server/routes/frames.ts deleted file mode 100644 index 4a0ce745c7246..0000000000000 --- a/x-pack/plugins/profiling/server/routes/frames.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; -import { Logger } from '@kbn/logging'; -import { RouteRegisterParameters } from '.'; -import { getRoutePaths } from '../../common'; -import { - createStackFrameMetadata, - Executable, - StackFrame, - StackFrameMetadata, -} from '../../common/profiling'; -import { createProfilingEsClient, ProfilingESClient } from '../utils/create_profiling_es_client'; -import { mgetStackFrames, mgetExecutables } from './stacktrace'; - -async function getFrameInformation({ - frameID, - executableID, - logger, - client, -}: { - frameID: string; - executableID: string; - logger: Logger; - client: ProfilingESClient; -}): Promise { - const [stackFrames, executables] = await Promise.all([ - mgetStackFrames({ - logger, - client, - stackFrameIDs: new Set([frameID]), - }), - mgetExecutables({ - logger, - client, - executableIDs: new Set([executableID]), - }), - ]); - - const frame = Array.from(stackFrames.values())[0] as StackFrame | undefined; - const executable = Array.from(executables.values())[0] as Executable | undefined; - - if (frame) { - return createStackFrameMetadata({ - FrameID: frameID, - FileID: executableID, - SourceFilename: frame.FileName, - FunctionName: frame.FunctionName, - ExeFileName: executable?.FileName, - }); - } -} - -export function registerFrameInformationRoute(params: RouteRegisterParameters) { - const { logger, router } = params; - - const routePaths = getRoutePaths(); - - router.get( - { - path: routePaths.FrameInformation, - validate: { - query: schema.object({ - frameID: schema.string(), - executableID: schema.string(), - }), - }, - }, - async (context, request, response) => { - const { frameID, executableID } = request.query; - - const client = createProfilingEsClient({ - request, - esClient: (await context.core).elasticsearch.client.asCurrentUser, - }); - - try { - const frame = await getFrameInformation({ - frameID, - executableID, - logger, - client, - }); - - return response.ok({ body: frame }); - } catch (error: any) { - logger.error(error); - return response.custom({ - statusCode: error.statusCode ?? 500, - body: { - message: error.message ?? 'An internal server error occured', - }, - }); - } - } - ); -} diff --git a/x-pack/plugins/profiling/server/routes/index.ts b/x-pack/plugins/profiling/server/routes/index.ts index 6e44bf6909585..b6bd705ba0e07 100644 --- a/x-pack/plugins/profiling/server/routes/index.ts +++ b/x-pack/plugins/profiling/server/routes/index.ts @@ -13,7 +13,6 @@ import { } from '../types'; import { registerFlameChartSearchRoute } from './flamechart'; -import { registerFrameInformationRoute } from './frames'; import { registerTopNFunctionsSearchRoute } from './functions'; import { @@ -41,5 +40,4 @@ export function registerRoutes(params: RouteRegisterParameters) { registerTraceEventsTopNHostsSearchRoute(params); registerTraceEventsTopNStackTracesSearchRoute(params); registerTraceEventsTopNThreadsSearchRoute(params); - registerFrameInformationRoute(params); } diff --git a/x-pack/plugins/profiling/server/routes/stacktrace.test.ts b/x-pack/plugins/profiling/server/routes/stacktrace.test.ts index a40f72d25f2d8..5dd3f1985a35a 100644 --- a/x-pack/plugins/profiling/server/routes/stacktrace.test.ts +++ b/x-pack/plugins/profiling/server/routes/stacktrace.test.ts @@ -118,6 +118,14 @@ describe('Stack trace operations', () => { } }); + test('runLengthDecode with larger output than available input', () => { + const bytes = Buffer.from([0x5, 0x0, 0x2, 0x2]); + const decoded = [0, 0, 0, 0, 0, 2, 2]; + const expected = decoded.concat(Array(decoded.length).fill(0)); + + expect(runLengthDecode(bytes, expected.length)).toEqual(expected); + }); + test('runLengthDecode without optional parameter', () => { const tests: Array<{ bytes: Buffer; diff --git a/x-pack/plugins/profiling/server/routes/stacktrace.ts b/x-pack/plugins/profiling/server/routes/stacktrace.ts index 4ae7d91596f10..6dbe063e2c4f9 100644 --- a/x-pack/plugins/profiling/server/routes/stacktrace.ts +++ b/x-pack/plugins/profiling/server/routes/stacktrace.ts @@ -122,6 +122,17 @@ export function runLengthDecode(input: Buffer, outputSize?: number): number[] { } } + // Due to truncation of the frame types for stacktraces longer than 255, + // the expected output size and the actual decoded size can be different. + // Ordinarily, these two values should be the same. + // + // We have decided to fill in the remainder of the output array with zeroes + // as a reasonable default. Without this step, the output array would have + // undefined values. + for (let i = idx; i < size; i++) { + output[i] = 0; + } + return output; } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 7358fbb33b4b2..6357dfd766a4c 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -22,6 +22,7 @@ export const APP_ICON = 'securityAnalyticsApp' as const; export const APP_ICON_SOLUTION = 'logoSecurity' as const; export const APP_PATH = `/app/security` as const; export const ADD_DATA_PATH = `/app/integrations/browse/security`; +export const ADD_THREAT_INTELLIGENCE_DATA_PATH = `/app/integrations/browse/threat_intel`; export const DEFAULT_BYTES_FORMAT = 'format:bytes:defaultPattern' as const; export const DEFAULT_DATE_FORMAT = 'dateFormat' as const; export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz' as const; @@ -152,6 +153,7 @@ export const USERS_PATH = '/users' as const; export const KUBERNETES_PATH = '/kubernetes' as const; export const NETWORK_PATH = '/network' as const; export const MANAGEMENT_PATH = '/administration' as const; +export const THREAT_INTELLIGENCE_PATH = '/threat_intelligence' as const; export const ENDPOINTS_PATH = `${MANAGEMENT_PATH}/endpoints` as const; export const POLICIES_PATH = `${MANAGEMENT_PATH}/policy` as const; export const TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/trusted_apps` as const; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 8f0bd47c204cb..19df6122044a4 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -41,6 +41,11 @@ export const METADATA_UNITED_INDEX = '.metrics-endpoint.metadata_united_default' export const policyIndexPattern = 'metrics-endpoint.policy-*'; export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; +// File storage indexes supporting endpoint Upload/download +export const FILE_STORAGE_METADATA_INDEX = '.fleet-files'; +export const FILE_STORAGE_DATA_INDEX = '.fleet-file_data'; + +// Endpoint API routes export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; export const HOST_METADATA_LIST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; export const HOST_METADATA_GET_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata/{id}`; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts index 9f1c0495f162d..971f096300e66 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts @@ -18,6 +18,8 @@ import type { LogsEndpointAction, LogsEndpointActionResponse, ProcessesEntry, + EndpointActionDataParameterTypes, + ActionResponseOutput, } from '../types'; import { ActivityLogItemTypes } from '../types'; import { RESPONSE_ACTION_COMMANDS } from '../service/response_actions/constants'; @@ -75,6 +77,32 @@ export class EndpointActionGenerator extends BaseDataGenerator { ); }); + const command = overrides?.EndpointActions?.data?.command ?? this.randomResponseActionCommand(); + let parameters: EndpointActionDataParameterTypes = overrides?.EndpointActions?.data?.parameters; + let output: ActionResponseOutput = overrides?.EndpointActions?.data + ?.output as ActionResponseOutput; + + if (command === 'get-file') { + if (!parameters) { + parameters = { + file: '/some/path/bad_file.txt', + }; + } + + if (!output) { + output = { + type: 'json', + content: { + file: { + name: 'bad_file.txt', + path: '/some/path/bad_file.txt', + size: 221, + }, + }, + }; + } + } + return merge( { '@timestamp': timeStamp.toISOString(), @@ -84,14 +112,14 @@ export class EndpointActionGenerator extends BaseDataGenerator { EndpointActions: { action_id: this.seededUUIDv4(), completed_at: timeStamp.toISOString(), + // randomly before a few hours/minutes/seconds later + started_at: new Date(startedAtTimes[this.randomN(startedAtTimes.length)]).toISOString(), data: { - command: this.randomResponseActionCommand(), + command, comment: '', - parameters: undefined, + parameters, + output, }, - // randomly before a few hours/minutes/seconds later - started_at: new Date(startedAtTimes[this.randomN(startedAtTimes.length)]).toISOString(), - output: undefined, }, error: undefined, }, @@ -160,7 +188,7 @@ export class EndpointActionGenerator extends BaseDataGenerator { type: ActivityLogItemTypes.RESPONSE, item: { id: this.seededUUIDv4(), - data: this.generateResponse(), + data: this.generateResponse({ ...(overrides?.item?.data ?? {}) }), }, }, overrides diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts index dee5d94b4e62b..5a22f37e0e006 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts @@ -13,5 +13,6 @@ export const RESPONSE_ACTION_COMMANDS = [ 'kill-process', 'suspend-process', 'running-processes', + 'get-file', ] as const; export type ResponseActions = typeof RESPONSE_ACTION_COMMANDS[number]; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index 91eb10c5f45a2..40a65e886fb6d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -62,9 +62,12 @@ interface EcsError { type?: string; } -interface EndpointActionFields { +interface EndpointActionFields< + TParameters extends EndpointActionDataParameterTypes = never, + TOutputContent extends object = object +> { action_id: string; - data: EndpointActionData; + data: EndpointActionData; } interface ActionRequestFields { @@ -98,12 +101,15 @@ export interface LogsEndpointAction { * An Action response written by the endpoint to the Endpoint `.logs-endpoint.action.responses` datastream * @since v7.16 */ -export interface LogsEndpointActionResponse { +export interface LogsEndpointActionResponse< + TParameters extends EndpointActionDataParameterTypes = never, + TOutputContent extends object = object +> { '@timestamp': string; agent: { id: string | string[]; }; - EndpointActions: EndpointActionFields & ActionResponseFields; + EndpointActions: EndpointActionFields & ActionResponseFields; error?: EcsError; } @@ -121,9 +127,14 @@ export type ResponseActionParametersWithPidOrEntityId = | ResponseActionParametersWithPid | ResponseActionParametersWithEntityId; +export interface ResponseActionGetFileParameters { + file: string; +} + export type EndpointActionDataParameterTypes = | undefined - | ResponseActionParametersWithPidOrEntityId; + | ResponseActionParametersWithPidOrEntityId + | ResponseActionGetFileParameters; export interface EndpointActionData< T extends EndpointActionDataParameterTypes = never, diff --git a/x-pack/plugins/security_solution/common/endpoint/types/file_storage.ts b/x-pack/plugins/security_solution/common/endpoint/types/file_storage.ts new file mode 100644 index 0000000000000..9ed8065197921 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/types/file_storage.ts @@ -0,0 +1,49 @@ +/* + * 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. + */ + +/** + * The Metadata information about a file that was uploaded by Endpoint + * as a result of a `get-file` response action + */ +export interface UploadedFile { + file: { + /** The chunk size used for each chunk in this file */ + ChunkSize?: number; + /** + * - `AWAITING_UPLOAD`: file metadata has been created. File is ready to be uploaded. + * - `UPLOADING`: file contents are being uploaded. + * - `READY`: file has been uploaded, successfully, without errors. + * - `UPLOAD_ERROR`: an error happened while the file was being uploaded, file contents + * are most likely corrupted. + * - `DELETED`: file is deleted. Files can be marked as deleted before the actual deletion + * of the contents and metadata happens. Deleted files should be treated as if they don’t + * exist. Only files in READY state can transition into DELETED state. + */ + Status: 'AWAITING_UPLOAD' | 'UPLOADING' | 'READY' | 'UPLOAD_ERROR' | 'DELETED'; + /** File extension (if any) */ + extension?: string; + hash?: { + md5?: string; + sha1?: string; + sha256?: string; + sha384?: string; + sha512?: string; + ssdeep?: string; + tlsh?: string; + }; + mime_type?: string; + mode?: string; + /** File name */ + name: string; + /** The full path to the file on the host machine */ + path: string; + /** The total size in bytes */ + size: number; + created?: string; + type: string; + }; +} diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx index 87b0b5a7da6ed..bd5049909c95d 100644 --- a/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx @@ -9,7 +9,12 @@ import { render } from '@testing-library/react'; import { useLocation } from 'react-router-dom'; import { useVariationMock } from '../../../common/components/utils.mocks'; import { GlobalHeader } from '.'; -import { ADD_DATA_PATH, SecurityPageName } from '../../../../common/constants'; +import { + ADD_DATA_PATH, + ADD_THREAT_INTELLIGENCE_DATA_PATH, + SecurityPageName, + THREAT_INTELLIGENCE_PATH, +} from '../../../../common/constants'; import { createSecuritySolutionStorageMock, mockGlobalState, @@ -98,6 +103,17 @@ describe('global header', () => { expect(link?.getAttribute('href')).toBe(ADD_DATA_PATH); }); + it('points to the threat_intel Add data URL for threat_intelligence url', () => { + (useLocation as jest.Mock).mockReturnValue({ pathname: THREAT_INTELLIGENCE_PATH }); + const { queryByTestId } = render( + + + + ); + const link = queryByTestId('add-data'); + expect(link?.getAttribute('href')).toBe(ADD_THREAT_INTELLIGENCE_DATA_PATH); + }); + it('points to the resolved Add data URL by useVariation', () => { (useLocation as jest.Mock).mockReturnValue([ { pageName: SecurityPageName.overview, detailName: undefined }, diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx index 3b405df246601..37efdce430317 100644 --- a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx @@ -20,8 +20,8 @@ import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { useVariation } from '../../../common/components/utils'; import { MlPopover } from '../../../common/components/ml_popover/ml_popover'; import { useKibana } from '../../../common/lib/kibana'; -import { ADD_DATA_PATH } from '../../../../common/constants'; -import { isDetectionsPath } from '../../../helpers'; +import { ADD_DATA_PATH, ADD_THREAT_INTELLIGENCE_DATA_PATH } from '../../../../common/constants'; +import { isDetectionsPath, isThreatIntelligencePath } from '../../../helpers'; import { Sourcerer } from '../../../common/components/sourcerer'; import { TimelineId } from '../../../../common/types/timeline'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; @@ -58,7 +58,10 @@ export const GlobalHeader = React.memo( const sourcererScope = getScopeFromPath(pathname); const showSourcerer = showSourcererByPath(pathname); - const [addIntegrationsUrl, setAddIntegrationsUrl] = useState(ADD_DATA_PATH); + const integrationsUrl = isThreatIntelligencePath(pathname) + ? ADD_THREAT_INTELLIGENCE_DATA_PATH + : ADD_DATA_PATH; + const [addIntegrationsUrl, setAddIntegrationsUrl] = useState(integrationsUrl); useVariation( cloudExperiments, 'security-solutions.add-integrations-url', diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/insights/insights.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/insights/insights.test.tsx index b31cf1d1252bc..5ab2a172bc6e1 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/insights/insights.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/insights/insights.test.tsx @@ -146,7 +146,7 @@ describe('Insights', () => { expect(screen.getByTestId('related-alerts-by-ancestry')).toBeInTheDocument(); expect( - screen.queryByRole('link', { name: new RegExp(i18n.ALERT_UPSELL) }) + screen.queryByRole('button', { name: new RegExp(i18n.INSIGHTS_UPSELL) }) ).not.toBeInTheDocument(); }); @@ -179,7 +179,7 @@ describe('Insights', () => { ); expect( - screen.getByRole('link', { name: new RegExp(i18n.ALERT_UPSELL) }) + screen.getByRole('button', { name: new RegExp(i18n.INSIGHTS_UPSELL) }) ).toBeInTheDocument(); expect(screen.queryByTestId('related-alerts-by-ancestry')).not.toBeInTheDocument(); }); @@ -198,7 +198,7 @@ describe('Insights', () => { expect(screen.queryByTestId('related-alerts-by-ancestry')).not.toBeInTheDocument(); expect( - screen.queryByRole('link', { name: new RegExp(i18n.ALERT_UPSELL) }) + screen.queryByRole('button', { name: new RegExp(i18n.INSIGHTS_UPSELL) }) ).not.toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_alerts_upsell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_alerts_upsell.tsx index 37ac07e3c03d0..ba225495a032b 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_alerts_upsell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_alerts_upsell.tsx @@ -5,11 +5,12 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiIcon, EuiText } from '@elastic/eui'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { ALERT_UPSELL } from './translations'; +import { INSIGHTS_UPSELL } from './translations'; +import { useNavigation } from '../../../lib/kibana'; const UpsellContainer = euiStyled.div` border: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; @@ -22,6 +23,15 @@ const StyledIcon = euiStyled(EuiIcon)` `; export const RelatedAlertsUpsell = React.memo(() => { + const { getAppUrl, navigateTo } = useNavigation(); + const subscriptionUrl = getAppUrl({ + appId: 'management', + path: 'stack/license_management', + }); + const goToSubscription = useCallback(() => { + navigateTo({ url: subscriptionUrl }); + }, [navigateTo, subscriptionUrl]); + return ( @@ -30,13 +40,8 @@ export const RelatedAlertsUpsell = React.memo(() => { - - {ALERT_UPSELL} + + {INSIGHTS_UPSELL} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/insights/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/insights/translations.ts index e839003e1f7f9..10d9efca4682d 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/insights/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/insights/translations.ts @@ -136,9 +136,9 @@ export const SIMPLE_ALERT_TABLE_LIMITED = i18n.translate( } ); -export const ALERT_UPSELL = i18n.translate( +export const INSIGHTS_UPSELL = i18n.translate( 'xpack.securitySolution.alertDetails.overview.insights.alertUpsellTitle', { - defaultMessage: 'Get more insights with a subscription', + defaultMessage: 'Get more insights with a platinum subscription', } ); diff --git a/x-pack/plugins/security_solution/public/helpers.tsx b/x-pack/plugins/security_solution/public/helpers.tsx index 4520f70e3ab76..df0a660f4def4 100644 --- a/x-pack/plugins/security_solution/public/helpers.tsx +++ b/x-pack/plugins/security_solution/public/helpers.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import { ALERT_RULE_UUID, ALERT_RULE_NAME, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; -import { has, get, isEmpty } from 'lodash/fp'; +import { ALERT_RULE_NAME, ALERT_RULE_PARAMETERS, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; +import { get, has, isEmpty } from 'lodash/fp'; import React from 'react'; import type { RouteProps } from 'react-router-dom'; import { matchPath, Redirect } from 'react-router-dom'; @@ -15,12 +15,13 @@ import type { Capabilities, CoreStart } from '@kbn/core/public'; import { ALERTS_PATH, APP_UI_ID, + CASES_FEATURE_ID, + CASES_PATH, EXCEPTIONS_PATH, + LANDING_PATH, RULES_PATH, SERVER_APP_ID, - CASES_FEATURE_ID, - LANDING_PATH, - CASES_PATH, + THREAT_INTELLIGENCE_PATH, } from '../common/constants'; import type { Ecs } from '../common/ecs'; import type { @@ -164,6 +165,13 @@ export const isDetectionsPath = (pathname: string): boolean => { }); }; +export const isThreatIntelligencePath = (pathname: string): boolean => { + return !!matchPath(pathname, { + path: `(${THREAT_INTELLIGENCE_PATH})`, + strict: false, + }); +}; + export const getSubPluginRoutesByCapabilities = ( subPlugins: StartedSubPlugins, capabilities: Capabilities diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx index 0133e09ac7202..ae59f3ceaa8b3 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_response_actions_list/response_actions_log.test.tsx @@ -20,6 +20,7 @@ import { MANAGEMENT_PATH } from '../../../../common/constants'; import { getActionListMock } from './mocks'; import { useGetEndpointsList } from '../../hooks/endpoint/use_get_endpoints_list'; import uuid from 'uuid'; +import { RESPONSE_ACTION_COMMANDS } from '../../../../common/endpoint/service/response_actions/constants'; let mockUseGetEndpointActionList: { isFetched?: boolean; @@ -556,10 +557,10 @@ describe('Response actions history', () => { userEvent.click(getByTestId(`${testPrefix}-${filterPrefix}-popoverButton`)); const filterList = getByTestId(`${testPrefix}-${filterPrefix}-popoverList`); expect(filterList).toBeTruthy(); - expect(filterList.querySelectorAll('ul>li').length).toEqual(5); + expect(filterList.querySelectorAll('ul>li').length).toEqual(RESPONSE_ACTION_COMMANDS.length); expect( Array.from(filterList.querySelectorAll('ul>li')).map((option) => option.textContent) - ).toEqual(['isolate', 'release', 'kill-process', 'suspend-process', 'processes']); + ).toEqual(['isolate', 'release', 'kill-process', 'suspend-process', 'processes', 'get-file']); }); it('should have `clear all` button `disabled` when no selected values', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx index 7b0132a4b4be4..d42585485bb94 100644 --- a/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/response_actions/view/response_actions_list_page.test.tsx @@ -375,7 +375,7 @@ describe('Response actions history page', () => { }); expect(history.location.search).toEqual( - '?commands=isolate%2Crelease%2Ckill-process%2Csuspend-process%2Cprocesses' + '?commands=isolate%2Crelease%2Ckill-process%2Csuspend-process%2Cprocesses%2Cget-file' ); }); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/utils.ts b/x-pack/plugins/security_solution/scripts/endpoint/action_responder/utils.ts index 59bf6e6da303f..0853500b83c73 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/action_responder/utils.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/action_responder/utils.ts @@ -8,11 +8,14 @@ import type { KbnClient } from '@kbn/test'; import type { Client } from '@elastic/elasticsearch'; import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; +import type { UploadedFile } from '../../../common/endpoint/types/file_storage'; import { sendEndpointMetadataUpdate } from '../common/endpoint_metadata_services'; import { FleetActionGenerator } from '../../../common/endpoint/data_generators/fleet_action_generator'; import { ENDPOINT_ACTION_RESPONSES_INDEX, ENDPOINTS_ACTION_LIST_ROUTE, + FILE_STORAGE_DATA_INDEX, + FILE_STORAGE_METADATA_INDEX, } from '../../../common/endpoint/constants'; import type { ActionDetails, @@ -144,6 +147,40 @@ export const sendEndpointActionResponse = async ( } } + // For `get-file`, upload a file to ES + if (action.command === 'get-file' && !endpointResponse.error) { + // Add the file's metadata + const fileMeta = await esClient.index({ + index: FILE_STORAGE_METADATA_INDEX, + id: `${action.id}.${action.hosts[0]}`, + body: { + file: { + created: new Date().toISOString(), + extension: 'zip', + path: '/some/path/bad_file.txt', + type: 'file', + size: 221, + name: 'bad_file.txt.zip', + mime_type: 'application/zip', + Status: 'READY', + ChunkSize: 4194304, + }, + }, + refresh: 'wait_for', + }); + + await esClient.index({ + index: FILE_STORAGE_DATA_INDEX, + id: `${fileMeta._id}.0`, + body: { + bid: fileMeta._id, + last: true, + data: 'UEsDBBQACAAIAFVeRFUAAAAAAAAAABMAAAAMACAAYmFkX2ZpbGUudHh0VVQNAAdTVjxjU1Y8Y1NWPGN1eAsAAQT1AQAABBQAAAArycgsVgCiRIWkxBSFtMycVC4AUEsHCKkCwMsTAAAAEwAAAFBLAQIUAxQACAAIAFVeRFWpAsDLEwAAABMAAAAMACAAAAAAAAAAAACkgQAAAABiYWRfZmlsZS50eHRVVA0AB1NWPGNTVjxjU1Y8Y3V4CwABBPUBAAAEFAAAAFBLBQYAAAAAAQABAFoAAABtAAAAAAA=', + }, + refresh: 'wait_for', + }); + } + return endpointResponse; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/mocks.ts index 23705d6bc43be..aec86fe776663 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/mocks.ts @@ -46,8 +46,7 @@ export const createActionResponsesEsSearchResultsMock = ( const fleetActionGenerator = new FleetActionGenerator('seed'); let hitSource: Array< - | estypes.SearchHit - | estypes.SearchHit> + estypes.SearchHit | estypes.SearchHit > = [ fleetActionGenerator.generateResponseEsHit({ action_id: '123', diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts index 8f5fc01c7f2d7..8cc2f72f150d2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts @@ -441,7 +441,18 @@ describe('When using Actions service utilities', () => { completedAt: COMPLETED_AT, wasSuccessful: true, errors: undefined, - outputs: {}, + outputs: { + '456': { + content: { + file: { + name: 'bad_file.txt', + path: '/some/path/bad_file.txt', + size: 221, + }, + }, + type: 'json', + }, + }, agentState: { '123': { completedAt: '2022-01-05T19:27:23.816Z', diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/cases/index.ts index 22f32cf636036..d7a42c3350c82 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/index.ts @@ -8,10 +8,6 @@ export { getCasesWebhookConnectorType } from './cases_webhook'; export { getJiraConnectorType } from './jira'; export { getResilientConnectorType } from './resilient'; -export { - getServiceNowITSMConnectorType, - getServiceNowSIRConnectorType, - getServiceNowITOMConnectorType, -} from './servicenow'; +export { getServiceNowITSMConnectorType } from './servicenow_itsm'; +export { getServiceNowSIRConnectorType } from './servicenow_sir'; export { getSwimlaneConnectorType } from './swimlane'; -export { getXmattersConnectorType } from './xmatters'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow.tsx deleted file mode 100644 index 932d244e852f8..0000000000000 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { lazy } from 'react'; -import { i18n } from '@kbn/i18n'; -import type { - ActionTypeModel as ConnectorTypeModel, - GenericValidationResult, -} from '@kbn/triggers-actions-ui-plugin/public'; -import { - ServiceNowConfig, - ServiceNowITOMActionParams, - ServiceNowITSMActionParams, - ServiceNowSecrets, - ServiceNowSIRActionParams, -} from './types'; -import { getConnectorDescriptiveTitle, getSelectedConnectorIcon } from './helpers'; - -export const SERVICENOW_ITOM_TITLE = i18n.translate( - 'xpack.stackConnectors.components.serviceNowITOM.connectorTypeTitle', - { - defaultMessage: 'ServiceNow ITOM', - } -); - -export const SERVICENOW_ITOM_DESC = i18n.translate( - 'xpack.stackConnectors.components.serviceNowITOM.selectMessageText', - { - defaultMessage: 'Create an event in ServiceNow ITOM.', - } -); - -export const SERVICENOW_ITSM_DESC = i18n.translate( - 'xpack.stackConnectors.components.serviceNowITSM.selectMessageText', - { - defaultMessage: 'Create an incident in ServiceNow ITSM.', - } -); - -export const SERVICENOW_SIR_DESC = i18n.translate( - 'xpack.stackConnectors.components.serviceNowSIR.selectMessageText', - { - defaultMessage: 'Create an incident in ServiceNow SecOps.', - } -); - -export const SERVICENOW_ITSM_TITLE = i18n.translate( - 'xpack.stackConnectors.components.serviceNowITSM.connectorTypeTitle', - { - defaultMessage: 'ServiceNow ITSM', - } -); - -export const SERVICENOW_SIR_TITLE = i18n.translate( - 'xpack.stackConnectors.components.serviceNowSIR.connectorTypeTitle', - { - defaultMessage: 'ServiceNow SecOps', - } -); - -export function getServiceNowITSMConnectorType(): ConnectorTypeModel< - ServiceNowConfig, - ServiceNowSecrets, - ServiceNowITSMActionParams -> { - return { - id: '.servicenow', - iconClass: lazy(() => import('./logo')), - selectMessage: SERVICENOW_ITSM_DESC, - actionTypeTitle: SERVICENOW_ITSM_TITLE, - actionConnectorFields: lazy(() => import('./servicenow_connectors')), - validateParams: async ( - actionParams: ServiceNowITSMActionParams - ): Promise> => { - const translations = await import('./translations'); - const errors = { - 'subActionParams.incident.short_description': new Array(), - }; - const validationResult = { - errors, - }; - if ( - actionParams.subActionParams && - actionParams.subActionParams.incident && - !actionParams.subActionParams.incident.short_description?.length - ) { - errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); - } - return validationResult; - }, - actionParamsFields: lazy(() => import('./servicenow_itsm_params')), - customConnectorSelectItem: { - getText: getConnectorDescriptiveTitle, - getComponent: getSelectedConnectorIcon, - }, - }; -} - -export function getServiceNowSIRConnectorType(): ConnectorTypeModel< - ServiceNowConfig, - ServiceNowSecrets, - ServiceNowSIRActionParams -> { - return { - id: '.servicenow-sir', - iconClass: lazy(() => import('./logo')), - selectMessage: SERVICENOW_SIR_DESC, - actionTypeTitle: SERVICENOW_SIR_TITLE, - actionConnectorFields: lazy(() => import('./servicenow_connectors')), - validateParams: async ( - actionParams: ServiceNowSIRActionParams - ): Promise> => { - const translations = await import('./translations'); - const errors = { - 'subActionParams.incident.short_description': new Array(), - }; - const validationResult = { - errors, - }; - if ( - actionParams.subActionParams && - actionParams.subActionParams.incident && - !actionParams.subActionParams.incident.short_description?.length - ) { - errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); - } - return validationResult; - }, - actionParamsFields: lazy(() => import('./servicenow_sir_params')), - customConnectorSelectItem: { - getText: getConnectorDescriptiveTitle, - getComponent: getSelectedConnectorIcon, - }, - }; -} - -export function getServiceNowITOMConnectorType(): ConnectorTypeModel< - ServiceNowConfig, - ServiceNowSecrets, - ServiceNowITOMActionParams -> { - return { - id: '.servicenow-itom', - iconClass: lazy(() => import('./logo')), - selectMessage: SERVICENOW_ITOM_DESC, - actionTypeTitle: SERVICENOW_ITOM_TITLE, - actionConnectorFields: lazy(() => import('./servicenow_connectors_no_app')), - validateParams: async ( - actionParams: ServiceNowITOMActionParams - ): Promise> => { - const translations = await import('./translations'); - const errors = { - severity: new Array(), - }; - const validationResult = { errors }; - - if (actionParams?.subActionParams?.severity == null) { - errors.severity.push(translations.SEVERITY_REQUIRED); - } - - return validationResult; - }, - actionParamsFields: lazy(() => import('./servicenow_itom_params')), - }; -} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/index.ts similarity index 65% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/index.ts rename to x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/index.ts index 553cf2edde846..703987149c50f 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/index.ts @@ -5,8 +5,4 @@ * 2.0. */ -export { - getServiceNowITSMConnectorType, - getServiceNowSIRConnectorType, - getServiceNowITOMConnectorType, -} from './servicenow'; +export { getServiceNowITSMConnectorType } from './servicenow_itsm'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/logo.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/logo.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/logo.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/logo.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm.test.tsx new file mode 100644 index 0000000000000..fe70d5f06046b --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; +import { registerConnectorTypes } from '../..'; +import type { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { registrationServicesMock } from '../../../mocks'; + +const SERVICENOW_ITSM_CONNECTOR_TYPE_ID = '.servicenow'; +let connectorTypeRegistry: TypeRegistry; + +beforeAll(() => { + connectorTypeRegistry = new TypeRegistry(); + registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock }); +}); + +describe('connectorTypeRegistry.get() works', () => { + test(`${SERVICENOW_ITSM_CONNECTOR_TYPE_ID}: connector type static data is as expected`, () => { + const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_ITSM_CONNECTOR_TYPE_ID); + expect(connectorTypeModel.id).toEqual(SERVICENOW_ITSM_CONNECTOR_TYPE_ID); + }); +}); + +describe('servicenow action params validation', () => { + test(`${SERVICENOW_ITSM_CONNECTOR_TYPE_ID}: action params validation succeeds when action params is valid`, async () => { + const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_ITSM_CONNECTOR_TYPE_ID); + const actionParams = { + subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { ['subActionParams.incident.short_description']: [] }, + }); + }); + + test(`${SERVICENOW_ITSM_CONNECTOR_TYPE_ID}: params validation fails when short_description is not valid`, async () => { + const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_ITSM_CONNECTOR_TYPE_ID); + const actionParams = { + subActionParams: { incident: { short_description: '' }, comments: [] }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + ['subActionParams.incident.short_description']: ['Short description is required.'], + }, + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm.tsx new file mode 100644 index 0000000000000..67c689f971e6e --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm.tsx @@ -0,0 +1,71 @@ +/* + * 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 { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { + ActionTypeModel as ConnectorTypeModel, + GenericValidationResult, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { ServiceNowConfig, ServiceNowSecrets } from '../../lib/servicenow/types'; +import { ServiceNowITSMActionParams } from './types'; +import { + getConnectorDescriptiveTitle, + getSelectedConnectorIcon, +} from '../../lib/servicenow/helpers'; + +export const SERVICENOW_ITSM_DESC = i18n.translate( + 'xpack.stackConnectors.components.serviceNowITSM.selectMessageText', + { + defaultMessage: 'Create an incident in ServiceNow ITSM.', + } +); + +export const SERVICENOW_ITSM_TITLE = i18n.translate( + 'xpack.stackConnectors.components.serviceNowITSM.connectorTypeTitle', + { + defaultMessage: 'ServiceNow ITSM', + } +); + +export function getServiceNowITSMConnectorType(): ConnectorTypeModel< + ServiceNowConfig, + ServiceNowSecrets, + ServiceNowITSMActionParams +> { + return { + id: '.servicenow', + iconClass: lazy(() => import('./logo')), + selectMessage: SERVICENOW_ITSM_DESC, + actionTypeTitle: SERVICENOW_ITSM_TITLE, + actionConnectorFields: lazy(() => import('../../lib/servicenow/servicenow_connectors')), + validateParams: async ( + actionParams: ServiceNowITSMActionParams + ): Promise> => { + const translations = await import('../../lib/servicenow/translations'); + const errors = { + 'subActionParams.incident.short_description': new Array(), + }; + const validationResult = { + errors, + }; + if ( + actionParams.subActionParams && + actionParams.subActionParams.incident && + !actionParams.subActionParams.incident.short_description?.length + ) { + errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); + } + return validationResult; + }, + actionParamsFields: lazy(() => import('./servicenow_itsm_params')), + customConnectorSelectItem: { + getText: getConnectorDescriptiveTitle, + getComponent: getSelectedConnectorIcon, + }, + }; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm_params.test.tsx similarity index 98% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itsm_params.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm_params.test.tsx index aa6cb6c71278d..39157f1a25f7a 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm_params.test.tsx @@ -10,12 +10,12 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { act } from '@testing-library/react'; import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; -import { useGetChoices } from './use_get_choices'; +import { useGetChoices } from '../../lib/servicenow/use_get_choices'; import ServiceNowITSMParamsFields from './servicenow_itsm_params'; -import { Choice } from './types'; +import { Choice } from '../../lib/servicenow/types'; import { merge } from 'lodash'; -jest.mock('./use_get_choices'); +jest.mock('../../lib/servicenow/use_get_choices'); jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); const useGetChoicesMock = useGetChoices as jest.Mock; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm_params.tsx similarity index 96% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itsm_params.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm_params.tsx index a585ee48864e8..56afb35086a64 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/servicenow_itsm_params.tsx @@ -22,11 +22,12 @@ import { TextFieldWithMessageVariables, useKibana, } from '@kbn/triggers-actions-ui-plugin/public'; -import { ServiceNowITSMActionParams, Choice, Fields } from './types'; -import { useGetChoices } from './use_get_choices'; -import { choicesToEuiOptions, DEFAULT_CORRELATION_ID } from './helpers'; +import { Choice, Fields } from '../../lib/servicenow/types'; +import { ServiceNowITSMActionParams } from './types'; +import { useGetChoices } from '../../lib/servicenow/use_get_choices'; +import { choicesToEuiOptions, DEFAULT_CORRELATION_ID } from '../../lib/servicenow/helpers'; -import * as i18n from './translations'; +import * as i18n from '../../lib/servicenow/translations'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/types.ts new file mode 100644 index 0000000000000..6c3b558f22a04 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_itsm/types.ts @@ -0,0 +1,13 @@ +/* + * 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 { ExecutorSubActionPushParamsITSM } from '../../../../server/connector_types/lib/servicenow/types'; + +export interface ServiceNowITSMActionParams { + subAction: string; + subActionParams: ExecutorSubActionPushParamsITSM; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/index.ts new file mode 100644 index 0000000000000..ec1076d47219c --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getServiceNowSIRConnectorType } from './servicenow_sir'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/logo.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/logo.tsx new file mode 100644 index 0000000000000..f97b07247569d --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/logo.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 React from 'react'; +import { LogoProps } from '../../types'; + +function Logo(props: LogoProps) { + return ( + + + + + + ); +} + +// eslint-disable-next-line import/no-default-export +export default Logo; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir.test.tsx new file mode 100644 index 0000000000000..e9ac99d210df8 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; +import { registerConnectorTypes } from '../..'; +import type { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { registrationServicesMock } from '../../../mocks'; + +const SERVICENOW_SIR_CONNECTOR_TYPE_ID = '.servicenow-sir'; +let connectorTypeRegistry: TypeRegistry; + +beforeAll(() => { + connectorTypeRegistry = new TypeRegistry(); + registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock }); +}); + +describe('connectorTypeRegistry.get() works', () => { + test(`${SERVICENOW_SIR_CONNECTOR_TYPE_ID}: connector type static data is as expected`, () => { + const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_SIR_CONNECTOR_TYPE_ID); + expect(connectorTypeModel.id).toEqual(SERVICENOW_SIR_CONNECTOR_TYPE_ID); + }); +}); + +describe('servicenow action params validation', () => { + test(`${SERVICENOW_SIR_CONNECTOR_TYPE_ID}: action params validation succeeds when action params is valid`, async () => { + const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_SIR_CONNECTOR_TYPE_ID); + const actionParams = { + subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { ['subActionParams.incident.short_description']: [] }, + }); + }); + + test(`${SERVICENOW_SIR_CONNECTOR_TYPE_ID}: params validation fails when short_description is not valid`, async () => { + const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_SIR_CONNECTOR_TYPE_ID); + const actionParams = { + subActionParams: { incident: { short_description: '' }, comments: [] }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + ['subActionParams.incident.short_description']: ['Short description is required.'], + }, + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir.tsx new file mode 100644 index 0000000000000..ff6ebaa913941 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir.tsx @@ -0,0 +1,71 @@ +/* + * 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 { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { + ActionTypeModel as ConnectorTypeModel, + GenericValidationResult, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { ServiceNowConfig, ServiceNowSecrets } from '../../lib/servicenow/types'; +import { ServiceNowSIRActionParams } from './types'; +import { + getConnectorDescriptiveTitle, + getSelectedConnectorIcon, +} from '../../lib/servicenow/helpers'; + +export const SERVICENOW_SIR_DESC = i18n.translate( + 'xpack.stackConnectors.components.serviceNowSIR.selectMessageText', + { + defaultMessage: 'Create an incident in ServiceNow SecOps.', + } +); + +export const SERVICENOW_SIR_TITLE = i18n.translate( + 'xpack.stackConnectors.components.serviceNowSIR.connectorTypeTitle', + { + defaultMessage: 'ServiceNow SecOps', + } +); + +export function getServiceNowSIRConnectorType(): ConnectorTypeModel< + ServiceNowConfig, + ServiceNowSecrets, + ServiceNowSIRActionParams +> { + return { + id: '.servicenow-sir', + iconClass: lazy(() => import('./logo')), + selectMessage: SERVICENOW_SIR_DESC, + actionTypeTitle: SERVICENOW_SIR_TITLE, + actionConnectorFields: lazy(() => import('../../lib/servicenow/servicenow_connectors')), + validateParams: async ( + actionParams: ServiceNowSIRActionParams + ): Promise> => { + const translations = await import('../../lib/servicenow/translations'); + const errors = { + 'subActionParams.incident.short_description': new Array(), + }; + const validationResult = { + errors, + }; + if ( + actionParams.subActionParams && + actionParams.subActionParams.incident && + !actionParams.subActionParams.incident.short_description?.length + ) { + errors['subActionParams.incident.short_description'].push(translations.TITLE_REQUIRED); + } + return validationResult; + }, + actionParamsFields: lazy(() => import('./servicenow_sir_params')), + customConnectorSelectItem: { + getText: getConnectorDescriptiveTitle, + getComponent: getSelectedConnectorIcon, + }, + }; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_sir_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir_params.test.tsx similarity index 98% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_sir_params.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir_params.test.tsx index 8739938891625..49916b350c6a7 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_sir_params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir_params.test.tsx @@ -10,12 +10,12 @@ import { act } from '@testing-library/react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; -import { useGetChoices } from './use_get_choices'; +import { useGetChoices } from '../../lib/servicenow/use_get_choices'; import ServiceNowSIRParamsFields from './servicenow_sir_params'; -import { Choice } from './types'; +import { Choice } from '../../lib/servicenow/types'; import { merge } from 'lodash'; -jest.mock('./use_get_choices'); +jest.mock('../../lib/servicenow/use_get_choices'); jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); const useGetChoicesMock = useGetChoices as jest.Mock; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir_params.tsx similarity index 95% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_sir_params.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir_params.tsx index e58d635f9ef2d..e1b28425887fd 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/servicenow_sir_params.tsx @@ -23,11 +23,12 @@ import { useKibana, } from '@kbn/triggers-actions-ui-plugin/public'; -import * as i18n from './translations'; -import { useGetChoices } from './use_get_choices'; -import { ServiceNowSIRActionParams, Fields, Choice } from './types'; -import { choicesToEuiOptions, DEFAULT_CORRELATION_ID } from './helpers'; -import { DeprecatedCallout } from './deprecated_callout'; +import * as i18n from '../../lib/servicenow/translations'; +import { useGetChoices } from '../../lib/servicenow/use_get_choices'; +import { ServiceNowSIRActionParams } from './types'; +import { Fields, Choice } from '../../lib/servicenow/types'; +import { choicesToEuiOptions, DEFAULT_CORRELATION_ID } from '../../lib/servicenow/helpers'; +import { DeprecatedCallout } from '../../lib/servicenow/deprecated_callout'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/types.ts new file mode 100644 index 0000000000000..889bd527a4a6a --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow_sir/types.ts @@ -0,0 +1,13 @@ +/* + * 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 { ExecutorSubActionPushParamsSIR } from '../../../../server/connector_types/lib/servicenow/types'; + +export interface ServiceNowSIRActionParams { + subAction: string; + subActionParams: ExecutorSubActionPushParamsSIR; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/index.ts index fe24d311c4d65..a2684c0b20734 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/index.ts @@ -12,20 +12,20 @@ import { getIndexConnectorType, getPagerDutyConnectorType, getServerLogConnectorType, + getServiceNowITOMConnectorType, getSlackConnectorType, getTeamsConnectorType, getWebhookConnectorType, + getXmattersConnectorType, } from './stack'; import { getCasesWebhookConnectorType, getJiraConnectorType, getResilientConnectorType, - getServiceNowITOMConnectorType, getServiceNowITSMConnectorType, getServiceNowSIRConnectorType, getSwimlaneConnectorType, - getXmattersConnectorType, } from './cases'; export interface RegistrationServices { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/api.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/api.test.ts similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/api.test.ts rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/api.test.ts diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/api.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/api.ts similarity index 96% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/api.ts rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/api.ts index 4cf46d57eb7f4..38ae8727f635d 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/api.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/api.ts @@ -15,10 +15,7 @@ import { import { snExternalServiceConfig } from '../../../../common/servicenow_config'; import { API_INFO_ERROR } from './translations'; import { AppInfo, RESTApiError, ServiceNowActionConnector } from './types'; -import { - ConnectorExecutorResult, - rewriteResponseToCamelCase, -} from '../../lib/rewrite_response_body'; +import { ConnectorExecutorResult, rewriteResponseToCamelCase } from '../rewrite_response_body'; import { Choice } from './types'; export async function getChoices({ diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/application_required_callout.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/application_required_callout.test.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/application_required_callout.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/application_required_callout.test.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/application_required_callout.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/application_required_callout.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/application_required_callout.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/application_required_callout.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/auth_types/credentials_auth.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/auth_types/credentials_auth.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/auth_types/credentials_auth.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/auth_types/credentials_auth.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/auth_types/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/auth_types/index.ts similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/auth_types/index.ts rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/auth_types/index.ts diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/auth_types/oauth.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/auth_types/oauth.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/auth_types/oauth.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/auth_types/oauth.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/credentials.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/credentials.test.tsx similarity index 97% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/credentials.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/credentials.test.tsx index aab2ab0fb21c6..2e1ae98a10886 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/credentials.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/credentials.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; import { Credentials } from './credentials'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; -import { ConnectorFormTestProvider } from '../../lib/test_utils'; +import { ConnectorFormTestProvider } from '../test_utils'; jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/credentials.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/credentials.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/credentials.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/credentials.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/credentials_api_url.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/credentials_api_url.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/credentials_api_url.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/credentials_api_url.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/deprecated_callout.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/deprecated_callout.test.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/deprecated_callout.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/deprecated_callout.test.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/deprecated_callout.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/deprecated_callout.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/deprecated_callout.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/deprecated_callout.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/helpers.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/helpers.test.ts similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/helpers.test.ts rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/helpers.test.ts diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/helpers.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/helpers.ts similarity index 96% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/helpers.ts rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/helpers.ts index def683edbdd33..f3e71b0e9fc7f 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/helpers.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/helpers.ts @@ -42,6 +42,6 @@ export const getSelectedConnectorIcon = ( actionConnector: ActionConnector ): React.LazyExoticComponent> | undefined => { if (actionConnector.isDeprecated) { - return lazy(() => import('./servicenow_selection_row')); + return lazy(() => import('./selection_row')); } }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/installation_callout.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/installation_callout.test.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/installation_callout.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/installation_callout.test.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/installation_callout.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/installation_callout.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/installation_callout.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/installation_callout.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_selection_row.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/selection_row.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_selection_row.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/selection_row.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/servicenow_connectors.test.tsx similarity index 99% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_connectors.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/servicenow_connectors.test.tsx index 6bf81f5aeae74..51b0f759c75bd 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/servicenow_connectors.test.tsx @@ -15,7 +15,7 @@ import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; import { updateActionConnector } from '@kbn/triggers-actions-ui-plugin/public/application/lib/action_connector_api'; import ServiceNowConnectorFields from './servicenow_connectors'; import { getAppInfo } from './api'; -import { ConnectorFormTestProvider } from '../../lib/test_utils'; +import { ConnectorFormTestProvider } from '../test_utils'; import { mount } from 'enzyme'; import userEvent from '@testing-library/user-event'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_connectors.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/servicenow_connectors.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_connectors.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/servicenow_connectors.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_connectors_no_app.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/servicenow_connectors_no_app.test.tsx similarity index 97% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_connectors_no_app.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/servicenow_connectors_no_app.test.tsx index e70005f8c7e1b..a0dda6edf76e0 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_connectors_no_app.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/servicenow_connectors_no_app.test.tsx @@ -8,11 +8,7 @@ import { act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { - AppMockRenderer, - ConnectorFormTestProvider, - createAppMockRenderer, -} from '../../lib/test_utils'; +import { AppMockRenderer, ConnectorFormTestProvider, createAppMockRenderer } from '../test_utils'; import ServiceNowConnectorFieldsNoApp from './servicenow_connectors_no_app'; describe('ServiceNowActionConnectorFields renders', () => { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_connectors_no_app.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/servicenow_connectors_no_app.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_connectors_no_app.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/servicenow_connectors_no_app.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/sn_store_button.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/sn_store_button.test.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/sn_store_button.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/sn_store_button.test.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/sn_store_button.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/sn_store_button.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/sn_store_button.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/sn_store_button.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/translations.ts similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/translations.ts rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/translations.ts diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/types.ts similarity index 73% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/types.ts rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/types.ts index f10de69252f9d..862eb2165b7b2 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/types.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/types.ts @@ -6,32 +6,12 @@ */ import { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; -import type { - ExecutorSubActionPushParamsITSM, - ExecutorSubActionPushParamsSIR, - ExecutorSubActionAddEventParams, -} from '../../../../server/connector_types/cases/servicenow/types'; export type ServiceNowActionConnector = UserConfiguredActionConnector< ServiceNowConfig, ServiceNowSecrets >; -export interface ServiceNowITSMActionParams { - subAction: string; - subActionParams: ExecutorSubActionPushParamsITSM; -} - -export interface ServiceNowSIRActionParams { - subAction: string; - subActionParams: ExecutorSubActionPushParamsSIR; -} - -export interface ServiceNowITOMActionParams { - subAction: string; - subActionParams: ExecutorSubActionAddEventParams; -} - // Config export interface ServiceNowCommonConfig { isOAuth: boolean; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/update_connector.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/update_connector.test.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/update_connector.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/update_connector.test.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/update_connector.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/update_connector.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/update_connector.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/update_connector.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_choices.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_choices.test.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_choices.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_choices.test.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_choices.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_choices.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_choices.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_choices.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_get_app_info.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_get_app_info.test.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_get_app_info.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_get_app_info.test.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_get_app_info.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_get_app_info.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_get_app_info.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_get_app_info.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_get_choices.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_get_choices.test.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_get_choices.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_get_choices.test.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_get_choices.tsx b/x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_get_choices.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/use_get_choices.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/lib/servicenow/use_get_choices.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/index.ts index 93d444d20204d..fec0283a799ab 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/index.ts @@ -9,6 +9,8 @@ export { getEmailConnectorType } from './email'; export { getIndexConnectorType } from './es_index'; export { getPagerDutyConnectorType } from './pagerduty'; export { getServerLogConnectorType } from './server_log'; +export { getServiceNowITOMConnectorType } from './servicenow_itom'; export { getSlackConnectorType } from './slack'; export { getTeamsConnectorType } from './teams'; export { getWebhookConnectorType } from './webhook'; +export { getXmattersConnectorType } from './xmatters'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/index.ts new file mode 100644 index 0000000000000..bfcd0f888f855 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getServiceNowITOMConnectorType } from './servicenow_itom'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/logo.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/logo.tsx new file mode 100644 index 0000000000000..f97b07247569d --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/logo.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 React from 'react'; +import { LogoProps } from '../../types'; + +function Logo(props: LogoProps) { + return ( + + + + + + ); +} + +// eslint-disable-next-line import/no-default-export +export default Logo; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom.test.tsx similarity index 54% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom.test.tsx index 9427623f0de8a..b86616ccde5e1 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom.test.tsx @@ -10,8 +10,6 @@ import { registerConnectorTypes } from '../..'; import type { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; import { registrationServicesMock } from '../../../mocks'; -const SERVICENOW_ITSM_CONNECTOR_TYPE_ID = '.servicenow'; -const SERVICENOW_SIR_CONNECTOR_TYPE_ID = '.servicenow-sir'; const SERVICENOW_ITOM_CONNECTOR_TYPE_ID = '.servicenow-itom'; let connectorTypeRegistry: TypeRegistry; @@ -21,45 +19,13 @@ beforeAll(() => { }); describe('connectorTypeRegistry.get() works', () => { - [ - SERVICENOW_ITSM_CONNECTOR_TYPE_ID, - SERVICENOW_SIR_CONNECTOR_TYPE_ID, - SERVICENOW_ITOM_CONNECTOR_TYPE_ID, - ].forEach((id) => { - test(`${id}: connector type static data is as expected`, () => { - const connectorTypeModel = connectorTypeRegistry.get(id); - expect(connectorTypeModel.id).toEqual(id); - }); + test(`${SERVICENOW_ITOM_CONNECTOR_TYPE_ID}: connector type static data is as expected`, () => { + const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_ITOM_CONNECTOR_TYPE_ID); + expect(connectorTypeModel.id).toEqual(SERVICENOW_ITOM_CONNECTOR_TYPE_ID); }); }); describe('servicenow action params validation', () => { - [SERVICENOW_ITSM_CONNECTOR_TYPE_ID, SERVICENOW_SIR_CONNECTOR_TYPE_ID].forEach((id) => { - test(`${id}: action params validation succeeds when action params is valid`, async () => { - const connectorTypeModel = connectorTypeRegistry.get(id); - const actionParams = { - subActionParams: { incident: { short_description: 'some title {{test}}' }, comments: [] }, - }; - - expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ - errors: { ['subActionParams.incident.short_description']: [] }, - }); - }); - - test(`${id}: params validation fails when short_description is not valid`, async () => { - const connectorTypeModel = connectorTypeRegistry.get(id); - const actionParams = { - subActionParams: { incident: { short_description: '' }, comments: [] }, - }; - - expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ - errors: { - ['subActionParams.incident.short_description']: ['Short description is required.'], - }, - }); - }); - }); - test(`${SERVICENOW_ITOM_CONNECTOR_TYPE_ID}: action params validation succeeds when action params is valid`, async () => { const connectorTypeModel = connectorTypeRegistry.get(SERVICENOW_ITOM_CONNECTOR_TYPE_ID); const actionParams = { subActionParams: { severity: 'Critical' } }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom.tsx new file mode 100644 index 0000000000000..09f4002774f67 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom.tsx @@ -0,0 +1,59 @@ +/* + * 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 { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { + ActionTypeModel as ConnectorTypeModel, + GenericValidationResult, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { ServiceNowConfig, ServiceNowSecrets } from '../../lib/servicenow/types'; +import { ServiceNowITOMActionParams } from './types'; + +export const SERVICENOW_ITOM_TITLE = i18n.translate( + 'xpack.stackConnectors.components.serviceNowITOM.connectorTypeTitle', + { + defaultMessage: 'ServiceNow ITOM', + } +); + +export const SERVICENOW_ITOM_DESC = i18n.translate( + 'xpack.stackConnectors.components.serviceNowITOM.selectMessageText', + { + defaultMessage: 'Create an event in ServiceNow ITOM.', + } +); + +export function getServiceNowITOMConnectorType(): ConnectorTypeModel< + ServiceNowConfig, + ServiceNowSecrets, + ServiceNowITOMActionParams +> { + return { + id: '.servicenow-itom', + iconClass: lazy(() => import('./logo')), + selectMessage: SERVICENOW_ITOM_DESC, + actionTypeTitle: SERVICENOW_ITOM_TITLE, + actionConnectorFields: lazy(() => import('../../lib/servicenow/servicenow_connectors_no_app')), + validateParams: async ( + actionParams: ServiceNowITOMActionParams + ): Promise> => { + const translations = await import('../../lib/servicenow/translations'); + const errors = { + severity: new Array(), + }; + const validationResult = { errors }; + + if (actionParams?.subActionParams?.severity == null) { + errors.severity.push(translations.SEVERITY_REQUIRED); + } + + return validationResult; + }, + actionParamsFields: lazy(() => import('./servicenow_itom_params')), + }; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itom_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom_params.test.tsx similarity index 98% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itom_params.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom_params.test.tsx index 60531c7a7104d..d2e9880058277 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itom_params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom_params.test.tsx @@ -9,10 +9,10 @@ import React from 'react'; import { mount } from 'enzyme'; import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; -import { useChoices } from './use_choices'; +import { useChoices } from '../../lib/servicenow/use_choices'; import ServiceNowITOMParamsFields from './servicenow_itom_params'; -jest.mock('./use_choices'); +jest.mock('../../lib/servicenow/use_choices'); jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); const useChoicesMock = useChoices as jest.Mock; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itom_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom_params.tsx similarity index 96% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itom_params.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom_params.tsx index caa2f40bac2c1..0086c6713e074 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases/servicenow/servicenow_itom_params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/servicenow_itom_params.tsx @@ -14,10 +14,10 @@ import { useKibana, } from '@kbn/triggers-actions-ui-plugin/public'; -import * as i18n from './translations'; -import { useChoices } from './use_choices'; +import * as i18n from '../../lib/servicenow/translations'; +import { useChoices } from '../../lib/servicenow/use_choices'; import { ServiceNowITOMActionParams } from './types'; -import { choicesToEuiOptions, isFieldInvalid } from './helpers'; +import { choicesToEuiOptions, isFieldInvalid } from '../../lib/servicenow/helpers'; const choicesFields = ['severity']; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/types.ts new file mode 100644 index 0000000000000..5155997c996c8 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/servicenow_itom/types.ts @@ -0,0 +1,13 @@ +/* + * 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 { ExecutorSubActionAddEventParams } from '../../../../server/connector_types/lib/servicenow/types'; + +export interface ServiceNowITOMActionParams { + subAction: string; + subActionParams: ExecutorSubActionAddEventParams; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/index.ts similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/index.ts rename to x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/index.ts diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/logo.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/logo.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/logo.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/logo.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/translations.ts similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/translations.ts rename to x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/translations.ts diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters.test.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters.test.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters_connectors.test.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters_connectors.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters_connectors.test.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters_connectors.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters_connectors.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters_connectors.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters_connectors.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters_params.test.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters_params.test.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters_params.test.tsx diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters_params.tsx similarity index 100% rename from x-pack/plugins/stack_connectors/public/connector_types/cases/xmatters/xmatters_params.tsx rename to x-pack/plugins/stack_connectors/public/connector_types/stack/xmatters/xmatters_params.tsx diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/index.ts index abc6c0557a780..ad706fff3b8e7 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/index.ts @@ -22,20 +22,10 @@ export { } from './resilient'; export type { ActionParamsType as ResilientActionParams } from './resilient'; -export { - getServiceNowITSMConnectorType, - getServiceNowSIRConnectorType, - getServiceNowITOMConnectorType, - ServiceNowITSMConnectorTypeId, - ServiceNowSIRConnectorTypeId, - ServiceNowITOMConnectorTypeId, -} from './servicenow'; -export type { ActionParamsType as ServiceNowActionParams } from './servicenow'; +export { getServiceNowITSMConnectorType, ServiceNowITSMConnectorTypeId } from './servicenow_itsm'; +import type { ActionParamsType as ServiceNowITSMActionParams } from './servicenow_itsm'; +export { getServiceNowSIRConnectorType, ServiceNowSIRConnectorTypeId } from './servicenow_sir'; +import type { ActionParamsType as ServiceNowSIRActionParams } from './servicenow_sir'; +export type ServiceNowActionParams = ServiceNowITSMActionParams | ServiceNowSIRActionParams; export { getConnectorType as getSwimlaneConnectorType } from './swimlane'; - -export { - getConnectorType as getXmattersConnectorType, - ConnectorTypeId as XmattersConnectorTypeId, -} from './xmatters'; -export type { ActionParamsType as XmattersActionParams } from './xmatters'; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/api.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/api.ts new file mode 100644 index 0000000000000..4eee89cb7de98 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/api.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { api } from '../../lib/servicenow/api'; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/index.test.ts similarity index 70% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/index.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/index.test.ts index 02cc32111bff4..50ff4d8e0f1c9 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/index.test.ts @@ -8,12 +8,11 @@ import { Logger } from '@kbn/core/server'; import { loggerMock } from '@kbn/logging-mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; -import { ExecutorParams, ServiceNowPublicConfigurationType } from './types'; +import { ExecutorParams, ServiceNowPublicConfigurationType } from '../../lib/servicenow/types'; import { ServiceNowConnectorType, ServiceNowConnectorTypeExecutorOptions, getServiceNowITSMConnectorType, - getServiceNowSIRConnectorType, } from '.'; import { api } from './api'; @@ -79,38 +78,4 @@ describe('ServiceNow', () => { }); }); }); - - describe('ServiceNow SIR', () => { - let connectorType: ServiceNowConnectorType; - - beforeAll(() => { - connectorType = getServiceNowSIRConnectorType({ - logger: mockedLogger, - }); - }); - - describe('execute()', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('it pass the correct comment field key', async () => { - const actionId = 'some-action-id'; - const executorOptions = { - actionId, - config, - secrets, - params, - services, - } as unknown as ServiceNowConnectorTypeExecutorOptions< - ServiceNowPublicConfigurationType, - ExecutorParams - >; - await connectorType.executor(executorOptions); - expect((api.pushToService as jest.Mock).mock.calls[0][0].commentFieldKey).toBe( - 'work_notes' - ); - }); - }); - }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/index.ts similarity index 51% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/index.ts rename to x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/index.ts index 493c7024bb15f..40e9d1470950e 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/index.ts @@ -20,62 +20,40 @@ import { UptimeConnectorFeatureId, SecurityConnectorFeatureId, } from '@kbn/actions-plugin/common/types'; -import { validate } from './validators'; +import { validate } from '../../lib/servicenow/validators'; import { + ExecutorParamsSchemaITSM, ExternalIncidentServiceConfigurationSchema, - ExternalIncidentServiceConfigurationBaseSchema, ExternalIncidentServiceSecretConfigurationSchema, - ExecutorParamsSchemaITSM, - ExecutorParamsSchemaSIR, - ExecutorParamsSchemaITOM, -} from './schema'; +} from '../../lib/servicenow/schema'; import { createExternalService } from './service'; -import { api as commonAPI } from './api'; -import * as i18n from './translations'; +import { api as apiITSM } from './api'; +import * as i18n from '../../lib/servicenow/translations'; import { ExecutorParams, ExecutorSubActionPushParams, - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - PushToServiceResponse, - ExecutorSubActionCommonFieldsParams, - ServiceNowExecutorResultData, - ExecutorSubActionGetChoicesParams, ServiceFactory, ExternalServiceAPI, - ExecutorParamsITOM, - ExecutorSubActionAddEventParams, - ExternalServiceApiITOM, - ExternalServiceITOM, ServiceNowPublicConfigurationBaseType, ExternalService, -} from './types'; + ExecutorSubActionCommonFieldsParams, + ExecutorSubActionGetChoicesParams, + PushToServiceResponse, + ServiceNowExecutorResultData, + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, +} from '../../lib/servicenow/types'; import { - ServiceNowITOMConnectorTypeId, ServiceNowITSMConnectorTypeId, serviceNowITSMTable, - ServiceNowSIRConnectorTypeId, - serviceNowSIRTable, snExternalServiceConfig, -} from './config'; -import { createExternalServiceSIR } from './service_sir'; -import { apiSIR } from './api_sir'; -import { throwIfSubActionIsNotSupported } from './utils'; -import { createExternalServiceITOM } from './service_itom'; -import { apiITOM } from './api_itom'; -import { createServiceWrapper } from './create_service_wrapper'; +} from '../../lib/servicenow/config'; +import { throwIfSubActionIsNotSupported } from '../../lib/servicenow/utils'; +import { createServiceWrapper } from '../../lib/servicenow/create_service_wrapper'; -export { - ServiceNowITSMConnectorTypeId, - serviceNowITSMTable, - ServiceNowSIRConnectorTypeId, - serviceNowSIRTable, - ServiceNowITOMConnectorTypeId, -}; +export { ServiceNowITSMConnectorTypeId, serviceNowITSMTable }; -export type ActionParamsType = - | TypeOf - | TypeOf; +export type ActionParamsType = TypeOf; interface GetConnectorTypeParams { logger: Logger; @@ -124,75 +102,7 @@ export function getServiceNowITSMConnectorType( logger, actionTypeId: ServiceNowITSMConnectorTypeId, createService: createExternalService, - api: commonAPI, - }), - }; -} - -export function getServiceNowSIRConnectorType( - params: GetConnectorTypeParams -): ServiceNowConnectorType { - const { logger } = params; - return { - id: ServiceNowSIRConnectorTypeId, - minimumLicenseRequired: 'platinum', - name: i18n.SERVICENOW_SIR, - supportedFeatureIds: [ - AlertingConnectorFeatureId, - CasesConnectorFeatureId, - SecurityConnectorFeatureId, - ], - validate: { - config: { - schema: ExternalIncidentServiceConfigurationSchema, - customValidator: validate.config, - }, - secrets: { - schema: ExternalIncidentServiceSecretConfigurationSchema, - customValidator: validate.secrets, - }, - connector: validate.connector, - params: { - schema: ExecutorParamsSchemaSIR, - }, - }, - executor: curry(executor)({ - logger, - actionTypeId: ServiceNowSIRConnectorTypeId, - createService: createExternalServiceSIR, - api: apiSIR, - }), - }; -} - -export function getServiceNowITOMConnectorType( - params: GetConnectorTypeParams -): ServiceNowConnectorType { - const { logger } = params; - return { - id: ServiceNowITOMConnectorTypeId, - minimumLicenseRequired: 'platinum', - name: i18n.SERVICENOW_ITOM, - supportedFeatureIds: [AlertingConnectorFeatureId, SecurityConnectorFeatureId], - validate: { - config: { - schema: ExternalIncidentServiceConfigurationBaseSchema, - customValidator: validate.config, - }, - secrets: { - schema: ExternalIncidentServiceSecretConfigurationSchema, - customValidator: validate.secrets, - }, - connector: validate.connector, - params: { - schema: ExecutorParamsSchemaITOM, - }, - }, - executor: curry(executorITOM)({ - logger, - actionTypeId: ServiceNowITOMConnectorTypeId, - createService: createExternalServiceITOM, - api: apiITOM, + api: apiITSM, }), }; } @@ -272,71 +182,3 @@ async function executor( return { status: 'ok', data: data ?? {}, actionId }; } - -const supportedSubActionsITOM = ['addEvent', 'getChoices']; - -async function executorITOM( - { - logger, - actionTypeId, - createService, - api, - }: { - logger: Logger; - actionTypeId: string; - createService: ServiceFactory; - api: ExternalServiceApiITOM; - }, - execOptions: ServiceNowConnectorTypeExecutorOptions< - ServiceNowPublicConfigurationBaseType, - ExecutorParamsITOM - > -): Promise> { - const { actionId, config, params, secrets, configurationUtilities } = execOptions; - const { subAction, subActionParams } = params; - const connectorTokenClient = execOptions.services.connectorTokenClient; - const externalServiceConfig = snExternalServiceConfig[actionTypeId]; - let data: ServiceNowExecutorResultData | null = null; - - const externalService = createServiceWrapper({ - connectorId: actionId, - credentials: { - config, - secrets, - }, - logger, - configurationUtilities, - serviceConfig: externalServiceConfig, - connectorTokenClient, - createServiceFn: createService, - }); - - const apiAsRecord = api as unknown as Record; - - throwIfSubActionIsNotSupported({ - api: apiAsRecord, - subAction, - supportedSubActions: supportedSubActionsITOM, - logger, - }); - - if (subAction === 'addEvent') { - const eventParams = subActionParams as ExecutorSubActionAddEventParams; - await api.addEvent({ - externalService, - params: eventParams, - logger, - }); - } - - if (subAction === 'getChoices') { - const getChoicesParams = subActionParams as ExecutorSubActionGetChoicesParams; - data = await api.getChoices({ - externalService, - params: getChoicesParams, - logger, - }); - } - - return { status: 'ok', data: data ?? {}, actionId }; -} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/service.test.ts new file mode 100644 index 0000000000000..f2bb94bd60756 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/service.test.ts @@ -0,0 +1,1022 @@ +/* + * 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 axios, { AxiosResponse } from 'axios'; + +import { createExternalService } from './service'; +import * as utils from '@kbn/actions-plugin/server/lib/axios_utils'; +import { ExternalService, ServiceNowITSMIncident } from '../../lib/servicenow/types'; +import { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import { serviceNowCommonFields, serviceNowChoices } from '../../lib/servicenow/mocks'; +import { snExternalServiceConfig } from '../../lib/servicenow/config'; +const logger = loggingSystemMock.create().get() as jest.Mocked; + +jest.mock('axios'); +jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { + const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + patch: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); + +const getImportSetAPIResponse = (update = false) => ({ + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + table: 'incident', + display_name: 'number', + display_value: 'INC01', + record_link: 'https://example.com/api/now/table/incident/1', + status: update ? 'updated' : 'inserted', + sys_id: '1', + }, + ], +}); + +const getImportSetAPIError = () => ({ + import_set: 'ISET01', + staging_table: 'x_elas2_inc_int_elastic_incident', + result: [ + { + transform_map: 'Elastic Incident', + status: 'error', + error_message: 'An error has occurred while importing the incident', + status_message: 'failure', + }, + ], +}); + +const mockApplicationVersion = () => + requestMock.mockImplementationOnce(() => ({ + data: { + result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' }, + }, + })); + +const mockImportIncident = (update: boolean) => + requestMock.mockImplementationOnce(() => ({ + data: getImportSetAPIResponse(update), + })); + +const mockIncidentResponse = (update: boolean) => + requestMock.mockImplementation(() => ({ + data: { + result: { + sys_id: '1', + number: 'INC01', + ...(update + ? { sys_updated_on: '2020-03-10 12:24:20' } + : { sys_created_on: '2020-03-10 12:24:20' }), + }, + }, + })); + +const createIncident = async (service: ExternalService) => { + // Get application version + mockApplicationVersion(); + // Import set api response + mockImportIncident(false); + // Get incident response + mockIncidentResponse(false); + + return await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); +}; + +const updateIncident = async (service: ExternalService) => { + // Get application version + mockApplicationVersion(); + // Import set api response + mockImportIncident(true); + // Get incident response + mockIncidentResponse(true); + + return await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); +}; + +const expectImportedIncident = (update: boolean) => { + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health', + method: 'get', + }); + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident', + method: 'post', + data: { + u_short_description: 'title', + u_description: 'desc', + ...(update ? { elastic_incident_id: '1' } : {}), + }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'get', + }); +}; + +describe('ServiceNow service', () => { + let service: ExternalService; + + beforeEach(() => { + jest.clearAllMocks(); + service = createExternalService({ + credentials: { + // The trailing slash at the end of the url is intended. + // All API calls need to have the trailing slash removed. + config: { apiUrl: 'https://example.com/', isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: snExternalServiceConfig['.servicenow'], + axiosInstance: axios, + }); + }); + + describe('createExternalService', () => { + test('throws without url', () => { + expect(() => + createExternalService({ + credentials: { + config: { apiUrl: null, isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: snExternalServiceConfig['.servicenow'], + axiosInstance: axios, + }) + ).toThrow(); + }); + + test('throws when isOAuth is false and basic auth required values are falsy', () => { + const badBasicCredentials = [ + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { username: '', password: 'admin' }, + }, + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { username: null, password: 'admin' }, + }, + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { password: 'admin' }, + }, + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { username: 'admin', password: '' }, + }, + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { username: 'admin', password: null }, + }, + { + config: { apiUrl: 'test.com', isOAuth: false }, + secrets: { username: 'admin' }, + }, + ]; + + badBasicCredentials.forEach((badCredentials) => { + expect(() => + createExternalService({ + credentials: badCredentials, + logger, + configurationUtilities, + serviceConfig: snExternalServiceConfig['.servicenow'], + axiosInstance: axios, + }) + ).toThrow(); + }); + }); + + test('throws when isOAuth is true and OAuth required values are falsy', () => { + const badOAuthCredentials = [ + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: '', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: null, + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: '', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: null, + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: '', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: null, + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + }, + secrets: { clientSecret: 'clientSecret', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: '', privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: null, privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { privateKey: 'privateKey' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: '' }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret', privateKey: null }, + }, + { + config: { + apiUrl: 'test.com', + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'user@email.com', + }, + secrets: { clientSecret: 'clientSecret' }, + }, + ]; + + badOAuthCredentials.forEach((badCredentials) => { + expect(() => + createExternalService({ + credentials: badCredentials, + logger, + configurationUtilities, + serviceConfig: snExternalServiceConfig['.servicenow'], + axiosInstance: axios, + }) + ).toThrow(); + }); + }); + }); + + describe('getIncident', () => { + test('it returns the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01' } }, + })); + const res = await service.getIncident('1'); + expect(res).toEqual({ sys_id: '1', number: 'INC01' }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01' } }, + })); + + await service.getIncident('1'); + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'get', + }); + }); + + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, + axiosInstance: axios, + }); + + requestMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01' } }, + })); + + await service.getIncident('1'); + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + await expect(service.getIncident('1')).rejects.toThrow( + 'Unable to get incident with id 1. Error: An error has occurred' + ); + }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect(service.getIncident('1')).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); + }); + + describe('createIncident', () => { + // new connectors + describe('import set table', () => { + test('it creates the incident correctly', async () => { + const res = await createIncident(service); + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); + }); + + test('it should call request with correct arguments', async () => { + await createIncident(service); + expect(requestMock).toHaveBeenCalledTimes(3); + expectImportedIncident(false); + }); + + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: snExternalServiceConfig['.servicenow-sir'], + axiosInstance: axios, + }); + + const res = await createIncident(service); + + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc' }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', + }); + + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); + }); + + test('it should throw an error when the application is not installed', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null' + ); + }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); + + test('it should throw an error when there is an import set api error', async () => { + requestMock.mockImplementation(() => ({ data: getImportSetAPIError() })); + await expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred while importing the incident Reason: unknown' + ); + }); + }); + + // old connectors + describe('table API', () => { + beforeEach(() => { + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, + axiosInstance: axios, + }); + }); + + test('it creates the incident correctly', async () => { + mockIncidentResponse(false); + const res = await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); + + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident', + method: 'post', + data: { short_description: 'title', description: 'desc' }, + }); + }); + + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }, + axiosInstance: axios, + }); + + mockIncidentResponse(false); + + const res = await service.createIncident({ + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident', + method: 'post', + data: { short_description: 'title', description: 'desc' }, + }); + + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); + }); + }); + }); + + describe('updateIncident', () => { + // new connectors + describe('import set table', () => { + test('it updates the incident correctly', async () => { + const res = await updateIncident(service); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); + }); + + test('it should call request with correct arguments', async () => { + await updateIncident(service); + expectImportedIncident(true); + }); + + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: snExternalServiceConfig['.servicenow-sir'], + axiosInstance: axios, + }); + + const res = await updateIncident(service); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health', + method: 'get', + }); + + expect(requestMock).toHaveBeenNthCalledWith(2, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident', + method: 'post', + data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' }, + }); + + expect(requestMock).toHaveBeenNthCalledWith(3, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'get', + }); + + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); + }); + + test('it should throw an error when the application is not installed', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null' + ); + }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); + + test('it should throw an error when there is an import set api error', async () => { + requestMock.mockImplementation(() => ({ data: getImportSetAPIError() })); + await expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred while importing the incident Reason: unknown' + ); + }); + }); + + // old connectors + describe('table API', () => { + beforeEach(() => { + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, + axiosInstance: axios, + }); + }); + + test('it updates the incident correctly', async () => { + mockIncidentResponse(true); + const res = await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1', + }); + + expect(requestMock).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/incident/1', + method: 'patch', + data: { short_description: 'title', description: 'desc' }, + }); + }); + + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }, + axiosInstance: axios, + }); + + mockIncidentResponse(false); + + const res = await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident, + }); + + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/v2/table/sn_si_incident/1', + method: 'patch', + data: { short_description: 'title', description: 'desc' }, + }); + + expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'); + }); + }); + }); + + describe('getFields', () => { + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { result: serviceNowCommonFields }, + })); + await service.getFields(); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + }); + }); + + test('it returns common fields correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { result: serviceNowCommonFields }, + })); + const res = await service.getFields(); + expect(res).toEqual(serviceNowCommonFields); + }); + + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, + axiosInstance: axios, + }); + + requestMock.mockImplementation(() => ({ + data: { result: serviceNowCommonFields }, + })); + await service.getFields(); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + await expect(service.getFields()).rejects.toThrow( + '[Action][ServiceNow]: Unable to get fields. Error: An error has occurred' + ); + }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect(service.getIncident('1')).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); + }); + + describe('getChoices', () => { + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { result: serviceNowChoices }, + })); + await service.getChoices(['priority', 'category']); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category^language=en&sysparm_fields=label,value,dependent_value,element', + }); + }); + + test('it returns common fields correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { result: serviceNowChoices }, + })); + const res = await service.getChoices(['priority']); + expect(res).toEqual(serviceNowChoices); + }); + + test('it should call request with correct arguments when table changes', async () => { + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }, + axiosInstance: axios, + }); + + requestMock.mockImplementation(() => ({ + data: { result: serviceNowChoices }, + })); + + await service.getChoices(['priority', 'category']); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category^language=en&sysparm_fields=label,value,dependent_value,element', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + await expect(service.getChoices(['priority'])).rejects.toThrow( + '[Action][ServiceNow]: Unable to get choices. Error: An error has occurred' + ); + }); + + test('it should throw an error when instance is not alive', async () => { + requestMock.mockImplementation(() => ({ + status: 200, + data: {}, + request: { connection: { servername: 'Developer instance' } }, + })); + await expect(service.getIncident('1')).rejects.toThrow( + 'There is an issue with your Service Now Instance. Please check Developer instance.' + ); + }); + }); + + describe('getUrl', () => { + test('it returns the instance url', async () => { + expect(service.getUrl()).toBe('https://example.com'); + }); + }); + + describe('checkInstance', () => { + test('it throws an error if there is no result on data', () => { + const res = { status: 200, data: {} } as AxiosResponse; + expect(() => service.checkInstance(res)).toThrow(); + }); + + test('it does NOT throws an error if the status > 400', () => { + const res = { status: 500, data: {} } as AxiosResponse; + expect(() => service.checkInstance(res)).not.toThrow(); + }); + + test('it shows the servername', () => { + const res = { + status: 200, + data: {}, + request: { connection: { servername: 'https://example.com' } }, + } as AxiosResponse; + expect(() => service.checkInstance(res)).toThrow( + 'There is an issue with your Service Now Instance. Please check https://example.com.' + ); + }); + + describe('getApplicationInformation', () => { + test('it returns the application information', async () => { + mockApplicationVersion(); + const res = await service.getApplicationInformation(); + expect(res).toEqual({ + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + await expect(service.getApplicationInformation()).rejects.toThrow( + '[Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown' + ); + }); + }); + + describe('checkIfApplicationIsInstalled', () => { + test('it logs the application information', async () => { + mockApplicationVersion(); + await service.checkIfApplicationIsInstalled(); + expect(logger.debug).toHaveBeenCalledWith( + 'Create incident: Application scope: x_elas2_inc_int: Application version1.0.0' + ); + }); + + test('it does not log if useOldApi = true', async () => { + service = createExternalService({ + credentials: { + config: { apiUrl: 'https://example.com/', isOAuth: false }, + secrets: { username: 'admin', password: 'admin' }, + }, + logger, + configurationUtilities, + serviceConfig: { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }, + axiosInstance: axios, + }); + await service.checkIfApplicationIsInstalled(); + expect(requestMock).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/bulk_actions/translations.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/service.ts similarity index 55% rename from x-pack/plugins/cases/public/components/bulk_actions/translations.ts rename to x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/service.ts index c5bc5d7cde66b..f4ac61632722a 100644 --- a/x-pack/plugins/cases/public/components/bulk_actions/translations.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_itsm/service.ts @@ -5,11 +5,6 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; +export const SYS_DICTIONARY_ENDPOINT = `api/now/table/sys_dictionary`; -export const BULK_ACTION_DELETE_SELECTED = i18n.translate( - 'xpack.cases.caseTable.bulkActions.deleteSelectedTitle', - { - defaultMessage: 'Delete selected', - } -); +export { createExternalService } from '../../lib/servicenow/service'; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_sir.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/api.test.ts similarity index 96% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_sir.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/api.test.ts index 0eab1bf7fe089..5947b317c9e27 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_sir.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/api.test.ts @@ -6,9 +6,9 @@ */ import { Logger } from '@kbn/core/server'; -import { externalServiceSIRMock, sirParams } from './mocks'; -import { ExternalServiceSIR, ObservableTypes } from './types'; -import { apiSIR, combineObservables, formatObservables, prepareParams } from './api_sir'; +import { externalServiceSIRMock, sirParams } from '../../lib/servicenow/mocks'; +import { ExternalServiceSIR, ObservableTypes } from '../../lib/servicenow/types'; +import { api, combineObservables, formatObservables, prepareParams } from './api'; let mockedLogger: jest.Mocked; describe('api_sir', () => { @@ -189,7 +189,7 @@ describe('api_sir', () => { describe('pushToService', () => { test('it creates an incident correctly', async () => { const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; - const res = await apiSIR.pushToService({ + const res = await api.pushToService({ externalService, params, config: { usesTableApi: false }, @@ -218,7 +218,7 @@ describe('api_sir', () => { test('it adds observables correctly', async () => { const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; - await apiSIR.pushToService({ + await api.pushToService({ externalService, params, config: { usesTableApi: false }, @@ -246,7 +246,7 @@ describe('api_sir', () => { test('it does not call bulkAddObservableToIncident if the connector uses the old API', async () => { const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; - await apiSIR.pushToService({ + await api.pushToService({ externalService, params, config: { usesTableApi: true }, @@ -271,7 +271,7 @@ describe('api_sir', () => { }, }; - await apiSIR.pushToService({ + await api.pushToService({ externalService, params, config: { usesTableApi: false }, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_sir.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/api.ts similarity index 95% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_sir.ts rename to x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/api.ts index 4e74d79c6f4a0..3d81f4782b94b 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_sir.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/api.ts @@ -6,7 +6,7 @@ */ import { isEmpty, isString } from 'lodash'; - +import { api as commonApi } from '../../lib/servicenow/api'; import { ExecutorSubActionPushParamsSIR, ExternalServiceAPI, @@ -15,9 +15,7 @@ import { PushToServiceApiHandlerArgs, PushToServiceApiParamsSIR, PushToServiceResponse, -} from './types'; - -import { api } from './api'; +} from '../../lib/servicenow/types'; const SPLIT_REGEX = /[ ,|\r\n\t]+/; @@ -106,7 +104,7 @@ const pushToServiceHandler = async ({ commentFieldKey, logger, }: PushToServiceApiHandlerArgs): Promise => { - const res = await api.pushToService({ + const res = await commonApi.pushToService({ externalService, params: prepareParams(!!config.usesTableApi, params as PushToServiceApiParamsSIR), config, @@ -148,7 +146,7 @@ const pushToServiceHandler = async ({ return res; }; -export const apiSIR: ExternalServiceAPI = { - ...api, +export const api: ExternalServiceAPI = { + ...commonApi, pushToService: pushToServiceHandler, }; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/index.test.ts new file mode 100644 index 0000000000000..3fff7ae0e389d --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/index.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { loggerMock } from '@kbn/logging-mocks'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { ExecutorParams, ServiceNowPublicConfigurationType } from '../../lib/servicenow/types'; +import { + ServiceNowConnectorType, + ServiceNowConnectorTypeExecutorOptions, + getServiceNowSIRConnectorType, +} from '.'; +import { api } from './api'; + +jest.mock('./api', () => ({ + api: { + getChoices: jest.fn(), + getFields: jest.fn(), + getIncident: jest.fn(), + handshake: jest.fn(), + pushToService: jest.fn(), + }, +})); + +const services = actionsMock.createServices(); +const mockedLogger: jest.Mocked = loggerMock.create(); + +describe('ServiceNow', () => { + const config = { apiUrl: 'https://instance.com' }; + const secrets = { username: 'username', password: 'password' }; + const params = { + subAction: 'pushToService', + subActionParams: { + incident: { + short_description: 'An incident', + description: 'This is serious', + }, + }, + }; + + beforeEach(() => { + (api.pushToService as jest.Mock).mockResolvedValue({ id: 'some-id' }); + }); + + describe('ServiceNow SIR', () => { + let connectorType: ServiceNowConnectorType; + + beforeAll(() => { + connectorType = getServiceNowSIRConnectorType({ + logger: mockedLogger, + }); + }); + + describe('execute()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it pass the correct comment field key', async () => { + const actionId = 'some-action-id'; + const executorOptions = { + actionId, + config, + secrets, + params, + services, + } as unknown as ServiceNowConnectorTypeExecutorOptions< + ServiceNowPublicConfigurationType, + ExecutorParams + >; + await connectorType.executor(executorOptions); + expect((api.pushToService as jest.Mock).mock.calls[0][0].commentFieldKey).toBe( + 'work_notes' + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/index.ts new file mode 100644 index 0000000000000..16db999af8a1e --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/index.ts @@ -0,0 +1,182 @@ +/* + * 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 { curry } from 'lodash'; +import { TypeOf } from '@kbn/config-schema'; + +import { Logger } from '@kbn/core/server'; +import type { + ActionType as ConnectorType, + ActionTypeExecutorOptions as ConnectorTypeExecutorOptions, + ActionTypeExecutorResult as ConnectorTypeExecutorResult, +} from '@kbn/actions-plugin/server/types'; +import { + AlertingConnectorFeatureId, + CasesConnectorFeatureId, + SecurityConnectorFeatureId, +} from '@kbn/actions-plugin/common/types'; +import { validate } from '../../lib/servicenow/validators'; +import { + ExecutorParamsSchemaSIR, + ExternalIncidentServiceConfigurationSchema, + ExternalIncidentServiceSecretConfigurationSchema, +} from '../../lib/servicenow/schema'; +import * as i18n from '../../lib/servicenow/translations'; +import { + ExecutorParams, + ExecutorSubActionPushParams, + ServiceFactory, + ExternalServiceAPI, + ServiceNowPublicConfigurationBaseType, + ExternalService, + ExecutorSubActionCommonFieldsParams, + ExecutorSubActionGetChoicesParams, + PushToServiceResponse, + ServiceNowExecutorResultData, + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, +} from '../../lib/servicenow/types'; +import { + ServiceNowSIRConnectorTypeId, + serviceNowSIRTable, + snExternalServiceConfig, +} from '../../lib/servicenow/config'; +import { createExternalService } from './service'; +import { api as apiSIR } from './api'; +import { throwIfSubActionIsNotSupported } from '../../lib/servicenow/utils'; +import { createServiceWrapper } from '../../lib/servicenow/create_service_wrapper'; + +export { ServiceNowSIRConnectorTypeId, serviceNowSIRTable }; + +export type ActionParamsType = TypeOf; + +interface GetConnectorTypeParams { + logger: Logger; +} + +export type ServiceNowConnectorType< + C extends Record = ServiceNowPublicConfigurationBaseType, + T extends Record = ExecutorParams +> = ConnectorType; + +export type ServiceNowConnectorTypeExecutorOptions< + C extends Record = ServiceNowPublicConfigurationBaseType, + T extends Record = ExecutorParams +> = ConnectorTypeExecutorOptions; + +// connector type definition +export function getServiceNowSIRConnectorType( + params: GetConnectorTypeParams +): ServiceNowConnectorType { + const { logger } = params; + return { + id: ServiceNowSIRConnectorTypeId, + minimumLicenseRequired: 'platinum', + name: i18n.SERVICENOW_SIR, + supportedFeatureIds: [ + AlertingConnectorFeatureId, + CasesConnectorFeatureId, + SecurityConnectorFeatureId, + ], + validate: { + config: { + schema: ExternalIncidentServiceConfigurationSchema, + customValidator: validate.config, + }, + secrets: { + schema: ExternalIncidentServiceSecretConfigurationSchema, + customValidator: validate.secrets, + }, + connector: validate.connector, + params: { + schema: ExecutorParamsSchemaSIR, + }, + }, + executor: curry(executor)({ + logger, + actionTypeId: ServiceNowSIRConnectorTypeId, + createService: createExternalService, + api: apiSIR, + }), + }; +} + +// action executor +const supportedSubActions: string[] = ['getFields', 'pushToService', 'getChoices', 'getIncident']; +async function executor( + { + logger, + actionTypeId, + createService, + api, + }: { + logger: Logger; + actionTypeId: string; + createService: ServiceFactory; + api: ExternalServiceAPI; + }, + execOptions: ServiceNowConnectorTypeExecutorOptions< + ServiceNowPublicConfigurationType, + ExecutorParams + > +): Promise> { + const { actionId, config, params, secrets, services, configurationUtilities } = execOptions; + const { subAction, subActionParams } = params; + const connectorTokenClient = services.connectorTokenClient; + const externalServiceConfig = snExternalServiceConfig[actionTypeId]; + let data: ServiceNowExecutorResultData | null = null; + + const externalService = createServiceWrapper({ + connectorId: actionId, + credentials: { + config, + secrets, + }, + logger, + configurationUtilities, + serviceConfig: externalServiceConfig, + connectorTokenClient, + createServiceFn: createService, + }); + + const apiAsRecord = api as unknown as Record; + throwIfSubActionIsNotSupported({ api: apiAsRecord, subAction, supportedSubActions, logger }); + + if (subAction === 'pushToService') { + const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; + data = await api.pushToService({ + externalService, + params: pushToServiceParams, + config, + secrets, + logger, + commentFieldKey: externalServiceConfig.commentFieldKey, + }); + + logger.debug(`response push to service for incident id: ${data.id}`); + } + + if (subAction === 'getFields') { + const getFieldsParams = subActionParams as ExecutorSubActionCommonFieldsParams; + data = await api.getFields({ + externalService, + params: getFieldsParams, + logger, + }); + } + + if (subAction === 'getChoices') { + const getChoicesParams = subActionParams as ExecutorSubActionGetChoicesParams; + data = await api.getChoices({ + externalService, + params: getChoicesParams, + logger, + }); + } + + return { status: 'ok', data: data ?? {}, actionId }; +} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_sir.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/service.test.ts similarity index 92% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_sir.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/service.test.ts index 0cf30d358b47f..f8033b7e3a60d 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_sir.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/service.test.ts @@ -7,14 +7,14 @@ import axios from 'axios'; -import { createExternalServiceSIR } from './service_sir'; +import { createExternalService } from './service'; import * as utils from '@kbn/actions-plugin/server/lib/axios_utils'; -import { ExternalServiceSIR } from './types'; +import { ExternalServiceSIR } from '../../lib/servicenow/types'; import { Logger } from '@kbn/core/server'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; -import { observables } from './mocks'; -import { snExternalServiceConfig } from './config'; +import { observables } from '../../lib/servicenow/mocks'; +import { snExternalServiceConfig } from '../../lib/servicenow/config'; const logger = loggingSystemMock.create().get() as jest.Mocked; @@ -92,7 +92,7 @@ describe('ServiceNow SIR service', () => { let service: ExternalServiceSIR; beforeEach(() => { - service = createExternalServiceSIR({ + service = createExternalService({ credentials: { config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_sir.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/service.ts similarity index 84% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_sir.ts rename to x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/service.ts index de3220c36bd4c..918710d5b8229 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_sir.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow_sir/service.ts @@ -6,10 +6,15 @@ */ import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; -import { Observable, ExternalServiceSIR, ObservableResponse, ServiceFactory } from './types'; +import { + Observable, + ExternalServiceSIR, + ObservableResponse, + ServiceFactory, +} from '../../lib/servicenow/types'; -import { createExternalService } from './service'; -import { createServiceError } from './utils'; +import { createExternalService as createExternalServiceCommon } from '../../lib/servicenow/service'; +import { createServiceError } from '../../lib/servicenow/utils'; const getAddObservableToIncidentURL = (url: string, incidentID: string) => `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables`; @@ -17,14 +22,14 @@ const getAddObservableToIncidentURL = (url: string, incidentID: string) => const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) => `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`; -export const createExternalServiceSIR: ServiceFactory = ({ +export const createExternalService: ServiceFactory = ({ credentials, logger, configurationUtilities, serviceConfig, axiosInstance, }): ExternalServiceSIR => { - const snService = createExternalService({ + const snService = createExternalServiceCommon({ credentials, logger, configurationUtilities, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/index.ts index db227eb96109a..67a34dd285fe3 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/index.ts @@ -12,19 +12,19 @@ import { getIndexConnectorType, getPagerDutyConnectorType, getServerLogConnectorType, + getServiceNowITOMConnectorType, getSlackConnectorType, getTeamsConnectorType, getWebhookConnectorType, + getXmattersConnectorType, } from './stack'; import { getCasesWebhookConnectorType, getJiraConnectorType, getResilientConnectorType, - getServiceNowITOMConnectorType, getServiceNowITSMConnectorType, getServiceNowSIRConnectorType, getSwimlaneConnectorType, - getXmattersConnectorType, } from './cases'; export type { @@ -35,31 +35,31 @@ export type { SlackActionParams, TeamsActionParams, WebhookActionParams, + XmattersActionParams, } from './stack'; export { EmailConnectorTypeId, IndexConnectorTypeId, PagerDutyConnectorTypeId, + ServiceNowITOMConnectorTypeId, ServerLogConnectorTypeId, SlackConnectorTypeId, TeamsConnectorTypeId, WebhookConnectorTypeId, + XmattersConnectorTypeId, } from './stack'; export type { CasesWebhookActionParams, JiraActionParams, ResilientActionParams, ServiceNowActionParams, - XmattersActionParams, } from './cases'; export { CasesWebhookConnectorTypeId, JiraConnectorTypeId, ResilientConnectorTypeId, - ServiceNowITOMConnectorTypeId, ServiceNowITSMConnectorTypeId, ServiceNowSIRConnectorTypeId, - XmattersConnectorTypeId, } from './cases'; export function registerConnectorTypes({ diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/api.test.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/api.test.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/api.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/api.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/config.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/config.test.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/config.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/config.test.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/config.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/config.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/config.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/config.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/create_service_wrapper.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.test.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/create_service_wrapper.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.test.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/create_service_wrapper.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/create_service_wrapper.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/create_service_wrapper.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/mocks.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/mocks.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/mocks.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/mocks.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/schema.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/schema.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/schema.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.test.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/service.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/translations.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/translations.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/translations.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/translations.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/types.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/types.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/types.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/types.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/utils.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.test.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/utils.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.test.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/utils.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/utils.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/utils.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/validators.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.test.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/validators.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.test.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/validators.ts b/x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/validators.ts rename to x-pack/plugins/stack_connectors/server/connector_types/lib/servicenow/validators.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/index.ts index 18fc54872261e..6fd7faeaf1729 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/index.ts @@ -29,6 +29,8 @@ export { } from './server_log'; export type { ActionParamsType as ServerLogActionParams } from './server_log'; +export { getServiceNowITOMConnectorType, ServiceNowITOMConnectorTypeId } from './servicenow_itom'; + export { getConnectorType as getSlackConnectorType, ConnectorTypeId as SlackConnectorTypeId, @@ -46,3 +48,9 @@ export { ConnectorTypeId as WebhookConnectorTypeId, } from './webhook'; export type { ActionParamsType as WebhookActionParams } from './webhook'; + +export { + getConnectorType as getXmattersConnectorType, + ConnectorTypeId as XmattersConnectorTypeId, +} from './xmatters'; +export type { ActionParamsType as XmattersActionParams } from './xmatters'; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_itom.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/api.test.ts similarity index 87% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_itom.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/api.test.ts index be10a10dfb819..981a8953750d9 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_itom.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/api.test.ts @@ -6,9 +6,9 @@ */ import { Logger } from '@kbn/core/server'; -import { externalServiceITOMMock, itomEventParams } from './mocks'; -import { ExternalServiceITOM } from './types'; -import { apiITOM, prepareParams } from './api_itom'; +import { externalServiceITOMMock, itomEventParams } from '../../lib/servicenow/mocks'; +import { ExternalServiceITOM } from '../../lib/servicenow/types'; +import { api, prepareParams } from './api'; let mockedLogger: jest.Mocked; describe('api_itom', () => { @@ -41,7 +41,7 @@ describe('api_itom', () => { describe('addEvent', () => { test('it adds an event correctly', async () => { - await apiITOM.addEvent({ + await api.addEvent({ externalService, params: itomEventParams, logger: mockedLogger, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_itom.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/api.ts similarity index 91% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_itom.ts rename to x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/api.ts index 668e17a042718..102e8e4191667 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/api_itom.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/api.ts @@ -5,12 +5,12 @@ * 2.0. */ -import { api } from './api'; +import { api as commonApi } from '../../lib/servicenow/api'; import { ExecutorSubActionAddEventParams, AddEventApiHandlerArgs, ExternalServiceApiITOM, -} from './types'; +} from '../../lib/servicenow/types'; const isValidDate = (d: Date) => !isNaN(d.valueOf()); @@ -64,7 +64,7 @@ const addEventServiceHandler = async ({ await itomExternalService.addEvent(preparedParams); }; -export const apiITOM: ExternalServiceApiITOM = { - getChoices: api.getChoices, +export const api: ExternalServiceApiITOM = { + getChoices: commonApi.getChoices, addEvent: addEventServiceHandler, }; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/index.ts new file mode 100644 index 0000000000000..c6d1c5d772899 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/index.ts @@ -0,0 +1,163 @@ +/* + * 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 { curry } from 'lodash'; + +import { Logger } from '@kbn/core/server'; +import type { + ActionType as ConnectorType, + ActionTypeExecutorOptions as ConnectorTypeExecutorOptions, + ActionTypeExecutorResult as ConnectorTypeExecutorResult, +} from '@kbn/actions-plugin/server/types'; +import { + AlertingConnectorFeatureId, + SecurityConnectorFeatureId, +} from '@kbn/actions-plugin/common/types'; +import { validate } from '../../lib/servicenow/validators'; +import { + ExecutorParamsSchemaITOM, + ExternalIncidentServiceSecretConfigurationSchema, + ExternalIncidentServiceConfigurationBaseSchema, +} from '../../lib/servicenow/schema'; +import * as i18n from '../../lib/servicenow/translations'; +import { + ExecutorSubActionGetChoicesParams, + PushToServiceResponse, + ServiceNowExecutorResultData, + ServiceNowSecretConfigurationType, + ServiceFactory, + ExecutorParamsITOM, + ExecutorSubActionAddEventParams, + ExternalServiceApiITOM, + ExternalServiceITOM, + ServiceNowPublicConfigurationBaseType, +} from '../../lib/servicenow/types'; +import { + ServiceNowITOMConnectorTypeId, + snExternalServiceConfig, +} from '../../lib/servicenow/config'; +import { throwIfSubActionIsNotSupported } from '../../lib/servicenow/utils'; +import { createExternalService } from './service'; +import { api as apiITOM } from './api'; +import { createServiceWrapper } from '../../lib/servicenow/create_service_wrapper'; + +export { ServiceNowITOMConnectorTypeId }; + +interface GetConnectorTypeParams { + logger: Logger; +} + +export type ServiceNowConnectorType< + C extends Record = ServiceNowPublicConfigurationBaseType, + T extends Record = ExecutorParamsITOM +> = ConnectorType; + +export type ServiceNowConnectorTypeExecutorOptions< + C extends Record = ServiceNowPublicConfigurationBaseType, + T extends Record = ExecutorParamsITOM +> = ConnectorTypeExecutorOptions; + +// connector type definition +export function getServiceNowITOMConnectorType( + params: GetConnectorTypeParams +): ServiceNowConnectorType { + const { logger } = params; + return { + id: ServiceNowITOMConnectorTypeId, + minimumLicenseRequired: 'platinum', + name: i18n.SERVICENOW_ITOM, + supportedFeatureIds: [AlertingConnectorFeatureId, SecurityConnectorFeatureId], + validate: { + config: { + schema: ExternalIncidentServiceConfigurationBaseSchema, + customValidator: validate.config, + }, + secrets: { + schema: ExternalIncidentServiceSecretConfigurationSchema, + customValidator: validate.secrets, + }, + connector: validate.connector, + params: { + schema: ExecutorParamsSchemaITOM, + }, + }, + executor: curry(executorITOM)({ + logger, + actionTypeId: ServiceNowITOMConnectorTypeId, + createService: createExternalService, + api: apiITOM, + }), + }; +} + +// action executor +const supportedSubActionsITOM = ['addEvent', 'getChoices']; +async function executorITOM( + { + logger, + actionTypeId, + createService, + api, + }: { + logger: Logger; + actionTypeId: string; + createService: ServiceFactory; + api: ExternalServiceApiITOM; + }, + execOptions: ServiceNowConnectorTypeExecutorOptions< + ServiceNowPublicConfigurationBaseType, + ExecutorParamsITOM + > +): Promise> { + const { actionId, config, params, secrets, configurationUtilities } = execOptions; + const { subAction, subActionParams } = params; + const connectorTokenClient = execOptions.services.connectorTokenClient; + const externalServiceConfig = snExternalServiceConfig[actionTypeId]; + let data: ServiceNowExecutorResultData | null = null; + + const externalService = createServiceWrapper({ + connectorId: actionId, + credentials: { + config, + secrets, + }, + logger, + configurationUtilities, + serviceConfig: externalServiceConfig, + connectorTokenClient, + createServiceFn: createService, + }); + + const apiAsRecord = api as unknown as Record; + + throwIfSubActionIsNotSupported({ + api: apiAsRecord, + subAction, + supportedSubActions: supportedSubActionsITOM, + logger, + }); + + if (subAction === 'addEvent') { + const eventParams = subActionParams as ExecutorSubActionAddEventParams; + await api.addEvent({ + externalService, + params: eventParams, + logger, + }); + } + + if (subAction === 'getChoices') { + const getChoicesParams = subActionParams as ExecutorSubActionGetChoicesParams; + data = await api.getChoices({ + externalService, + params: getChoicesParams, + logger, + }); + } + + return { status: 'ok', data: data ?? {}, actionId }; +} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_itom.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/service.test.ts similarity index 89% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_itom.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/service.test.ts index ddc347f49dc76..13d03df0cd972 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_itom.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/service.test.ts @@ -7,14 +7,14 @@ import axios from 'axios'; -import { createExternalServiceITOM } from './service_itom'; +import { createExternalService } from './service'; import * as utils from '@kbn/actions-plugin/server/lib/axios_utils'; -import { ExternalServiceITOM } from './types'; +import { ExternalServiceITOM } from '../../lib/servicenow/types'; import { Logger } from '@kbn/core/server'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; -import { snExternalServiceConfig } from './config'; -import { itomEventParams, serviceNowChoices } from './mocks'; +import { snExternalServiceConfig } from '../../lib/servicenow/config'; +import { itomEventParams, serviceNowChoices } from '../../lib/servicenow/mocks'; const logger = loggingSystemMock.create().get() as jest.Mocked; @@ -35,7 +35,7 @@ describe('ServiceNow SIR service', () => { let service: ExternalServiceITOM; beforeEach(() => { - service = createExternalServiceITOM({ + service = createExternalService({ credentials: { config: { apiUrl: 'https://example.com/', isOAuth: false }, secrets: { username: 'admin', password: 'admin' }, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_itom.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/service.ts similarity index 73% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_itom.ts rename to x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/service.ts index 74e76e4aedc1d..74d704a116016 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases/servicenow/service_itom.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/servicenow_itom/service.ts @@ -6,21 +6,25 @@ */ import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; -import { ServiceFactory, ExternalServiceITOM, ExecutorSubActionAddEventParams } from './types'; +import { + ServiceFactory, + ExternalServiceITOM, + ExecutorSubActionAddEventParams, +} from '../../lib/servicenow/types'; -import { createExternalService } from './service'; -import { createServiceError } from './utils'; +import { createExternalService as createExternalServiceCommon } from '../../lib/servicenow/service'; +import { createServiceError } from '../../lib/servicenow/utils'; const getAddEventURL = (url: string) => `${url}/api/global/em/jsonv2`; -export const createExternalServiceITOM: ServiceFactory = ({ +export const createExternalService: ServiceFactory = ({ credentials, logger, configurationUtilities, serviceConfig, axiosInstance, }): ExternalServiceITOM => { - const snService = createExternalService({ + const snService = createExternalServiceCommon({ credentials, logger, configurationUtilities, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/xmatters/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/xmatters/index.test.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/xmatters/index.test.ts rename to x-pack/plugins/stack_connectors/server/connector_types/stack/xmatters/index.test.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/xmatters/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/xmatters/index.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/xmatters/index.ts rename to x-pack/plugins/stack_connectors/server/connector_types/stack/xmatters/index.ts diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases/xmatters/post_xmatters.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/xmatters/post_xmatters.ts similarity index 100% rename from x-pack/plugins/stack_connectors/server/connector_types/cases/xmatters/post_xmatters.ts rename to x-pack/plugins/stack_connectors/server/connector_types/stack/xmatters/post_xmatters.ts diff --git a/x-pack/plugins/stack_connectors/server/types.ts b/x-pack/plugins/stack_connectors/server/types.ts index 01f7768bfac10..1da81e3dc5436 100644 --- a/x-pack/plugins/stack_connectors/server/types.ts +++ b/x-pack/plugins/stack_connectors/server/types.ts @@ -6,7 +6,7 @@ */ export type { GetFieldsByIssueTypeResponse as JiraGetFieldsResponse } from './connector_types/cases/jira/types'; -export type { GetCommonFieldsResponse as ServiceNowGetFieldsResponse } from './connector_types/cases/servicenow/types'; +export type { GetCommonFieldsResponse as ServiceNowGetFieldsResponse } from './connector_types/lib/servicenow/types'; export type { GetCommonFieldsResponse as ResilientGetFieldsResponse } from './connector_types/cases/resilient/types'; export type { SwimlanePublicConfigurationType } from './connector_types/cases/swimlane/types'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/single_ping_result.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/single_ping_result.tsx index c1da7e0035097..89fb255dc80ef 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/single_ping_result.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/monitor_test_result/single_ping_result.tsx @@ -18,8 +18,8 @@ import { formatTestDuration } from '../../../utils/monitor_test_result/test_time export const SinglePingResult = ({ ping, loading }: { ping: Ping; loading: boolean }) => { const ip = !loading ? ping?.resolve?.ip : undefined; - const durationUs = !loading ? ping?.monitor?.duration?.us : undefined; - const rtt = !loading ? ping?.resolve?.rtt?.us : undefined; + const durationUs = !loading ? ping?.monitor?.duration?.us ?? NaN : NaN; + const rtt = !loading ? ping?.resolve?.rtt?.us ?? NaN : NaN; const url = !loading ? ping?.url?.full : undefined; const responseStatus = !loading ? ping?.http?.response?.status_code : undefined; @@ -29,10 +29,12 @@ export const SinglePingResult = ({ ping, loading }: { ping: Ping; loading: boole {ip} {DURATION_LABEL} - {formatTestDuration(durationUs)} + {isNaN(durationUs) ? '' : formatTestDuration(durationUs)} rtt - {formatTestDuration(rtt)} + + {isNaN(rtt) ? '' : formatTestDuration(rtt)} + URL {url} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx index 85adcd7ff3c0c..fc01a5d9164ee 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/last_ten_test_runs.tsx @@ -31,7 +31,6 @@ import { import { useSyntheticsSettingsContext } from '../../../contexts/synthetics_settings_context'; import { sortPings } from '../../../utils/monitor_test_result/sort_pings'; -import { checkIsStalePing } from '../../../utils/monitor_test_result/check_pings'; import { selectPingsLoading, selectMonitorRecentPings, selectPingsError } from '../../../state'; import { parseBadgeStatus, StatusBadge } from '../../common/monitor_test_result/status_badge'; import { isStepEnd } from '../../common/monitor_test_result/browser_steps_list'; @@ -56,8 +55,6 @@ export const LastTenTestRuns = () => { const { monitor } = useSelectedMonitor(); const isBrowserMonitor = monitor?.[ConfigKey.MONITOR_TYPE] === DataStream.BROWSER; - const hasStalePings = checkIsStalePing(monitor, pings?.[0]); - const loading = hasStalePings || pingsLoading; const sorting: EuiTableSortingType = { sort: { @@ -146,12 +143,12 @@ export const LastTenTestRuns = () => { { latestPing?.monitor?.check_group ); - const hasStalePings = checkIsStalePing(monitor, latestPing); - const loading = hasStalePings || stepsLoading || pingsLoading; + const loading = stepsLoading || pingsLoading; return ( diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/index.ts index b1fb95d5d5ee4..1c9df0c866ad2 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_details/index.ts @@ -6,13 +6,16 @@ */ import { createReducer } from '@reduxjs/toolkit'; +import { EncryptedSyntheticsSavedMonitor, Ping } from '../../../../../common/runtime_types'; +import { checkIsStalePing } from '../../utils/monitor_test_result/check_pings'; + import { IHttpSerializedFetchError } from '../utils/http_error'; + import { getMonitorRecentPingsAction, setMonitorDetailsLocationAction, getMonitorAction, } from './actions'; -import { EncryptedSyntheticsSavedMonitor, Ping } from '../../../../../common/runtime_types'; export interface MonitorDetailsState { pings: Ping[]; @@ -38,8 +41,9 @@ export const monitorDetailsReducer = createReducer(initialState, (builder) => { state.selectedLocationId = action.payload; }) - .addCase(getMonitorRecentPingsAction.get, (state) => { + .addCase(getMonitorRecentPingsAction.get, (state, action) => { state.loading = true; + state.pings = state.pings.filter((ping) => !checkIsStalePing(action.payload.monitorId, ping)); }) .addCase(getMonitorRecentPingsAction.success, (state, action) => { state.pings = action.payload.pings; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/check_pings.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/check_pings.ts index 043aefbac819b..233d80ea6b7da 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/check_pings.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/monitor_test_result/check_pings.ts @@ -5,18 +5,15 @@ * 2.0. */ -import { EncryptedSyntheticsSavedMonitor, Ping } from '../../../../../common/runtime_types'; +import { Ping } from '../../../../../common/runtime_types'; /** * Checks if the loaded/cached pings are of the current selected monitors */ -export function checkIsStalePing( - monitor: EncryptedSyntheticsSavedMonitor | null, - ping: Ping | undefined -) { - if (!monitor?.id || !ping?.monitor?.id) { +export function checkIsStalePing(monitorOrConfigId: string | undefined, ping: Ping | undefined) { + if (!monitorOrConfigId || !ping?.monitor?.id) { return true; } - return monitor.id !== ping.monitor.id && monitor.id !== ping.config_id; + return monitorOrConfigId !== ping.monitor.id && monitorOrConfigId !== ping.config_id; } diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_status_alert/alert_monitor_status.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_status_alert/alert_monitor_status.test.tsx index 3f675597301f3..6dbef12159a18 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_status_alert/alert_monitor_status.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_status_alert/alert_monitor_status.test.tsx @@ -14,8 +14,7 @@ import { } from './alert_monitor_status'; import { render } from '../../../../lib/helper/rtl_helpers'; -// FLAKY: https://github.com/elastic/kibana/issues/133226 -describe.skip('alert monitor status component', () => { +describe('alert monitor status component', () => { jest.setTimeout(10_000); describe('hasFilters', () => { diff --git a/x-pack/plugins/synthetics/server/synthetics_service/service_api_client.ts b/x-pack/plugins/synthetics/server/synthetics_service/service_api_client.ts index 93b47e9467be0..f447a21c3bee7 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/service_api_client.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/service_api_client.ts @@ -160,12 +160,11 @@ export class ServiceAPIClient { stack_version: this.kibanaVersion, is_edit: isEdit, }, - headers: - process.env.NODE_ENV !== 'production' && this.authorization - ? { - Authorization: this.authorization, - } - : undefined, + headers: this.authorization + ? { + Authorization: this.authorization, + } + : undefined, httpsAgent: this.getHttpsAgent(url), }) ); diff --git a/x-pack/plugins/threat_intelligence/cypress/e2e/indicators.cy.ts b/x-pack/plugins/threat_intelligence/cypress/e2e/indicators.cy.ts index c5d67894aa0ff..17c4d79e60dcc 100644 --- a/x-pack/plugins/threat_intelligence/cypress/e2e/indicators.cy.ts +++ b/x-pack/plugins/threat_intelligence/cypress/e2e/indicators.cy.ts @@ -6,30 +6,31 @@ */ import { + ADD_INTEGRATIONS_BUTTON, + BREADCRUMBS, DEFAULT_LAYOUT_TITLE, - FLYOUT_JSON, - FLYOUT_TABLE, - FLYOUT_TABS, - FLYOUT_TITLE, - INDICATORS_TABLE, - TOGGLE_FLYOUT_BUTTON, - FILTERS_GLOBAL_CONTAINER, - TIME_RANGE_PICKER, - QUERY_INPUT, - TABLE_CONTROLS, - INDICATOR_TYPE_CELL, EMPTY_STATE, - FIELD_SELECTOR, - BREADCRUMBS, - LEADING_BREADCRUMB, ENDING_BREADCRUMB, FIELD_BROWSER, FIELD_BROWSER_MODAL, - FIELD_SELECTOR_TOGGLE_BUTTON, + FIELD_SELECTOR, FIELD_SELECTOR_INPUT, FIELD_SELECTOR_LIST, + FIELD_SELECTOR_TOGGLE_BUTTON, + FILTERS_GLOBAL_CONTAINER, + FLYOUT_JSON, + FLYOUT_TABLE, + FLYOUT_TABS, + FLYOUT_TITLE, + INDICATOR_TYPE_CELL, + INDICATORS_TABLE, INSPECTOR_BUTTON, INSPECTOR_PANEL, + LEADING_BREADCRUMB, + QUERY_INPUT, + TABLE_CONTROLS, + TIME_RANGE_PICKER, + TOGGLE_FLYOUT_BUTTON, } from '../screens/indicators'; import { login } from '../tasks/login'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; @@ -189,4 +190,20 @@ describe('Indicators', () => { }); }); }); + + describe('Add integrations', () => { + before(() => { + cy.visit(THREAT_INTELLIGENCE); + + selectRange(); + }); + + describe('when the global header add integrations button is clicked', () => { + it('should navigate to the Integrations page with Threat Intelligence category selected', () => { + cy.get(ADD_INTEGRATIONS_BUTTON).click(); + + cy.url().should('include', 'threat_intel'); + }); + }); + }); }); diff --git a/x-pack/plugins/threat_intelligence/cypress/screens/indicators.ts b/x-pack/plugins/threat_intelligence/cypress/screens/indicators.ts index 0464e57c6749b..9e32e8843c4c3 100644 --- a/x-pack/plugins/threat_intelligence/cypress/screens/indicators.ts +++ b/x-pack/plugins/threat_intelligence/cypress/screens/indicators.ts @@ -117,3 +117,5 @@ export const INDICATOR_FLYOUT_INVESTIGATE_IN_TIMELINE_BUTTON = export const INSPECTOR_BUTTON = '[data-test-subj="tiIndicatorsGridInspect"]'; export const INSPECTOR_PANEL = '[data-test-subj="inspectorPanel"]'; + +export const ADD_INTEGRATIONS_BUTTON = '[data-test-subj="add-data"]'; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 5076646708475..8a1fda344a013 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -9314,10 +9314,7 @@ "xpack.cases.casesStats.mttr": "Temps moyen avant fermeture", "xpack.cases.casesStats.mttrDescription": "La durée moyenne (de la création à la clôture) de vos cas en cours", "xpack.cases.caseTable.bulkActions": "Actions groupées", - "xpack.cases.caseTable.bulkActions.closeSelectedTitle": "Fermer la sélection", "xpack.cases.caseTable.bulkActions.deleteSelectedTitle": "Supprimer la sélection", - "xpack.cases.caseTable.bulkActions.markInProgressTitle": "Marquer comme étant en cours", - "xpack.cases.caseTable.bulkActions.openSelectedTitle": "Ouvrir la sélection", "xpack.cases.caseTable.changeStatus": "Modifier le statut", "xpack.cases.caseTable.closed": "Fermé", "xpack.cases.caseTable.closedCases": "Cas fermés", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index be84b953d2d01..79f3d61efeae0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9301,10 +9301,7 @@ "xpack.cases.casesStats.mttr": "クローズまでの平均時間", "xpack.cases.casesStats.mttrDescription": "現在のアセットの平均期間(作成から終了まで)", "xpack.cases.caseTable.bulkActions": "一斉アクション", - "xpack.cases.caseTable.bulkActions.closeSelectedTitle": "選択した項目を閉じる", "xpack.cases.caseTable.bulkActions.deleteSelectedTitle": "選択した項目を削除", - "xpack.cases.caseTable.bulkActions.markInProgressTitle": "実行中に設定", - "xpack.cases.caseTable.bulkActions.openSelectedTitle": "選択した項目を開く", "xpack.cases.caseTable.changeStatus": "ステータスの変更", "xpack.cases.caseTable.closed": "終了", "xpack.cases.caseTable.closedCases": "終了したケース", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index bfcc8fdf4108f..d0852f4c84387 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9319,10 +9319,7 @@ "xpack.cases.casesStats.mttr": "平均关闭时间", "xpack.cases.casesStats.mttrDescription": "当前案例的平均持续时间(从创建到关闭)", "xpack.cases.caseTable.bulkActions": "批处理操作", - "xpack.cases.caseTable.bulkActions.closeSelectedTitle": "关闭所选", "xpack.cases.caseTable.bulkActions.deleteSelectedTitle": "删除所选", - "xpack.cases.caseTable.bulkActions.markInProgressTitle": "标记为进行中", - "xpack.cases.caseTable.bulkActions.openSelectedTitle": "打开所选", "xpack.cases.caseTable.changeStatus": "更改状态", "xpack.cases.caseTable.closed": "已关闭", "xpack.cases.caseTable.closedCases": "已关闭案例", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx index 76804970ebee5..0055349deb8ff 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx @@ -266,7 +266,7 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab /> )} - {alertsCount >= 0 && ( + {alertsCount > 0 && ( { + describe('disable', () => { const objectRemover = new ObjectRemover(supertestWithoutAuth); const ruleUtils = new RuleUtils({ space: Spaces.space1, supertestWithoutAuth }); @@ -52,15 +51,17 @@ export default function createDisableRuleTests({ getService }: FtrProviderContex await ruleUtils.disable(createdRule.id); // task doc should still exist but be disabled - const taskRecord = await getScheduledTask(createdRule.scheduled_task_id); - expect(taskRecord.type).to.eql('task'); - expect(taskRecord.task.taskType).to.eql('alerting:test.noop'); - expect(JSON.parse(taskRecord.task.params)).to.eql({ - alertId: createdRule.id, - spaceId: Spaces.space1.id, - consumer: 'alertsFixture', + await retry.try(async () => { + const taskRecord = await getScheduledTask(createdRule.scheduled_task_id); + expect(taskRecord.type).to.eql('task'); + expect(taskRecord.task.taskType).to.eql('alerting:test.noop'); + expect(JSON.parse(taskRecord.task.params)).to.eql({ + alertId: createdRule.id, + spaceId: Spaces.space1.id, + consumer: 'alertsFixture', + }); + expect(taskRecord.task.enabled).to.eql(false); }); - expect(taskRecord.task.enabled).to.eql(false); // Ensure AAD isn't broken await checkAAD({ @@ -196,15 +197,17 @@ export default function createDisableRuleTests({ getService }: FtrProviderContex .expect(204); // task doc should still exist but be disabled - const taskRecord = await getScheduledTask(createdRule.scheduled_task_id); - expect(taskRecord.type).to.eql('task'); - expect(taskRecord.task.taskType).to.eql('alerting:test.noop'); - expect(JSON.parse(taskRecord.task.params)).to.eql({ - alertId: createdRule.id, - spaceId: Spaces.space1.id, - consumer: 'alertsFixture', + await retry.try(async () => { + const taskRecord = await getScheduledTask(createdRule.scheduled_task_id); + expect(taskRecord.type).to.eql('task'); + expect(taskRecord.task.taskType).to.eql('alerting:test.noop'); + expect(JSON.parse(taskRecord.task.params)).to.eql({ + alertId: createdRule.id, + spaceId: Spaces.space1.id, + consumer: 'alertsFixture', + }); + expect(taskRecord.task.enabled).to.eql(false); }); - expect(taskRecord.task.enabled).to.eql(false); // Ensure AAD isn't broken await checkAAD({ diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/run_soon.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/run_soon.ts index f32665a5a1fac..bba958d47d241 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/run_soon.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/run_soon.ts @@ -18,8 +18,7 @@ export default function createRunSoonTests({ getService }: FtrProviderContext) { const es = getService('es'); const esArchiver = getService('esArchiver'); - // Failing: See https://github.com/elastic/kibana/issues/142564 - describe.skip('runSoon', () => { + describe('runSoon', () => { const objectRemover = new ObjectRemover(supertest); before(async () => { @@ -36,7 +35,7 @@ export default function createRunSoonTests({ getService }: FtrProviderContext) { it('should successfully run rule where scheduled task id is different than rule id', async () => { await retry.try(async () => { - // Sometimes the rule may already be running. Try until it isn't + // Sometimes the rule may already be running, which returns a 200. Try until it isn't const response = await supertest .post(`${getUrlPrefix(``)}/internal/alerting/rule/${LOADED_RULE_ID}/_run_soon`) .set('kbn-xsrf', 'foo'); @@ -53,10 +52,13 @@ export default function createRunSoonTests({ getService }: FtrProviderContext) { expect(response.status).to.eql(200); objectRemover.add('default', response.body.id, 'rule', 'alerting'); - const runSoonResponse = await supertest - .post(`${getUrlPrefix(``)}/internal/alerting/rule/${response.body.id}/_run_soon`) - .set('kbn-xsrf', 'foo'); - expect(runSoonResponse.status).to.eql(204); + await retry.try(async () => { + // Sometimes the rule may already be running, which returns a 200. Try until it isn't + const runSoonResponse = await supertest + .post(`${getUrlPrefix(``)}/internal/alerting/rule/${response.body.id}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(runSoonResponse.status).to.eql(204); + }); }); it('should return message when task does not exist for rule', async () => { diff --git a/x-pack/test/functional/apps/ml/short_tests/notifications/notification_list.ts b/x-pack/test/functional/apps/ml/short_tests/notifications/notification_list.ts index 30cad369259c3..494b9256cd6d7 100644 --- a/x-pack/test/functional/apps/ml/short_tests/notifications/notification_list.ts +++ b/x-pack/test/functional/apps/ml/short_tests/notifications/notification_list.ts @@ -13,8 +13,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const ml = getService('ml'); const browser = getService('browser'); - // Failing: See https://github.com/elastic/kibana/issues/142248 - describe.skip('Notifications list', function () { + const configs = [ + { jobId: 'fq_001', spaceId: undefined }, + { jobId: 'fq_002', spaceId: 'space1' }, + ]; + + describe('Notifications list', function () { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); @@ -22,10 +26,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // Prepare jobs to generate notifications await Promise.all( - [ - { jobId: 'fq_001', spaceId: undefined }, - { jobId: 'fq_002', spaceId: 'space1' }, - ].map(async (v) => { + configs.map(async (v) => { const datafeedConfig = ml.commonConfig.getADFqDatafeedConfig(v.jobId); await ml.api.createAnomalyDetectionJob( @@ -45,7 +46,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - await ml.api.cleanMlIndices(); + for (const { jobId } of configs) { + await ml.api.deleteAnomalyDetectionJobES(jobId); + } await ml.testResources.cleanMLSavedObjects(); await ml.testResources.deleteIndexPatternByTitle('ft_farequote'); }); diff --git a/x-pack/test/functional/services/cases/list.ts b/x-pack/test/functional/services/cases/list.ts index a5f650198cf22..15e2e40b0ca71 100644 --- a/x-pack/test/functional/services/cases/list.ts +++ b/x-pack/test/functional/services/cases/list.ts @@ -23,6 +23,12 @@ export function CasesTableServiceProvider( const retry = getService('retry'); const config = getService('config'); + const assertCaseExists = (index: number, totalCases: number) => { + if (index > totalCases - 1) { + throw new Error('Cannot get case from table. Index is greater than the length of all rows'); + } + }; + return { /** * Goes to the first case listed on the table. @@ -40,11 +46,10 @@ export function CasesTableServiceProvider( }); }, - async deleteFirstListedCase() { - await testSubjects.existOrFail('action-delete', { - timeout: config.get('timeouts.waitFor'), - }); - await testSubjects.click('action-delete'); + async deleteCase(index: number = 0) { + this.openRowActions(index); + await testSubjects.existOrFail('cases-bulk-action-delete'); + await testSubjects.click('cases-bulk-action-delete'); await testSubjects.existOrFail('confirmModalConfirmButton', { timeout: config.get('timeouts.waitFor'), }); @@ -55,10 +60,13 @@ export function CasesTableServiceProvider( }, async bulkDeleteAllCases() { - await testSubjects.setCheckbox('checkboxSelectAll', 'check'); - const button = await find.byCssSelector('[aria-label="Bulk actions"]'); - await button.click(); - await testSubjects.click('cases-bulk-delete-button'); + await this.selectAllCasesAndOpenBulkActions(); + + await testSubjects.existOrFail('cases-bulk-action-delete'); + await testSubjects.click('cases-bulk-action-delete'); + await testSubjects.existOrFail('confirmModalConfirmButton', { + timeout: config.get('timeouts.waitFor'), + }); await testSubjects.click('confirmModalConfirmButton'); }, @@ -109,9 +117,7 @@ export function CasesTableServiceProvider( async getCaseFromTable(index: number) { const rows = await find.allByCssSelector('[data-test-subj*="cases-table-row-"', 100); - if (index > rows.length) { - throw new Error('Cannot get case from table. Index is greater than the length of all rows'); - } + assertCaseExists(index, rows.length); return rows[index] ?? null; }, @@ -155,5 +161,55 @@ export function CasesTableServiceProvider( async refreshTable() { await testSubjects.click('all-cases-refresh'); }, + + async openRowActions(index: number) { + const rows = await find.allByCssSelector( + '[data-test-subj*="case-action-popover-button-"', + 100 + ); + + assertCaseExists(index, rows.length); + + const row = rows[index]; + await row.click(); + await find.existsByCssSelector('[data-test-subj*="case-action-popover-"'); + }, + + async selectAllCasesAndOpenBulkActions() { + await testSubjects.setCheckbox('checkboxSelectAll', 'check'); + const button = await find.byCssSelector('[aria-label="Bulk actions"]'); + await button.click(); + }, + + async changeStatus(status: CaseStatuses, index: number) { + await this.openRowActions(index); + + await testSubjects.existOrFail('cases-bulk-action-delete'); + + await find.existsByCssSelector('[data-test-subj*="case-action-status-panel-"'); + const statusButton = await find.byCssSelector('[data-test-subj*="case-action-status-panel-"'); + + statusButton.click(); + + await testSubjects.existOrFail(`cases-bulk-action-status-${status}`); + await testSubjects.click(`cases-bulk-action-status-${status}`); + }, + + async bulkChangeStatusCases(status: CaseStatuses) { + await this.selectAllCasesAndOpenBulkActions(); + + await testSubjects.existOrFail('case-bulk-action-status'); + await testSubjects.click('case-bulk-action-status'); + await testSubjects.existOrFail(`cases-bulk-action-status-${status}`); + await testSubjects.click(`cases-bulk-action-status-${status}`); + }, + + async selectAndChangeStatusOfAllCases(status: CaseStatuses) { + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('cases-table', { timeout: 20 * 1000 }); + await header.waitUntilLoadingHasFinished(); + await testSubjects.missingOrFail('cases-table-loading', { timeout: 5000 }); + await this.bulkChangeStatusCases(status); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/deletion.ts b/x-pack/test/functional_with_es_ssl/apps/cases/deletion.ts index b0e79c195b719..f0ea7c60bc7c9 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/deletion.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/deletion.ts @@ -71,8 +71,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await cases.casesTable.validateCasesTableHasNthRows(0); }); - it(`User ${user.username} can delete a case using the trash icon in the table row`, async () => { - await cases.casesTable.deleteFirstListedCase(); + it(`User ${user.username} can delete a case using the row actions`, async () => { + await cases.casesTable.deleteCase(0); + await cases.casesTable.waitForTableToFinishLoading(); + await cases.casesTable.validateCasesTableHasNthRows(1); }); }); }); @@ -103,10 +105,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); describe('all cases list page', () => { + it(`User ${user.username} cannot delete cases using individual row actions`, async () => { + await cases.casesTable.openRowActions(0); + await testSubjects.missingOrFail('cases-bulk-action-delete'); + }); + it(`User ${user.username} cannot delete cases using bulk actions or individual row trash icon`, async () => { - await testSubjects.missingOrFail('case-table-bulk-actions'); - await testSubjects.missingOrFail('checkboxSelectAll'); - await testSubjects.missingOrFail('action-delete'); + await cases.casesTable.selectAllCasesAndOpenBulkActions(); + await testSubjects.missingOrFail('cases-bulk-action-delete'); }); }); }); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts index ec8e05ceb9b9d..c09a5e67e1b86 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts @@ -20,7 +20,6 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const header = getPageObject('header'); const testSubjects = getService('testSubjects'); const cases = getService('cases'); - const retry = getService('retry'); const browser = getService('browser'); describe('cases list', () => { @@ -56,33 +55,44 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); }); - describe('deleting', () => { - before(async () => { - await cases.api.createNthRandomCases(8); - await cases.api.createCase({ title: 'delete me', tags: ['one'] }); - await header.waitUntilLoadingHasFinished(); - await cases.casesTable.waitForCasesToBeListed(); - }); + describe('bulk actions', () => { + describe('delete', () => { + before(async () => { + await cases.api.createNthRandomCases(8); + await cases.api.createCase({ title: 'delete me', tags: ['one'] }); + await header.waitUntilLoadingHasFinished(); + await cases.casesTable.waitForCasesToBeListed(); + }); - after(async () => { - await cases.api.deleteAllCases(); - await cases.casesTable.waitForCasesToBeDeleted(); + after(async () => { + await cases.api.deleteAllCases(); + await cases.casesTable.waitForCasesToBeDeleted(); + }); + + it('bulk delete cases from the list', async () => { + await cases.casesTable.selectAndDeleteAllCases(); + await cases.casesTable.waitForTableToFinishLoading(); + await cases.casesTable.validateCasesTableHasNthRows(0); + }); }); - it('deletes a case correctly from the list', async () => { - await cases.casesTable.deleteFirstListedCase(); - await cases.casesTable.waitForTableToFinishLoading(); + describe('status', () => { + before(async () => { + await cases.api.createNthRandomCases(2); + await header.waitUntilLoadingHasFinished(); + await cases.casesTable.waitForCasesToBeListed(); + }); - await retry.tryForTime(2000, async () => { - const firstRow = await testSubjects.find('case-details-link'); - expect(await firstRow.getVisibleText()).not.to.be('delete me'); + after(async () => { + await cases.api.deleteAllCases(); + await cases.casesTable.waitForCasesToBeDeleted(); }); - }); - it('bulk delete cases from the list', async () => { - await cases.casesTable.selectAndDeleteAllCases(); - await cases.casesTable.waitForTableToFinishLoading(); - await cases.casesTable.validateCasesTableHasNthRows(0); + it('change the status of cases to in-progress correctly', async () => { + await cases.casesTable.selectAndChangeStatusOfAllCases(CaseStatuses['in-progress']); + await cases.casesTable.waitForTableToFinishLoading(); + await testSubjects.missingOrFail('status-badge-open'); + }); }); }); @@ -193,7 +203,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('filters cases by status', async () => { - await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); + await cases.casesTable.changeStatus(CaseStatuses['in-progress'], 0); + await testSubjects.existOrFail(`status-badge-${CaseStatuses['in-progress']}`); await cases.casesTable.filterByStatus(CaseStatuses['in-progress']); await cases.casesTable.validateCasesTableHasNthRows(1); }); @@ -277,28 +288,52 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); }); - describe('changes status from the list', () => { - before(async () => { - await cases.api.createNthRandomCases(1); - await header.waitUntilLoadingHasFinished(); - await cases.casesTable.waitForCasesToBeListed(); - }); + describe('row actions', () => { + describe('Status', () => { + before(async () => { + await cases.api.createNthRandomCases(1); + await header.waitUntilLoadingHasFinished(); + await cases.casesTable.waitForCasesToBeListed(); + }); - after(async () => { - await cases.api.deleteAllCases(); - await cases.casesTable.waitForCasesToBeDeleted(); - }); + after(async () => { + await cases.api.deleteAllCases(); + await cases.casesTable.waitForCasesToBeDeleted(); + }); - it('to in progress', async () => { - await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses['in-progress']); - }); + it('to in progress', async () => { + await cases.casesTable.changeStatus(CaseStatuses['in-progress'], 0); + await testSubjects.existOrFail(`status-badge-${CaseStatuses['in-progress']}`); + }); + + it('to closed', async () => { + await cases.casesTable.changeStatus(CaseStatuses.closed, 0); + await testSubjects.existOrFail(`status-badge-${CaseStatuses.closed}`); + }); - it('to closed', async () => { - await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses.closed); + it('to open', async () => { + await cases.casesTable.changeStatus(CaseStatuses.open, 0); + await testSubjects.existOrFail(`status-badge-${CaseStatuses.open}`); + }); }); - it('to open', async () => { - await cases.common.changeCaseStatusViaDropdownAndVerify(CaseStatuses.open); + describe('Delete', () => { + before(async () => { + await cases.api.createNthRandomCases(1); + await header.waitUntilLoadingHasFinished(); + await cases.casesTable.waitForCasesToBeListed(); + }); + + after(async () => { + await cases.api.deleteAllCases(); + await cases.casesTable.waitForCasesToBeDeleted(); + }); + + it('deletes a case correctly', async () => { + await cases.casesTable.deleteCase(0); + await cases.casesTable.waitForTableToFinishLoading(); + await cases.casesTable.validateCasesTableHasNthRows(0); + }); }); }); }); diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts index fe2099df32bda..636f16c5b98ff 100644 --- a/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/alert_flyout.ts @@ -142,8 +142,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/g - describe.skip('tls alert', function () { + describe('tls alert', function () { const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078'; const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078'; let alerts: any; @@ -185,7 +184,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('has created a valid alert with expected parameters', async () => { let alert: any; - await retry.tryForTime(15000, async () => { + await retry.tryForTime(60 * 1000, async () => { const apiResponse = await supertest.get(`/api/alerts/_find?search=${alertId}`); const alertsFromThisTest = apiResponse.body.data.filter( ({ name }: { name: string }) => name === alertId diff --git a/x-pack/test/reporting_functional/reporting_and_timeout/index.ts b/x-pack/test/reporting_functional/reporting_and_timeout/index.ts index 59b2ef546bed1..17f13bf868019 100644 --- a/x-pack/test/reporting_functional/reporting_and_timeout/index.ts +++ b/x-pack/test/reporting_functional/reporting_and_timeout/index.ts @@ -19,7 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const config = getService('config'); const screenshotDir = config.get('screenshots.directory'); - describe('Reporting Functional Tests with forced timeout', function () { + // FLAKY: https://github.com/elastic/kibana/issues/135309 + describe.skip('Reporting Functional Tests with forced timeout', function () { const dashboardTitle = 'Ecom Dashboard Hidden Panel Titles'; const baselineAPng = path.resolve(__dirname, 'fixtures/baseline/warnings_capture_a.png'); const sessionPng = 'warnings_capture_session_a'; diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index 7779c604472e1..bb0bd27ce85d9 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -6,7 +6,6 @@ */ import expect from '@kbn/expect'; -import { SuperTest } from 'supertest'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES, ALL_SPACES_ID } from '../lib/spaces'; @@ -17,6 +16,8 @@ import { getRedactedNamespaces, } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { getTestDataLoader, SPACE_1, SPACE_2 } from '../../../common/lib/test_data_loader'; const { DEFAULT: { spaceId: DEFAULT_SPACE_ID }, @@ -85,7 +86,10 @@ const createRequest = ({ type, id, initialNamespaces }: BulkCreateTestCase) => ( ...(initialNamespaces && { initialNamespaces }), }); -export function bulkCreateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { +export function bulkCreateTestSuiteFactory(context: FtrProviderContext) { + const testDataLoader = getTestDataLoader(context); + const supertest = context.getService('supertestWithoutAuth'); + const expectSavedObjectForbidden = expectResponses.forbiddenTypes('bulk_create'); const expectResponseBody = ( @@ -193,16 +197,31 @@ export function bulkCreateTestSuiteFactory(esArchiver: any, supertest: SuperTest const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { - before(() => - esArchiver.load( - 'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces' - ) - ); - after(() => - esArchiver.unload( - 'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces' - ) - ); + before(async () => { + await testDataLoader.createFtrSpaces(); + await testDataLoader.createFtrSavedObjectsData([ + { + spaceName: null, + dataUrl: + 'x-pack/test/saved_object_api_integration/common/fixtures/kbn_archiver/default_space.json', + }, + { + spaceName: SPACE_1.id, + dataUrl: + 'x-pack/test/saved_object_api_integration/common/fixtures/kbn_archiver/space_1.json', + }, + { + spaceName: SPACE_2.id, + dataUrl: + 'x-pack/test/saved_object_api_integration/common/fixtures/kbn_archiver/space_2.json', + }, + ]); + }); + + after(async () => { + await testDataLoader.deleteFtrSpaces(); + await testDataLoader.deleteFtrSavedObjectsData(); + }); const attrs = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index 2d8ca1c303bd4..4176b6707b124 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -113,12 +113,9 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { return { normalTypes, crossNamespace, hiddenType, allTypes }; }; -export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - +export default function (context: FtrProviderContext) { const { addTests, createTestDefinitions, expectSavedObjectForbidden } = - bulkCreateTestSuiteFactory(esArchiver, supertest); + bulkCreateTestSuiteFactory(context); const createTests = (overwrite: boolean, spaceId: string, user: TestUser) => { const { normalTypes, crossNamespace, hiddenType, allTypes } = createTestCases( overwrite, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index 34e18b76088f1..bccc55403ba79 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -103,11 +103,8 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ]; }; -export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - const { addTests, createTestDefinitions } = bulkCreateTestSuiteFactory(esArchiver, supertest); +export default function (context: FtrProviderContext) { + const { addTests, createTestDefinitions } = bulkCreateTestSuiteFactory(context); const createTests = (overwrite: boolean, spaceId: string) => { const testCases = createTestCases(overwrite, spaceId); return createTestDefinitions(testCases, false, overwrite, { diff --git a/yarn.lock b/yarn.lock index 2babed56a8638..60a8900ecc859 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6523,11 +6523,6 @@ dependencies: "@types/jquery" "*" -"@types/fnv-plus@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@types/fnv-plus/-/fnv-plus-1.3.0.tgz#0f43f0b7e7b4b24de3a1cab69bfa009508f4c084" - integrity sha512-ijls8MsO6Q9JUSd5w1v4y2ijM6S4D/nmOyI/FwcepvrZfym0wZhLdYGFD5TJID7tga0O3I7SmtK69RzpSJ1Fcw== - "@types/fs-extra@^8.0.0": version "8.1.1" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-8.1.1.tgz#1e49f22d09aa46e19b51c0b013cb63d0d923a068" @@ -15499,11 +15494,6 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== -fnv-plus@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/fnv-plus/-/fnv-plus-1.3.1.tgz#c34cb4572565434acb08ba257e4044ce2b006d67" - integrity sha512-Gz1EvfOneuFfk4yG458dJ3TLJ7gV19q3OM/vVvvHf7eT02Hm1DleB4edsia6ahbKgAYxO9gvyQ1ioWZR+a00Yw== - focus-lock@^0.11.2: version "0.11.2" resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-0.11.2.tgz#aeef3caf1cea757797ac8afdebaec8fd9ab243ed"