From 636fcc20004082cd54da3bdadafa800b2e5e8498 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 25 Feb 2021 13:12:24 +0100 Subject: [PATCH 01/40] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20fix=20links?= =?UTF-8?q?=20in=20embeddable=20plugin=20readme=20(#92778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/embeddable/README.asciidoc | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/plugins/embeddable/README.asciidoc b/src/plugins/embeddable/README.asciidoc index daa6040eab7eb6..007b16587e9f89 100644 --- a/src/plugins/embeddable/README.asciidoc +++ b/src/plugins/embeddable/README.asciidoc @@ -22,18 +22,17 @@ There is also an example of rendering dashboard container outside of dashboard a === Docs -link:./docs/README.md[Embeddable docs, guides & caveats] +link:https://github.com/elastic/kibana/blob/master/src/plugins/embeddable/docs/README.md[Embeddable docs, guides & caveats] === API docs -==== Server API -https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md[Server Setup contract] -https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablestart.md[Server Start contract] - ===== Browser API https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablesetup.md[Browser Setup contract] https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestart.md[Browser Start contract] +==== Server API +https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/server/kibana-plugin-plugins-embeddable-server.embeddablesetup.md[Server Setup contract] + === Testing Run unit tests From 45bea2b9b6d3a08be154fd84c46ddb1b9f2e1e62 Mon Sep 17 00:00:00 2001 From: Luke Elmers Date: Thu, 25 Feb 2021 07:14:04 -0700 Subject: [PATCH 02/40] [core.savedObjects] Remove _shard_doc tiebreaker since ES now adds it automatically. (#92295) --- .../service/lib/search_dsl/search_dsl.test.ts | 3 +-- .../service/lib/search_dsl/search_dsl.ts | 2 +- .../lib/search_dsl/sorting_params.test.ts | 26 ------------------- .../service/lib/search_dsl/sorting_params.ts | 12 +-------- 4 files changed, 3 insertions(+), 40 deletions(-) diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index fc26c837d5e52f..267d671361184e 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -96,8 +96,7 @@ describe('getSearchDsl', () => { mappings, opts.type, opts.sortField, - opts.sortOrder, - opts.pit + opts.sortOrder ); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index cae5e43897bcfe..9820544f02bd12 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -78,7 +78,7 @@ export function getSearchDsl( hasReferenceOperator, kueryNode, }), - ...getSortingParams(mappings, type, sortField, sortOrder, pit), + ...getSortingParams(mappings, type, sortField, sortOrder), ...(pit ? getPitParams(pit) : {}), ...(searchAfter ? { search_after: searchAfter } : {}), }; diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts index 73c7065705fc54..1376f0d50a9da6 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.test.ts @@ -79,11 +79,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect(getSortingParams(MAPPINGS, 'saved', 'title', undefined, { id: 'abc' }).sort).toEqual( - expect.arrayContaining([{ _shard_doc: 'asc' }]) - ); - }); }); describe('sortField is simple root property with multiple types', () => { it('returns correct params', () => { @@ -98,11 +93,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, ['saved', 'pending'], 'type', undefined, { id: 'abc' }).sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is simple non-root property with multiple types', () => { it('returns correct params', () => { @@ -124,11 +114,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, 'saved', 'title.raw', undefined, { id: 'abc' }).sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is multi-field with single type as array', () => { it('returns correct params', () => { @@ -143,11 +128,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, ['saved'], 'title.raw', undefined, { id: 'abc' }).sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is root multi-field with multiple types', () => { it('returns correct params', () => { @@ -162,12 +142,6 @@ describe('searchDsl/getSortParams', () => { ], }); }); - it('appends tiebreaker when PIT is provided', () => { - expect( - getSortingParams(MAPPINGS, ['saved', 'pending'], 'type.raw', undefined, { id: 'abc' }) - .sort - ).toEqual(expect.arrayContaining([{ _shard_doc: 'asc' }])); - }); }); describe('sortField is not-root multi-field with multiple types', () => { it('returns correct params', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts index abef9bfa0a3006..e3bfba6a80f59d 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/sorting_params.ts @@ -8,12 +8,6 @@ import Boom from '@hapi/boom'; import { getProperty, IndexMapping } from '../../../mappings'; -import { SavedObjectsPitParams } from '../../../types'; - -// TODO: The plan is for ES to automatically add this tiebreaker when -// using PIT. We should remove this logic once that is resolved. -// https://github.com/elastic/elasticsearch/issues/56828 -const ES_PROVIDED_TIEBREAKER = { _shard_doc: 'asc' }; const TOP_LEVEL_FIELDS = ['_id', '_score']; @@ -21,8 +15,7 @@ export function getSortingParams( mappings: IndexMapping, type: string | string[], sortField?: string, - sortOrder?: string, - pit?: SavedObjectsPitParams + sortOrder?: string ) { if (!sortField) { return {}; @@ -38,7 +31,6 @@ export function getSortingParams( order: sortOrder, }, }, - ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -59,7 +51,6 @@ export function getSortingParams( unmapped_type: rootField.type, }, }, - ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } @@ -84,7 +75,6 @@ export function getSortingParams( unmapped_type: field.type, }, }, - ...(pit ? [ES_PROVIDED_TIEBREAKER] : []), ], }; } From f0838e6ee55fb0e2f3097f93d6f60b9fbb1e84a2 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Thu, 25 Feb 2021 06:35:44 -0800 Subject: [PATCH 03/40] [Security Solution][Detections] Pull gap detection logic out in preparation for sharing between rule types (#91966) * Pull gap detection logic out in preparation for sharing between rule types * Remove comments and unused import * Remove unncessary function, cleanup, comment * Update comment * Address PR comments * remove unneeded mocks * Undo change to parseInterval * Remove another unneeded mock Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../signals/search_after_bulk_create.test.ts | 75 ++-- .../signals/search_after_bulk_create.ts | 14 +- .../signals/signal_rule_alert_type.test.ts | 40 +-- .../signals/signal_rule_alert_type.ts | 51 ++- .../threat_mapping/create_threat_signal.ts | 6 +- .../threat_mapping/create_threat_signals.ts | 6 +- .../signals/threat_mapping/types.ts | 9 +- .../lib/detection_engine/signals/types.ts | 13 +- .../detection_engine/signals/utils.test.ts | 209 +++++------- .../lib/detection_engine/signals/utils.ts | 323 ++++++------------ 10 files changed, 279 insertions(+), 467 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 55d128225c5553..50823ebd85d05e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -5,7 +5,6 @@ * 2.0. */ -import moment from 'moment'; import { sampleRuleAlertParams, sampleEmptyDocSearchResults, @@ -23,9 +22,10 @@ import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mock import uuid from 'uuid'; import { listMock } from '../../../../../lists/server/mocks'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { BulkResponse } from './types'; +import { BulkResponse, RuleRangeTuple } from './types'; import { SearchListItemArraySchema } from '../../../../../lists/common/schemas'; import { getSearchListItemResponseMock } from '../../../../../lists/common/schemas/response/search_list_item_schema.mock'; +import { getRuleRangeTuples } from './utils'; const buildRuleMessage = buildRuleMessageFactory({ id: 'fake id', @@ -39,16 +39,26 @@ describe('searchAfterAndBulkCreate', () => { let inputIndexPattern: string[] = []; let listClient = listMock.getListClient(); const someGuids = Array.from({ length: 13 }).map(() => uuid.v4()); + const sampleParams = sampleRuleAlertParams(30); + let tuples: RuleRangeTuple[]; beforeEach(() => { jest.clearAllMocks(); listClient = listMock.getListClient(); listClient.searchListItemByValues = jest.fn().mockResolvedValue([]); inputIndexPattern = ['auditbeat-*']; mockService = alertsMock.createAlertServices(); + ({ tuples } = getRuleRangeTuples({ + logger: mockLogger, + previousStartedAt: new Date(), + from: sampleParams.from, + to: sampleParams.to, + interval: '5m', + maxSignals: sampleParams.maxSignals, + buildRuleMessage, + })); }); test('should return success with number of searches less than max signals', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -112,11 +122,9 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -147,7 +155,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success with number of searches less than max signals with gap', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -201,8 +208,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: moment.duration(2, 'm'), - previousStartedAt: moment().subtract(10, 'm').toDate(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -233,7 +239,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success when no search results are in the allowlist', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -278,8 +283,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -305,7 +309,7 @@ describe('searchAfterAndBulkCreate', () => { }); expect(success).toEqual(true); expect(mockService.callCluster).toHaveBeenCalledTimes(3); - expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist + expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -316,7 +320,6 @@ describe('searchAfterAndBulkCreate', () => { { ...getSearchListItemResponseMock(), value: ['3.3.3.3'] }, ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce( repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ @@ -342,8 +345,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -382,7 +384,6 @@ describe('searchAfterAndBulkCreate', () => { ]; listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster.mockResolvedValueOnce( repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3), [ '1.1.1.1', @@ -406,8 +407,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -437,13 +437,12 @@ describe('searchAfterAndBulkCreate', () => { expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); // I don't like testing log statements since logs change but this is the best // way I can think of to ensure this section is getting hit with this test case. - expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[8][0]).toContain( + expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[7][0]).toContain( 'ran out of sort ids to sort on name: "fake name" id: "fake id" rule id: "fake rule id" signals index: "fakeindex"' ); }); test('should return success when no sortId present but search results are in the allowlist', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -487,8 +486,7 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [exceptionItem], services: mockService, @@ -514,17 +512,16 @@ describe('searchAfterAndBulkCreate', () => { }); expect(success).toEqual(true); expect(mockService.callCluster).toHaveBeenCalledTimes(2); - expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist + expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); // I don't like testing log statements since logs change but this is the best // way I can think of to ensure this section is getting hit with this test case. - expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[15][0]).toContain( + expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[14][0]).toContain( 'ran out of sort ids to sort on name: "fake name" id: "fake id" rule id: "fake rule id" signals index: "fakeindex"' ); }); test('should return success when no exceptions list provided', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -565,8 +562,7 @@ describe('searchAfterAndBulkCreate', () => { ); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [], services: mockService, @@ -592,7 +588,7 @@ describe('searchAfterAndBulkCreate', () => { }); expect(success).toEqual(true); expect(mockService.callCluster).toHaveBeenCalledTimes(3); - expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist + expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -609,15 +605,13 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - const sampleParams = sampleRuleAlertParams(10); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockRejectedValue(new Error('bulk failed')); // Added this recently const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], - gap: null, - previousStartedAt: new Date(), + tuples, ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -659,7 +653,6 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster.mockResolvedValueOnce(sampleEmptyDocSearchResults()); listClient.searchListItemByValues = jest.fn(({ value }) => Promise.resolve( @@ -672,8 +665,7 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], - gap: null, - previousStartedAt: new Date(), + tuples, ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -702,7 +694,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('if returns false when singleSearchAfter throws an exception', async () => { - const sampleParams = sampleRuleAlertParams(10); mockService.callCluster .mockResolvedValueOnce({ took: 100, @@ -741,8 +732,7 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], - gap: null, - previousStartedAt: new Date(), + tuples, ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -771,7 +761,6 @@ describe('searchAfterAndBulkCreate', () => { }); test('it returns error array when singleSearchAfter returns errors', async () => { - const sampleParams = sampleRuleAlertParams(30); const bulkItem: BulkResponse = { took: 100, errors: true, @@ -832,7 +821,6 @@ describe('searchAfterAndBulkCreate', () => { ], }) .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); - const { success, createdSignalsCount, @@ -840,8 +828,7 @@ describe('searchAfterAndBulkCreate', () => { errors, } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, - gap: null, - previousStartedAt: new Date(), + tuples, listClient, exceptionsList: [], services: mockService, @@ -873,7 +860,6 @@ describe('searchAfterAndBulkCreate', () => { }); it('invokes the enrichment callback with signal search results', async () => { - const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockResolvedValueOnce({ @@ -917,8 +903,7 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ enrichment: mockEnrichment, ruleParams: sampleParams, - gap: moment.duration(2, 'm'), - previousStartedAt: moment().subtract(10, 'm').toDate(), + tuples, listClient, exceptionsList: [], services: mockService, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index 061aa4bba5a412..1dd3a2d2173a82 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -17,7 +17,6 @@ import { createSearchResultReturnType, createSearchAfterReturnTypeFromResponse, createTotalHitsFromSearchResult, - getSignalTimeTuples, mergeReturns, mergeSearchResults, } from './utils'; @@ -25,8 +24,7 @@ import { SearchAfterAndBulkCreateParams, SearchAfterAndBulkCreateReturnType } fr // search_after through documents and re-index using bulk endpoint. export const searchAfterAndBulkCreate = async ({ - gap, - previousStartedAt, + tuples: totalToFromTuples, ruleParams, exceptionsList, services, @@ -64,16 +62,6 @@ export const searchAfterAndBulkCreate = async ({ // to ensure we don't exceed maxSignals let signalsCreatedCount = 0; - const totalToFromTuples = getSignalTimeTuples({ - logger, - ruleParamsFrom: ruleParams.from, - ruleParamsTo: ruleParams.to, - ruleParamsMaxSignals: ruleParams.maxSignals, - gap, - previousStartedAt, - interval, - buildRuleMessage, - }); const tuplesToBeLogged = [...totalToFromTuples]; logger.debug(buildRuleMessage(`totalToFromTuples: ${totalToFromTuples.length}`)); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index a79961eb716fdd..cadc6d0c5b7c01 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -11,14 +11,7 @@ import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { signalRulesAlertType } from './signal_rule_alert_type'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import { ruleStatusServiceFactory } from './rule_status_service'; -import { - getGapBetweenRuns, - getGapMaxCatchupRatio, - getListsClient, - getExceptions, - sortExceptionItems, - checkPrivileges, -} from './utils'; +import { getListsClient, getExceptions, sortExceptionItems, checkPrivileges } from './utils'; import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; import { RuleExecutorOptions, SearchAfterAndBulkCreateReturnType } from './types'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; @@ -40,8 +33,6 @@ jest.mock('./utils', () => { const original = jest.requireActual('./utils'); return { ...original, - getGapBetweenRuns: jest.fn(), - getGapMaxCatchupRatio: jest.fn(), getListsClient: jest.fn(), getExceptions: jest.fn(), sortExceptionItems: jest.fn(), @@ -113,7 +104,6 @@ describe('rules_notification_alert_type', () => { warning: jest.fn(), }; (ruleStatusServiceFactory as jest.Mock).mockReturnValue(ruleStatusService); - (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(0)); (getListsClient as jest.Mock).mockReturnValue({ listClient: getListClientMock(), exceptionsClient: getExceptionListClientMock(), @@ -124,7 +114,6 @@ describe('rules_notification_alert_type', () => { exceptionsWithValueLists: [], }); (searchAfterAndBulkCreate as jest.Mock).mockClear(); - (getGapMaxCatchupRatio as jest.Mock).mockClear(); (searchAfterAndBulkCreate as jest.Mock).mockResolvedValue({ success: true, searchAfterTimes: [], @@ -187,23 +176,12 @@ describe('rules_notification_alert_type', () => { describe('executor', () => { it('should warn about the gap between runs if gap is very large', async () => { - (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(100, 'm')); - (getGapMaxCatchupRatio as jest.Mock).mockReturnValue({ - maxCatchup: 4, - ratio: 20, - gapDiffInUnits: 95, - }); + payload.previousStartedAt = moment().subtract(100, 'm').toDate(); await alert.executor(payload); expect(logger.warn).toHaveBeenCalled(); - expect(logger.warn.mock.calls[0][0]).toContain( - '2 hours (6000000ms) has passed since last rule execution, and signals may have been missed.' - ); expect(ruleStatusService.error).toHaveBeenCalled(); - expect(ruleStatusService.error.mock.calls[0][0]).toContain( - '2 hours (6000000ms) has passed since last rule execution, and signals may have been missed.' - ); expect(ruleStatusService.error.mock.calls[0][1]).toEqual({ - gap: '2 hours', + gap: 'an hour', }); }); @@ -257,12 +235,7 @@ describe('rules_notification_alert_type', () => { }); it('should NOT warn about the gap between runs if gap small', async () => { - (getGapBetweenRuns as jest.Mock).mockReturnValue(moment.duration(1, 'm')); - (getGapMaxCatchupRatio as jest.Mock).mockReturnValue({ - maxCatchup: 1, - ratio: 1, - gapDiffInUnits: 1, - }); + payload.previousStartedAt = moment().subtract(10, 'm').toDate(); await alert.executor(payload); expect(logger.warn).toHaveBeenCalledTimes(0); expect(ruleStatusService.error).toHaveBeenCalledTimes(0); @@ -450,6 +423,7 @@ describe('rules_notification_alert_type', () => { const ruleAlert = getMlResult(); ruleAlert.params.anomalyThreshold = undefined; payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; await alert.executor(payload); expect(logger.error).toHaveBeenCalled(); expect(logger.error.mock.calls[0][0]).toContain( @@ -460,6 +434,7 @@ describe('rules_notification_alert_type', () => { it('should throw an error if Machine learning job summary was null', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([]); await alert.executor(payload); expect(logger.warn).toHaveBeenCalled(); @@ -473,6 +448,7 @@ describe('rules_notification_alert_type', () => { it('should log an error if Machine learning job was not started', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([ { id: 'some_job_id', @@ -518,6 +494,7 @@ describe('rules_notification_alert_type', () => { it('should call ruleStatusService.success if signals were created', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([ { id: 'some_job_id', @@ -544,6 +521,7 @@ describe('rules_notification_alert_type', () => { it('should not call checkPrivileges if ML rule', async () => { const ruleAlert = getMlResult(); payload = getPayload(ruleAlert, alertServices) as jest.Mocked; + payload.previousStartedAt = null; jobsSummaryMock.mockResolvedValue([ { id: 'some_job_id', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 98c9dd41d179c4..2025ba512cb653 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -37,11 +37,8 @@ import { WrappedSignalHit, } from './types'; import { - getGapBetweenRuns, getListsClient, getExceptions, - getGapMaxCatchupRatio, - MAX_RULE_GAP_RATIO, wrapSignal, createErrorsFromShard, createSearchAfterReturnType, @@ -50,6 +47,7 @@ import { checkPrivileges, hasTimestampFields, hasReadIndexPrivileges, + getRuleRangeTuples, } from './utils'; import { signalParamsSchema } from './signal_params_schema'; import { siemRuleActionGroups } from './siem_rule_action_groups'; @@ -230,29 +228,24 @@ export const signalRulesAlertType = ({ } catch (exc) { logger.error(buildRuleMessage(`Check privileges failed to execute ${exc}`)); } - - const gap = getGapBetweenRuns({ previousStartedAt, interval, from, to }); - if (gap != null && gap.asMilliseconds() > 0) { - const fromUnit = from[from.length - 1]; - const { ratio } = getGapMaxCatchupRatio({ - logger, - buildRuleMessage, - previousStartedAt, - ruleParamsFrom: from, - interval, - unit: fromUnit, - }); - if (ratio && ratio >= MAX_RULE_GAP_RATIO) { - const gapString = gap.humanize(); - const gapMessage = buildRuleMessage( - `${gapString} (${gap.asMilliseconds()}ms) has passed since last rule execution, and signals may have been missed.`, - 'Consider increasing your look behind time or adding more Kibana instances.' - ); - logger.warn(gapMessage); - - hasError = true; - await ruleStatusService.error(gapMessage, { gap: gapString }); - } + const { tuples, remainingGap } = getRuleRangeTuples({ + logger, + previousStartedAt, + from, + to, + interval, + maxSignals, + buildRuleMessage, + }); + if (remainingGap.asMilliseconds() > 0) { + const gapString = remainingGap.humanize(); + const gapMessage = buildRuleMessage( + `${gapString} (${remainingGap.asMilliseconds()}ms) were not queried between this rule execution and the last execution, so signals may have been missed.`, + 'Consider increasing your look behind time or adding more Kibana instances.' + ); + logger.warn(gapMessage); + hasError = true; + await ruleStatusService.error(gapMessage, { gap: gapString }); } try { const { listClient, exceptionsClient } = getListsClient({ @@ -479,6 +472,7 @@ export const signalRulesAlertType = ({ } const inputIndex = await getInputIndex(services, version, index); result = await createThreatSignals({ + tuples, threatMapping, query, inputIndex, @@ -489,8 +483,6 @@ export const signalRulesAlertType = ({ savedId, services, exceptionItems: exceptionItems ?? [], - gap, - previousStartedAt, listClient, logger, eventsTelemetry, @@ -531,8 +523,7 @@ export const signalRulesAlertType = ({ }); result = await searchAfterAndBulkCreate({ - gap, - previousStartedAt, + tuples, listClient, exceptionsList: exceptionItems ?? [], ruleParams: params, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index ba428bc0771250..d9c72f7f95679e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -13,6 +13,7 @@ import { CreateThreatSignalOptions } from './types'; import { SearchAfterAndBulkCreateReturnType } from '../types'; export const createThreatSignal = async ({ + tuples, threatMapping, threatEnrichment, query, @@ -23,8 +24,6 @@ export const createThreatSignal = async ({ savedId, services, exceptionItems, - gap, - previousStartedAt, listClient, logger, eventsTelemetry, @@ -80,8 +79,7 @@ export const createThreatSignal = async ({ ); const result = await searchAfterAndBulkCreate({ - gap, - previousStartedAt, + tuples, listClient, exceptionsList: exceptionItems, ruleParams: params, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index e45aea29c423f0..854c2b8f3fdc18 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -15,6 +15,7 @@ import { combineConcurrentResults } from './utils'; import { buildThreatEnrichment } from './build_threat_enrichment'; export const createThreatSignals = async ({ + tuples, threatMapping, query, inputIndex, @@ -24,8 +25,6 @@ export const createThreatSignals = async ({ savedId, services, exceptionItems, - gap, - previousStartedAt, listClient, logger, eventsTelemetry, @@ -111,6 +110,7 @@ export const createThreatSignals = async ({ const concurrentSearchesPerformed = chunks.map>( (slicedChunk) => createThreatSignal({ + tuples, threatEnrichment, threatMapping, query, @@ -121,8 +121,6 @@ export const createThreatSignals = async ({ savedId, services, exceptionItems, - gap, - previousStartedAt, listClient, logger, eventsTelemetry, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index a022cbbdd40428..1c35a5af09b38b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -6,7 +6,6 @@ */ import { SearchResponse } from 'elasticsearch'; -import { Duration } from 'moment'; import { ListClient } from '../../../../../../lists/server'; import { @@ -34,11 +33,12 @@ import { ILegacyScopedClusterClient, Logger } from '../../../../../../../../src/ import { RuleAlertAction } from '../../../../../common/detection_engine/types'; import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; -import { SearchAfterAndBulkCreateReturnType, SignalsEnrichment } from '../types'; +import { RuleRangeTuple, SearchAfterAndBulkCreateReturnType, SignalsEnrichment } from '../types'; export type SortOrderOrUndefined = 'asc' | 'desc' | undefined; export interface CreateThreatSignalsOptions { + tuples: RuleRangeTuple[]; threatMapping: ThreatMapping; query: string; inputIndex: string[]; @@ -48,8 +48,6 @@ export interface CreateThreatSignalsOptions { savedId: string | undefined; services: AlertServices; exceptionItems: ExceptionListItemSchema[]; - gap: Duration | null; - previousStartedAt: Date | null; listClient: ListClient; logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; @@ -79,6 +77,7 @@ export interface CreateThreatSignalsOptions { } export interface CreateThreatSignalOptions { + tuples: RuleRangeTuple[]; threatMapping: ThreatMapping; threatEnrichment: SignalsEnrichment; query: string; @@ -89,8 +88,6 @@ export interface CreateThreatSignalOptions { savedId: string | undefined; services: AlertServices; exceptionItems: ExceptionListItemSchema[]; - gap: Duration | null; - previousStartedAt: Date | null; listClient: ListClient; logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index e5ca1f6a60456a..f759da31566e20 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -73,6 +73,12 @@ export interface ThresholdSignalHistory { [hash: string]: ThresholdSignalHistoryRecord; } +export interface RuleRangeTuple { + to: moment.Moment; + from: moment.Moment; + maxSignals: number; +} + export interface SignalSource { [key: string]: SearchTypes; // TODO: SignalSource is being used as the type for documents matching detection engine queries, but they may not @@ -251,8 +257,11 @@ export interface QueryFilter { export type SignalsEnrichment = (signals: SignalSearchResponse) => Promise; export interface SearchAfterAndBulkCreateParams { - gap: moment.Duration | null; - previousStartedAt: Date | null | undefined; + tuples: Array<{ + to: moment.Moment; + from: moment.Moment; + maxSignals: number; + }>; ruleParams: RuleTypeParams; services: AlertServices; listClient: ListClient; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index 7888bb6deaab79..0a581816ee82f1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -25,10 +25,10 @@ import { parseInterval, getDriftTolerance, getGapBetweenRuns, - getGapMaxCatchupRatio, + getNumCatchupIntervals, errorAggregator, getListsClient, - getSignalTimeTuples, + getRuleRangeTuples, getExceptions, hasTimestampFields, wrapBuildingBlocks, @@ -109,7 +109,7 @@ describe('utils', () => { expect(duration?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); }); - test('it returns null given an invalid duration', () => { + test('it throws given an invalid duration', () => { const duration = parseInterval('junk'); expect(duration).toBeNull(); }); @@ -148,7 +148,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-6m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -158,7 +158,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-5m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift?.asMilliseconds()).toEqual(0); }); @@ -167,7 +167,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-10m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); @@ -177,7 +177,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-10m', to: 'now', - interval: moment.duration(0, 'milliseconds'), + intervalDuration: moment.duration(0, 'milliseconds'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(10, 'minutes').asMilliseconds()); @@ -187,7 +187,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'invalid', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -197,7 +197,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: '10m', to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -207,7 +207,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-10m', to: 'now-1m', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(4, 'minutes').asMilliseconds()); @@ -217,7 +217,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: moment().subtract(10, 'minutes').toISOString(), to: 'now', - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); @@ -227,7 +227,7 @@ describe('utils', () => { const drift = getDriftTolerance({ from: 'now-6m', to: moment().toISOString(), - interval: moment.duration(5, 'minutes'), + intervalDuration: moment.duration(5, 'minutes'), }); expect(drift).not.toBeNull(); expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); @@ -238,7 +238,7 @@ describe('utils', () => { test('it returns a gap of 0 when "from" and interval match each other and the previous started was from the previous interval time', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-5m', to: 'now', now: nowDate.clone(), @@ -250,7 +250,7 @@ describe('utils', () => { test('it returns a negative gap of 1 minute when "from" overlaps to by 1 minute and the previousStartedAt was 5 minutes ago', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -262,7 +262,7 @@ describe('utils', () => { test('it returns a negative gap of 5 minutes when "from" overlaps to by 1 minute and the previousStartedAt was 5 minutes ago', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-10m', to: 'now', now: nowDate.clone(), @@ -274,7 +274,7 @@ describe('utils', () => { test('it returns a negative gap of 1 minute when "from" overlaps to by 1 minute and the previousStartedAt was 10 minutes ago and so was the interval', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(10, 'minutes').toDate(), - interval: '10m', + intervalDuration: moment.duration(10, 'minutes'), from: 'now-11m', to: 'now', now: nowDate.clone(), @@ -286,7 +286,7 @@ describe('utils', () => { test('it returns a gap of only -30 seconds when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is 30 seconds more', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(5, 'minutes').subtract(30, 'seconds').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -298,7 +298,7 @@ describe('utils', () => { test('it returns an exact 0 gap when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is one minute late', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(6, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -310,7 +310,7 @@ describe('utils', () => { test('it returns a gap of 30 seconds when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is one minute and 30 seconds late', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(6, 'minutes').subtract(30, 'seconds').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -322,7 +322,7 @@ describe('utils', () => { test('it returns a gap of 1 minute when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is two minutes late', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(7, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'now', now: nowDate.clone(), @@ -331,32 +331,21 @@ describe('utils', () => { expect(gap?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); }); - test('it returns null if given a previousStartedAt of null', () => { + test('it returns 0 if given a previousStartedAt of null', () => { const gap = getGapBetweenRuns({ previousStartedAt: null, - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-5m', to: 'now', now: nowDate.clone(), }); - expect(gap).toBeNull(); - }); - - test('it returns null if the interval is an invalid string such as "invalid"', () => { - const gap = getGapBetweenRuns({ - previousStartedAt: nowDate.clone().toDate(), - interval: 'invalid', // if not set to "x" where x is an interval such as 6m - from: 'now-5m', - to: 'now', - now: nowDate.clone(), - }); - expect(gap).toBeNull(); + expect(gap.asMilliseconds()).toEqual(0); }); test('it returns the expected result when "from" is an invalid string such as "invalid"', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(7, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'invalid', to: 'now', now: nowDate.clone(), @@ -368,7 +357,7 @@ describe('utils', () => { test('it returns the expected result when "to" is an invalid string such as "invalid"', () => { const gap = getGapBetweenRuns({ previousStartedAt: nowDate.clone().subtract(7, 'minutes').toDate(), - interval: '5m', + intervalDuration: moment.duration(5, 'minutes'), from: 'now-6m', to: 'invalid', now: nowDate.clone(), @@ -609,134 +598,116 @@ describe('utils', () => { }); }); - describe('getSignalTimeTuples', () => { + describe('getRuleRangeTuples', () => { test('should return a single tuple if no gap', () => { - const someTuples = getSignalTimeTuples({ + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: null, previousStartedAt: moment().subtract(30, 's').toDate(), interval: '30s', - ruleParamsFrom: 'now-30s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-30s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - const someTuple = someTuples[0]; + const someTuple = tuples[0]; expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(30); + expect(tuples.length).toEqual(1); + expect(remainingGap.asMilliseconds()).toEqual(0); + }); + + test('should return a single tuple if malformed interval prevents gap calculation', () => { + const { tuples, remainingGap } = getRuleRangeTuples({ + logger: mockLogger, + previousStartedAt: moment().subtract(30, 's').toDate(), + interval: 'invalid', + from: 'now-30s', + to: 'now', + maxSignals: 20, + buildRuleMessage, + }); + const someTuple = tuples[0]; + expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(30); + expect(tuples.length).toEqual(1); + expect(remainingGap.asMilliseconds()).toEqual(0); }); test('should return two tuples if gap and previouslyStartedAt', () => { - const someTuples = getSignalTimeTuples({ + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: moment.duration(10, 's'), previousStartedAt: moment().subtract(65, 's').toDate(), interval: '50s', - ruleParamsFrom: 'now-55s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-55s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - const someTuple = someTuples[1]; - expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(10); + const someTuple = tuples[1]; + expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(55); + expect(remainingGap.asMilliseconds()).toEqual(0); }); test('should return five tuples when give long gap', () => { - const someTuples = getSignalTimeTuples({ + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: moment.duration(65, 's'), // 64 is 5 times the interval + lookback, which will trigger max lookback - previousStartedAt: moment().subtract(65, 's').toDate(), + previousStartedAt: moment().subtract(65, 's').toDate(), // 64 is 5 times the interval + lookback, which will trigger max lookback interval: '10s', - ruleParamsFrom: 'now-13s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-13s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - expect(someTuples.length).toEqual(5); - someTuples.forEach((item, index) => { + expect(tuples.length).toEqual(5); + tuples.forEach((item, index) => { if (index === 0) { return; } - expect(moment(item.to).diff(moment(item.from), 's')).toEqual(10); + expect(moment(item.to).diff(moment(item.from), 's')).toEqual(13); + expect(item.to.diff(tuples[index - 1].to, 's')).toEqual(-10); + expect(item.from.diff(tuples[index - 1].from, 's')).toEqual(-10); }); + expect(remainingGap.asMilliseconds()).toEqual(12000); }); - // this tests if calculatedFrom in utils.ts:320 parses an int and not a float - // if we don't parse as an int, then dateMath.parse will fail - // as it doesn't support parsing `now-67.549`, it only supports ints like `now-67`. - test('should return five tuples when given a gap with a decimal to ensure no parsing errors', () => { - const someTuples = getSignalTimeTuples({ + test('should return a single tuple when give a negative gap (rule ran sooner than expected)', () => { + const { tuples, remainingGap } = getRuleRangeTuples({ logger: mockLogger, - gap: moment.duration(67549, 'ms'), // 64 is 5 times the interval + lookback, which will trigger max lookback - previousStartedAt: moment().subtract(67549, 'ms').toDate(), - interval: '10s', - ruleParamsFrom: 'now-13s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, - buildRuleMessage, - }); - expect(someTuples.length).toEqual(5); - }); - - test('should return single tuples when give a negative gap (rule ran sooner than expected)', () => { - const someTuples = getSignalTimeTuples({ - logger: mockLogger, - gap: moment.duration(-15, 's'), // 64 is 5 times the interval + lookback, which will trigger max lookback previousStartedAt: moment().subtract(-15, 's').toDate(), interval: '10s', - ruleParamsFrom: 'now-13s', - ruleParamsTo: 'now', - ruleParamsMaxSignals: 20, + from: 'now-13s', + to: 'now', + maxSignals: 20, buildRuleMessage, }); - expect(someTuples.length).toEqual(1); - const someTuple = someTuples[0]; + expect(tuples.length).toEqual(1); + const someTuple = tuples[0]; expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(13); + expect(remainingGap.asMilliseconds()).toEqual(0); }); }); describe('getMaxCatchupRatio', () => { - test('should return null if rule has never run before', () => { - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger: mockLogger, - previousStartedAt: null, - interval: '30s', - ruleParamsFrom: 'now-30s', - buildRuleMessage, - unit: 's', + test('should return 0 if gap is 0', () => { + const catchup = getNumCatchupIntervals({ + gap: moment.duration(0), + intervalDuration: moment.duration(11000), }); - expect(maxCatchup).toBeNull(); - expect(ratio).toBeNull(); - expect(gapDiffInUnits).toBeNull(); + expect(catchup).toEqual(0); }); - test('should should have non-null values when gap is present', () => { - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger: mockLogger, - previousStartedAt: moment().subtract(65, 's').toDate(), - interval: '50s', - ruleParamsFrom: 'now-55s', - buildRuleMessage, - unit: 's', + test('should return 1 if gap is in (0, intervalDuration]', () => { + const catchup = getNumCatchupIntervals({ + gap: moment.duration(10000), + intervalDuration: moment.duration(10000), }); - expect(maxCatchup).toEqual(0.2); - expect(ratio).toEqual(0.2); - expect(gapDiffInUnits).toEqual(10); + expect(catchup).toEqual(1); }); - // when a rule runs sooner than expected we don't - // consider that a gap as that is a very rare circumstance - test('should return null when given a negative gap (rule ran sooner than expected)', () => { - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger: mockLogger, - previousStartedAt: moment().subtract(-15, 's').toDate(), - interval: '10s', - ruleParamsFrom: 'now-13s', - buildRuleMessage, - unit: 's', + test('should round up return value', () => { + const catchup = getNumCatchupIntervals({ + gap: moment.duration(15000), + intervalDuration: moment.duration(11000), }); - expect(maxCatchup).toBeNull(); - expect(ratio).toBeNull(); - expect(gapDiffInUnits).toBeNull(); + expect(catchup).toEqual(2); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 58bf22be97bf87..2b306cd2a8d9d0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -29,12 +29,12 @@ import { ListArray } from '../../../../common/detection_engine/schemas/types/lis import { BulkResponse, BulkResponseErrorAggregation, - isValidUnit, SignalHit, SearchAfterAndBulkCreateReturnType, SignalSearchResponse, Signal, WrappedSignalHit, + RuleRangeTuple, } from './types'; import { BuildRuleMessage } from './rule_messages'; import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; @@ -163,82 +163,21 @@ export const checkPrivileges = async ( }, }); -export const getGapMaxCatchupRatio = ({ - logger, - previousStartedAt, - unit, - buildRuleMessage, - ruleParamsFrom, - interval, +export const getNumCatchupIntervals = ({ + gap, + intervalDuration, }: { - logger: Logger; - ruleParamsFrom: string; - previousStartedAt: Date | null | undefined; - interval: string; - buildRuleMessage: BuildRuleMessage; - unit: string; -}): { - maxCatchup: number | null; - ratio: number | null; - gapDiffInUnits: number | null; -} => { - if (previousStartedAt == null) { - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; - } - if (!isValidUnit(unit)) { - logger.error(buildRuleMessage(`unit: ${unit} failed isValidUnit check`)); - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; - } - /* - we need the total duration from now until the last time the rule ran. - the next few lines can be summed up as calculating - "how many second | minutes | hours have passed since the last time this ran?" - */ - const nowToGapDiff = moment.duration(moment().diff(previousStartedAt)); - // rule ran early, no gap - if (shorthandMap[unit].asFn(nowToGapDiff) < 0) { - // rule ran early, no gap - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; - } - const calculatedFrom = `now-${ - parseInt(shorthandMap[unit].asFn(nowToGapDiff).toString(), 10) + unit - }`; - logger.debug(buildRuleMessage(`calculatedFrom: ${calculatedFrom}`)); - - const intervalMoment = moment.duration(parseInt(interval, 10), unit); - logger.debug(buildRuleMessage(`intervalMoment: ${shorthandMap[unit].asFn(intervalMoment)}`)); - const calculatedFromAsMoment = dateMath.parse(calculatedFrom); - const dateMathRuleParamsFrom = dateMath.parse(ruleParamsFrom); - if (dateMathRuleParamsFrom != null && intervalMoment != null) { - const momentUnit = shorthandMap[unit].momentString as moment.DurationInputArg2; - const gapDiffInUnits = dateMathRuleParamsFrom.diff(calculatedFromAsMoment, momentUnit); - - const ratio = gapDiffInUnits / shorthandMap[unit].asFn(intervalMoment); - - // maxCatchup is to ensure we are not trying to catch up too far back. - // This allows for a maximum of 4 consecutive rule execution misses - // to be included in the number of signals generated. - const maxCatchup = ratio < MAX_RULE_GAP_RATIO ? ratio : MAX_RULE_GAP_RATIO; - return { maxCatchup, ratio, gapDiffInUnits }; + gap: moment.Duration; + intervalDuration: moment.Duration; +}): number => { + if (gap.asMilliseconds() <= 0 || intervalDuration.asMilliseconds() <= 0) { + return 0; } - logger.error(buildRuleMessage('failed to parse calculatedFrom and intervalMoment')); - return { - maxCatchup: null, - ratio: null, - gapDiffInUnits: null, - }; + const ratio = Math.ceil(gap.asMilliseconds() / intervalDuration.asMilliseconds()); + // maxCatchup is to ensure we are not trying to catch up too far back. + // This allows for a maximum of 4 consecutive rule execution misses + // to be included in the number of signals generated. + return ratio < MAX_RULE_GAP_RATIO ? ratio : MAX_RULE_GAP_RATIO; }; export const getListsClient = ({ @@ -396,50 +335,40 @@ export const parseInterval = (intervalString: string): moment.Duration | null => export const getDriftTolerance = ({ from, to, - interval, + intervalDuration, now = moment(), }: { from: string; to: string; - interval: moment.Duration; + intervalDuration: moment.Duration; now?: moment.Moment; -}): moment.Duration | null => { +}): moment.Duration => { const toDate = parseScheduleDates(to) ?? now; const fromDate = parseScheduleDates(from) ?? dateMath.parse('now-6m'); const timeSegment = toDate.diff(fromDate); const duration = moment.duration(timeSegment); - if (duration !== null) { - return duration.subtract(interval); - } else { - return null; - } + return duration.subtract(intervalDuration); }; export const getGapBetweenRuns = ({ previousStartedAt, - interval, + intervalDuration, from, to, now = moment(), }: { previousStartedAt: Date | undefined | null; - interval: string; + intervalDuration: moment.Duration; from: string; to: string; now?: moment.Moment; -}): moment.Duration | null => { +}): moment.Duration => { if (previousStartedAt == null) { - return null; - } - const intervalDuration = parseInterval(interval); - if (intervalDuration == null) { - return null; - } - const driftTolerance = getDriftTolerance({ from, to, interval: intervalDuration }); - if (driftTolerance == null) { - return null; + return moment.duration(0); } + const driftTolerance = getDriftTolerance({ from, to, intervalDuration }); + const diff = moment.duration(now.diff(previousStartedAt)); const drift = diff.subtract(intervalDuration); return drift.subtract(driftTolerance); @@ -489,135 +418,103 @@ export const errorAggregator = ( }, Object.create(null)); }; -/** - * Determines the number of time intervals to search if gap is present - * along with new maxSignals per time interval. - * @param logger Logger - * @param ruleParamsFrom string representing the rules 'from' property - * @param ruleParamsTo string representing the rules 'to' property - * @param ruleParamsMaxSignals int representing the maxSignals property on the rule (usually unmodified at 100) - * @param gap moment.Duration representing a gap in since the last time the rule ran - * @param previousStartedAt Date at which the rule last ran - * @param interval string the interval which the rule runs - * @param buildRuleMessage function provides meta information for logged event - */ -export const getSignalTimeTuples = ({ +export const getRuleRangeTuples = ({ logger, - ruleParamsFrom, - ruleParamsTo, - ruleParamsMaxSignals, - gap, previousStartedAt, + from, + to, interval, + maxSignals, buildRuleMessage, }: { logger: Logger; - ruleParamsFrom: string; - ruleParamsTo: string; - ruleParamsMaxSignals: number; - gap: moment.Duration | null; previousStartedAt: Date | null | undefined; + from: string; + to: string; interval: string; - buildRuleMessage: BuildRuleMessage; -}): Array<{ - to: moment.Moment | undefined; - from: moment.Moment | undefined; maxSignals: number; -}> => { - let totalToFromTuples: Array<{ - to: moment.Moment | undefined; - from: moment.Moment | undefined; - maxSignals: number; - }> = []; - if (gap != null && gap.valueOf() > 0 && previousStartedAt != null) { - const fromUnit = ruleParamsFrom[ruleParamsFrom.length - 1]; - if (isValidUnit(fromUnit)) { - const unit = fromUnit; // only seconds (s), minutes (m) or hours (h) - - /* - we need the total duration from now until the last time the rule ran. - the next few lines can be summed up as calculating - "how many second | minutes | hours have passed since the last time this ran?" - */ - const nowToGapDiff = moment.duration(moment().diff(previousStartedAt)); - const calculatedFrom = `now-${ - parseInt(shorthandMap[unit].asFn(nowToGapDiff).toString(), 10) + unit - }`; - logger.debug(buildRuleMessage(`calculatedFrom: ${calculatedFrom}`)); - - const intervalMoment = moment.duration(parseInt(interval, 10), unit); - logger.debug(buildRuleMessage(`intervalMoment: ${shorthandMap[unit].asFn(intervalMoment)}`)); - const momentUnit = shorthandMap[unit].momentString as moment.DurationInputArg2; - // maxCatchup is to ensure we are not trying to catch up too far back. - // This allows for a maximum of 4 consecutive rule execution misses - // to be included in the number of signals generated. - const { maxCatchup, ratio, gapDiffInUnits } = getGapMaxCatchupRatio({ - logger, - buildRuleMessage, - previousStartedAt, - unit, - ruleParamsFrom, - interval, - }); - logger.debug(buildRuleMessage(`maxCatchup: ${maxCatchup}, ratio: ${ratio}`)); - if (maxCatchup == null || ratio == null || gapDiffInUnits == null) { - throw new Error( - buildRuleMessage('failed to calculate maxCatchup, ratio, or gapDiffInUnits') - ); - } - let tempTo = dateMath.parse(ruleParamsFrom); - if (tempTo == null) { - // return an error - throw new Error(buildRuleMessage('dateMath parse failed')); - } - - let beforeMutatedFrom: moment.Moment | undefined; - while (totalToFromTuples.length < maxCatchup) { - // if maxCatchup is less than 1, we calculate the 'from' differently - // and maxSignals becomes some less amount of maxSignals - // in order to maintain maxSignals per full rule interval. - if (maxCatchup > 0 && maxCatchup < 1) { - totalToFromTuples.push({ - to: tempTo.clone(), - from: tempTo.clone().subtract(gapDiffInUnits, momentUnit), - maxSignals: ruleParamsMaxSignals * maxCatchup, - }); - break; - } - const beforeMutatedTo = tempTo.clone(); - - // moment.subtract mutates the moment so we need to clone again.. - beforeMutatedFrom = tempTo.clone().subtract(intervalMoment, momentUnit); - const tuple = { - to: beforeMutatedTo, - from: beforeMutatedFrom, - maxSignals: ruleParamsMaxSignals, - }; - totalToFromTuples = [...totalToFromTuples, tuple]; - tempTo = beforeMutatedFrom; - } - totalToFromTuples = [ - { - to: dateMath.parse(ruleParamsTo), - from: dateMath.parse(ruleParamsFrom), - maxSignals: ruleParamsMaxSignals, - }, - ...totalToFromTuples, - ]; - } - } else { - totalToFromTuples = [ - { - to: dateMath.parse(ruleParamsTo), - from: dateMath.parse(ruleParamsFrom), - maxSignals: ruleParamsMaxSignals, - }, - ]; + buildRuleMessage: BuildRuleMessage; +}) => { + const originalTo = dateMath.parse(to); + const originalFrom = dateMath.parse(from); + if (originalTo == null || originalFrom == null) { + throw new Error(buildRuleMessage('dateMath parse failed')); + } + const tuples = [ + { + to: originalTo, + from: originalFrom, + maxSignals, + }, + ]; + const intervalDuration = parseInterval(interval); + if (intervalDuration == null) { + logger.error(`Failed to compute gap between rule runs: could not parse rule interval`); + return { tuples, remainingGap: moment.duration(0) }; } - logger.debug( - buildRuleMessage(`totalToFromTuples: ${JSON.stringify(totalToFromTuples, null, 4)}`) + const gap = getGapBetweenRuns({ previousStartedAt, intervalDuration, from, to }); + const catchup = getNumCatchupIntervals({ + gap, + intervalDuration, + }); + const catchupTuples = getCatchupTuples({ + to: originalTo, + from: originalFrom, + ruleParamsMaxSignals: maxSignals, + catchup, + intervalDuration, + }); + tuples.push(...catchupTuples); + // Each extra tuple adds one extra intervalDuration to the time range this rule will cover. + const remainingGapMilliseconds = Math.max( + gap.asMilliseconds() - catchup * intervalDuration.asMilliseconds(), + 0 ); - return totalToFromTuples; + return { tuples, remainingGap: moment.duration(remainingGapMilliseconds) }; +}; + +/** + * Creates rule range tuples needed to cover gaps since the last rule run. + * @param to moment.Moment representing the rules 'to' property + * @param from moment.Moment representing the rules 'from' property + * @param ruleParamsMaxSignals int representing the maxSignals property on the rule (usually unmodified at 100) + * @param catchup number the number of additional rule run intervals to add + * @param intervalDuration moment.Duration the interval which the rule runs + */ +export const getCatchupTuples = ({ + to, + from, + ruleParamsMaxSignals, + catchup, + intervalDuration, +}: { + to: moment.Moment; + from: moment.Moment; + ruleParamsMaxSignals: number; + catchup: number; + intervalDuration: moment.Duration; +}): RuleRangeTuple[] => { + const catchupTuples: RuleRangeTuple[] = []; + const intervalInMilliseconds = intervalDuration.asMilliseconds(); + let currentTo = to; + let currentFrom = from; + // This loop will create tuples with overlapping time ranges, the same way rule runs have overlapping time + // ranges due to the additional lookback. We could choose to create tuples that don't overlap here by using the + // "from" value from one tuple as "to" in the next one, however, the overlap matters for rule types like EQL and + // threshold rules that look for sets of documents within the query. Thus we keep the overlap so that these + // extra tuples behave as similarly to the regular rule runs as possible. + while (catchupTuples.length < catchup) { + const nextTo = currentTo.clone().subtract(intervalInMilliseconds); + const nextFrom = currentFrom.clone().subtract(intervalInMilliseconds); + catchupTuples.push({ + to: nextTo, + from: nextFrom, + maxSignals: ruleParamsMaxSignals, + }); + currentTo = nextTo; + currentFrom = nextFrom; + } + return catchupTuples; }; /** From 83593bd8eef0d2037d1a802a620eac262513f1ea Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 25 Feb 2021 16:34:42 +0100 Subject: [PATCH 04/40] [ILM ] Fix logic for showing/hiding recommended allocation on Cloud (#90592) * updated logic for hiding recommended allocation options on cloud and moved tests over from legacy test folder * added version check and tests for version check to enable pre v8 behaviour * implement feedback to make tests more legible, fix test names and minor refactors * added additional callout for data tier state, also added some new copy specific to the migration of a deployment on cloud * remove unused stackVersion context value * address windows max path length constraint * - Fix botched conflict resolution! - Addressed PR feedback, updated data allocation field for readability; added comments and refactored default allocation notice and warning - Added one more test case for on cloud; when to show the call to migrate to node roles Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../edit_policy/edit_policy.helpers.tsx | 9 ++ .../edit_policy/edit_policy.test.ts | 136 ++++++++++++++---- .../reactive_form/node_allocation.test.ts | 27 +++- .../components/cloud_data_tier_callout.tsx | 27 +++- .../components/default_allocation_notice.tsx | 25 +--- .../components/default_allocation_warning.tsx | 59 ++++++++ .../components/index.ts | 4 + .../components/missing_cold_tier_callout.tsx | 45 ++++++ .../data_tier_allocation_field.tsx | 57 ++++++-- .../public/types.ts | 1 - 10 files changed, 320 insertions(+), 70 deletions(-) create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_warning.tsx create mode 100644 x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/missing_cold_tier_callout.tsx diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index a9845c23156049..53871eae6dba1c 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -183,6 +183,13 @@ export const setup = async (arg?: { const enable = (phase: Phases) => createFormToggleAction(`enablePhaseSwitch-${phase}`); + const showDataAllocationOptions = (phase: Phases) => () => { + act(() => { + find(`${phase}-dataTierAllocationControls.dataTierSelect`).simulate('click'); + }); + component.update(); + }; + const createMinAgeActions = (phase: Phases) => { return { hasMinAgeInput: () => exists(`${phase}-selectedMinimumAge`), @@ -379,6 +386,7 @@ export const setup = async (arg?: { }, warm: { enable: enable('warm'), + showDataAllocationOptions: showDataAllocationOptions('warm'), ...createMinAgeActions('warm'), setReplicas: setReplicas('warm'), hasErrorIndicator: () => exists('phaseErrorIndicator-warm'), @@ -390,6 +398,7 @@ export const setup = async (arg?: { }, cold: { enable: enable('cold'), + showDataAllocationOptions: showDataAllocationOptions('cold'), ...createMinAgeActions('cold'), setReplicas: setReplicas('cold'), setFreeze, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 7fe5c6f50d046b..ffa9b1aa236a0a 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -693,43 +693,52 @@ describe('', () => { expect(find('cold-dataTierAllocationControls.dataTierSelect').text()).toContain('Off'); }); }); - }); - - describe('searchable snapshot', () => { describe('on cloud', () => { - describe('new policy', () => { + describe('using legacy data role config', () => { beforeEach(async () => { - // simulate creating a new policy - httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('')]); + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); httpRequestsMockHelpers.setListNodes({ - isUsingDeprecatedDataRoleConfig: false, nodesByAttributes: { test: ['123'] }, - nodesByRoles: { data: ['123'] }, + // On cloud, even if there are data_* roles set, the default, recommended allocation option should not + // be available. + nodesByRoles: { data_hot: ['123'] }, + isUsingDeprecatedDataRoleConfig: true, }); httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); await act(async () => { - testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + testBed = await setup({ + appServicesContext: { + cloud: { + isCloudEnabled: true, + }, + license: licensingMock.createLicense({ license: { type: 'basic' } }), + }, + }); }); const { component } = testBed; component.update(); }); - test('defaults searchable snapshot to true on cloud', async () => { - const { find, actions } = testBed; - await actions.cold.enable(true); - expect( - find('searchableSnapshotField-cold.searchableSnapshotToggle').props()['aria-checked'] - ).toBe(true); + test('removes default, recommended option', async () => { + const { actions, find } = testBed; + await actions.warm.enable(true); + actions.warm.showDataAllocationOptions(); + + expect(find('defaultDataAllocationOption').exists()).toBeFalsy(); + expect(find('customDataAllocationOption').exists()).toBeTruthy(); + expect(find('noneDataAllocationOption').exists()).toBeTruthy(); + // Show the call-to-action for users to migrate their cluster to use node roles + expect(find('cloudDataTierCallout').exists()).toBeTruthy(); }); }); - describe('existing policy', () => { + describe('using node roles', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); httpRequestsMockHelpers.setListNodes({ - isUsingDeprecatedDataRoleConfig: false, nodesByAttributes: { test: ['123'] }, - nodesByRoles: { data: ['123'] }, + nodesByRoles: { data_hot: ['123'] }, + isUsingDeprecatedDataRoleConfig: false, }); httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); @@ -740,19 +749,34 @@ describe('', () => { const { component } = testBed; component.update(); }); - test('correctly sets snapshot repository default to "found-snapshots"', async () => { - const { actions } = testBed; + + test('should show recommended, custom and "off" options on cloud with data roles', async () => { + const { actions, find } = testBed; + + await actions.warm.enable(true); + actions.warm.showDataAllocationOptions(); + expect(find('defaultDataAllocationOption').exists()).toBeTruthy(); + expect(find('customDataAllocationOption').exists()).toBeTruthy(); + expect(find('noneDataAllocationOption').exists()).toBeTruthy(); + // We should not be showing the call-to-action for users to activate the cold tier on cloud + expect(find('cloudMissingColdTierCallout').exists()).toBeFalsy(); + // Do not show the call-to-action for users to migrate their cluster to use node roles + expect(find('cloudDataTierCallout').exists()).toBeFalsy(); + }); + + test('should show cloud notice when cold tier nodes do not exist', async () => { + const { actions, find } = testBed; await actions.cold.enable(true); - await actions.cold.toggleSearchableSnapshot(true); - await actions.savePolicy(); - const latestRequest = server.requests[server.requests.length - 1]; - const request = JSON.parse(JSON.parse(latestRequest.requestBody).body); - expect(request.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( - 'found-snapshots' - ); + expect(find('cloudMissingColdTierCallout').exists()).toBeTruthy(); + // Assert that other notices are not showing + expect(find('defaultAllocationNotice').exists()).toBeFalsy(); + expect(find('noNodeAttributesWarning').exists()).toBeFalsy(); }); }); }); + }); + + describe('searchable snapshot', () => { describe('on non-enterprise license', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); @@ -789,6 +813,64 @@ describe('', () => { expect(actions.cold.searchableSnapshotDisabledDueToLicense()).toBeTruthy(); }); }); + + describe('on cloud', () => { + describe('new policy', () => { + beforeEach(async () => { + // simulate creating a new policy + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('')]); + httpRequestsMockHelpers.setListNodes({ + nodesByAttributes: { test: ['123'] }, + nodesByRoles: { data: ['123'] }, + isUsingDeprecatedDataRoleConfig: false, + }); + httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); + + await act(async () => { + testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + }); + + const { component } = testBed; + component.update(); + }); + test('defaults searchable snapshot to true on cloud', async () => { + const { find, actions } = testBed; + await actions.cold.enable(true); + expect( + find('searchableSnapshotField-cold.searchableSnapshotToggle').props()['aria-checked'] + ).toBe(true); + }); + }); + describe('existing policy', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setListNodes({ + isUsingDeprecatedDataRoleConfig: false, + nodesByAttributes: { test: ['123'] }, + nodesByRoles: { data_hot: ['123'] }, + }); + httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); + + await act(async () => { + testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + }); + + const { component } = testBed; + component.update(); + }); + test('correctly sets snapshot repository default to "found-snapshots"', async () => { + const { actions } = testBed; + await actions.cold.enable(true); + await actions.cold.toggleSearchableSnapshot(true); + await actions.savePolicy(); + const latestRequest = server.requests[server.requests.length - 1]; + const request = JSON.parse(JSON.parse(latestRequest.requestBody).body); + expect(request.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( + 'found-snapshots' + ); + }); + }); + }); }); describe('with rollover', () => { beforeEach(async () => { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts index 113698fdf6df2b..b02d190d108997 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/reactive_form/node_allocation.test.ts @@ -125,7 +125,7 @@ describe(' node allocation', () => { expect(actions.warm.hasDefaultAllocationWarning()).toBeTruthy(); }); - test('shows default allocation notice when hot tier exists, but not warm tier', async () => { + test('when configuring warm phase shows default allocation notice when hot tier exists, but not warm tier', async () => { httpRequestsMockHelpers.setListNodes({ nodesByAttributes: {}, nodesByRoles: { data_hot: ['test'], data_cold: ['test'] }, @@ -309,7 +309,7 @@ describe(' node allocation', () => { describe('on cloud', () => { describe('with deprecated data role config', () => { - test('should hide data tier option on cloud using legacy node role configuration', async () => { + test('should hide data tier option on cloud', async () => { httpRequestsMockHelpers.setListNodes({ nodesByAttributes: { test: ['123'] }, // On cloud, if using legacy config there will not be any "data_*" roles set. @@ -331,10 +331,29 @@ describe(' node allocation', () => { expect(exists('customDataAllocationOption')).toBeTruthy(); expect(exists('noneDataAllocationOption')).toBeTruthy(); }); + + test('should ask users to migrate to node roles when on cloud using legacy data role', async () => { + httpRequestsMockHelpers.setListNodes({ + nodesByAttributes: { test: ['123'] }, + // On cloud, if using legacy config there will not be any "data_*" roles set. + nodesByRoles: { data: ['test'] }, + isUsingDeprecatedDataRoleConfig: true, + }); + await act(async () => { + testBed = await setup({ appServicesContext: { cloud: { isCloudEnabled: true } } }); + }); + const { actions, component, exists } = testBed; + + component.update(); + await actions.warm.enable(true); + expect(component.find('.euiLoadingSpinner').exists()).toBeFalsy(); + + expect(exists('cloudDataTierCallout')).toBeTruthy(); + }); }); describe('with node role config', () => { - test('shows off, custom and data role options on cloud with data roles', async () => { + test('shows data role, custom and "off" options on cloud with data roles', async () => { httpRequestsMockHelpers.setListNodes({ nodesByAttributes: { test: ['123'] }, nodesByRoles: { data: ['test'], data_hot: ['test'], data_warm: ['test'] }, @@ -372,7 +391,7 @@ describe(' node allocation', () => { await actions.cold.enable(true); expect(component.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(exists('cloudDataTierCallout')).toBeTruthy(); + expect(exists('cloudMissingColdTierCallout')).toBeTruthy(); // Assert that other notices are not showing expect(actions.cold.hasDefaultAllocationNotice()).toBeFalsy(); expect(actions.cold.hasNoNodeAttrsWarning()).toBeFalsy(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx index 4d3dbbba39037b..351d6ac1c530b9 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx @@ -7,21 +7,38 @@ import { i18n } from '@kbn/i18n'; import React, { FunctionComponent } from 'react'; -import { EuiCallOut } from '@elastic/eui'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; const i18nTexts = { title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.title', { - defaultMessage: 'Create a cold tier', + defaultMessage: 'Migrate to data tiers', }), body: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.body', { - defaultMessage: 'Edit your Elastic Cloud deployment to set up a cold tier.', + defaultMessage: 'Migrate your Elastic Cloud deployment to use data tiers.', }), + linkText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.linkToCloudDeploymentDescription', + { defaultMessage: 'View cloud deployment' } + ), }; -export const CloudDataTierCallout: FunctionComponent = () => { +interface Props { + linkToCloudDeployment?: string; +} + +/** + * A call-to-action for users to migrate to data tiers if their cluster is still running + * the deprecated node.data:true config. + */ +export const CloudDataTierCallout: FunctionComponent = ({ linkToCloudDeployment }) => { return ( - {i18nTexts.body} + {i18nTexts.body}{' '} + {Boolean(linkToCloudDeployment) && ( + + {i18nTexts.linkText} + + )} ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx index 562267089051a2..e43b7508497748 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx @@ -11,8 +11,6 @@ import { EuiCallOut } from '@elastic/eui'; import { PhaseWithAllocation, DataTierRole } from '../../../../../../../../../common/types'; -import { AllocationNodeRole } from '../../../../../../../lib'; - const i18nTextsNodeRoleToDataTier: Record = { data_hot: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.dataTierHotLabel', { defaultMessage: 'hot', @@ -84,24 +82,13 @@ const i18nTexts = { interface Props { phase: PhaseWithAllocation; - targetNodeRole: AllocationNodeRole; + targetNodeRole: DataTierRole; } export const DefaultAllocationNotice: FunctionComponent = ({ phase, targetNodeRole }) => { - const content = - targetNodeRole === 'none' ? ( - - {i18nTexts.warning[phase].body} - - ) : ( - - {i18nTexts.notice[phase].body(targetNodeRole)} - - ); - - return content; + return ( + + {i18nTexts.notice[phase].body(targetNodeRole)} + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_warning.tsx new file mode 100644 index 00000000000000..a194f3c07f9003 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_warning.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 { i18n } from '@kbn/i18n'; +import React, { FunctionComponent } from 'react'; +import { EuiCallOut } from '@elastic/eui'; + +import { PhaseWithAllocation } from '../../../../../../../../../common/types'; + +const i18nTexts = { + warning: { + warm: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the warm tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the warm or hot tier to use role-based allocation. The policy will fail to complete allocation if there are no available nodes.', + } + ), + }, + cold: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the cold tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the cold, warm, or hot tier to use role-based allocation. The policy will fail to complete allocation if there are no available nodes.', + } + ), + }, + }, +}; + +interface Props { + phase: PhaseWithAllocation; +} + +export const DefaultAllocationWarning: FunctionComponent = ({ phase }) => { + return ( + + {i18nTexts.warning[phase].body} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts index b3f57ac24e0d75..938e0a850f9335 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts @@ -13,8 +13,12 @@ export { DataTierAllocation } from './data_tier_allocation'; export { DefaultAllocationNotice } from './default_allocation_notice'; +export { DefaultAllocationWarning } from './default_allocation_warning'; + export { NoNodeAttributesWarning } from './no_node_attributes_warning'; +export { MissingColdTierCallout } from './missing_cold_tier_callout'; + export { CloudDataTierCallout } from './cloud_data_tier_callout'; export { LoadingError } from './loading_error'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/missing_cold_tier_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/missing_cold_tier_callout.tsx new file mode 100644 index 00000000000000..21b8850e0b0884 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/missing_cold_tier_callout.tsx @@ -0,0 +1,45 @@ +/* + * 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'; +import React, { FunctionComponent } from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; + +const i18nTexts = { + title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudMissingColdTierCallout.title', { + defaultMessage: 'Create a cold tier', + }), + body: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudMissingColdTierCallout.body', { + defaultMessage: 'Edit your Elastic Cloud deployment to set up a cold tier.', + }), + linkText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.cloudMissingColdTierCallout.linkToCloudDeploymentDescription', + { defaultMessage: 'View cloud deployment' } + ), +}; + +interface Props { + linkToCloudDeployment?: string; +} + +/** + * A call-to-action for users to activate their cold tier slider to provision cold tier nodes. + * This may need to be change when we have autoscaling enabled on a cluster because nodes may not + * yet exist, but will automatically be provisioned. + */ +export const MissingColdTierCallout: FunctionComponent = ({ linkToCloudDeployment }) => { + return ( + + {i18nTexts.body}{' '} + {Boolean(linkToCloudDeployment) && ( + + {i18nTexts.linkText} + + )} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx index ad36039728f5ce..7a660e0379a8da 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx @@ -14,9 +14,7 @@ import { useKibana, useFormData } from '../../../../../../../shared_imports'; import { PhaseWithAllocation } from '../../../../../../../../common/types'; -import { getAvailableNodeRoleForPhase } from '../../../../../../lib/data_tiers'; - -import { isNodeRoleFirstPreference } from '../../../../../../lib'; +import { getAvailableNodeRoleForPhase, isNodeRoleFirstPreference } from '../../../../../../lib'; import { useLoadNodes } from '../../../../../../services/api'; @@ -25,7 +23,9 @@ import { DataTierAllocationType } from '../../../../types'; import { DataTierAllocation, DefaultAllocationNotice, + DefaultAllocationWarning, NoNodeAttributesWarning, + MissingColdTierCallout, CloudDataTierCallout, LoadingError, } from './components'; @@ -65,30 +65,48 @@ export const DataTierAllocationField: FunctionComponent = ({ phase, descr ); const hasNodeAttrs = Boolean(Object.keys(nodesByAttributes ?? {}).length); const isCloudEnabled = cloud?.isCloudEnabled ?? false; + const cloudDeploymentUrl = cloud?.cloudDeploymentUrl; const renderNotice = () => { switch (allocationType) { case 'node_roles': - if (isCloudEnabled && phase === 'cold') { - const isUsingNodeRolesAllocation = !isUsingDeprecatedDataRoleConfig && hasDataNodeRoles; + /** + * We'll drive Cloud users to add a cold tier to their deployment if there are no nodes with the cold node role. + */ + if (isCloudEnabled && phase === 'cold' && !isUsingDeprecatedDataRoleConfig) { const hasNoNodesWithNodeRole = !nodesByRoles.data_cold?.length; - if (isUsingNodeRolesAllocation && hasNoNodesWithNodeRole) { + if (hasDataNodeRoles && hasNoNodesWithNodeRole) { // Tell cloud users they can deploy nodes on cloud. return ( <> - + ); } } + /** + * Node role allocation moves data in a phase to a corresponding tier of the same name. To prevent policy execution from getting + * stuck ILM allocation will fall back to a previous tier if possible. We show the WARNING below to inform a user when even + * this fallback will not succeed. + */ const allocationNodeRole = getAvailableNodeRoleForPhase(phase, nodesByRoles); - if ( - allocationNodeRole === 'none' || - !isNodeRoleFirstPreference(phase, allocationNodeRole) - ) { + if (allocationNodeRole === 'none') { + return ( + <> + + + + ); + } + + /** + * If we are able to fallback to a data tier that does not map to this phase, we show a notice informing the user that their + * data will not be assigned to a corresponding tier. + */ + if (!isNodeRoleFirstPreference(phase, allocationNodeRole)) { return ( <> @@ -106,6 +124,19 @@ export const DataTierAllocationField: FunctionComponent = ({ phase, descr ); } + /** + * Special cloud case: when deprecated data role configuration is in use, it means that this deployment is not using + * the new node role based allocation. We drive users to the cloud console to migrate to node role based allocation + * in that case. + */ + if (isCloudEnabled && isUsingDeprecatedDataRoleConfig) { + return ( + <> + + + + ); + } break; default: return null; @@ -141,9 +172,7 @@ export const DataTierAllocationField: FunctionComponent = ({ phase, descr hasNodeAttributes={hasNodeAttrs} phase={phase} nodes={nodesByAttributes} - disableDataTierOption={Boolean( - isCloudEnabled && !hasDataNodeRoles && isUsingDeprecatedDataRoleConfig - )} + disableDataTierOption={Boolean(isCloudEnabled && isUsingDeprecatedDataRoleConfig)} isLoading={isLoading} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/types.ts b/x-pack/plugins/index_lifecycle_management/public/types.ts index 27b3795c6731f0..adfca9ad41b26c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/types.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; From 805e729bdbc67be0414b925699c14f1601449a7d Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 25 Feb 2021 16:44:13 +0100 Subject: [PATCH 05/40] =?UTF-8?q?docs:=20=E2=9C=8F=EF=B8=8F=20use=20correc?= =?UTF-8?q?t=20heading=20level=20(#92806)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: ✏️ use correct heading level * docs: ✏️ correct heading in data plugin --- src/plugins/embeddable/README.asciidoc | 2 +- src/plugins/expressions/README.asciidoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/embeddable/README.asciidoc b/src/plugins/embeddable/README.asciidoc index 007b16587e9f89..165dc37c56cb34 100644 --- a/src/plugins/embeddable/README.asciidoc +++ b/src/plugins/embeddable/README.asciidoc @@ -26,7 +26,7 @@ link:https://github.com/elastic/kibana/blob/master/src/plugins/embeddable/docs/R === API docs -===== Browser API +==== Browser API https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablesetup.md[Browser Setup contract] https://github.com/elastic/kibana/blob/master/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestart.md[Browser Start contract] diff --git a/src/plugins/expressions/README.asciidoc b/src/plugins/expressions/README.asciidoc index e07f6e2909ab8c..554e3bfcb6976c 100644 --- a/src/plugins/expressions/README.asciidoc +++ b/src/plugins/expressions/README.asciidoc @@ -46,7 +46,7 @@ image::https://user-images.githubusercontent.com/9773803/74162514-3250a880-4c21- https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionsserversetup.md[Server Setup contract] https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.expressionsserverstart.md[Server Start contract] -===== Browser API +==== Browser API https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsservicesetup.md[Browser Setup contract] https://github.com/elastic/kibana/blob/master/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionsstart.md[Browser Start contract] From 6b91f480f55d30626773e29dc4a5258fc3285496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ece=20=C3=96zalp?= Date: Thu, 25 Feb 2021 11:00:42 -0500 Subject: [PATCH 06/40] [Security Solution] Replace EUI theme with mocks in jest suites (#92462) Previously there were a large number of jest specs that utilized the ThemeProvider (from styled-components package) to inject EUI themes into the mounted components. The full EUI theme is almost never necessary for unit tests as each tested component usually consumes no more than a single field or two from the EUI theme. In certain cases, the theme was not used at all. This change is intended to remove all unnecessary ThemeProviders from the suites, and replaces the imported EUI theme json files with mock themes customized for each tested component. With this change, snapshots are now significantly smaller, and tests are lighter. Closes #64092. --- .../components/and_or_badge/index.test.tsx | 11 +- .../and_or_badge/rounded_badge.test.tsx | 14 +- .../rounded_badge_antenna.test.tsx | 9 +- .../components/autocomplete/field.test.tsx | 142 ++- .../autocomplete/field_value_exists.test.tsx | 8 +- .../autocomplete/field_value_lists.test.tsx | 142 +-- .../autocomplete/field_value_match.test.tsx | 418 ++++--- .../field_value_match_any.test.tsx | 238 ++-- .../components/autocomplete/operator.test.tsx | 206 ++-- .../components/charts/barchart.test.tsx | 20 +- .../charts/draggable_legend.test.tsx | 28 +- .../charts/draggable_legend_item.test.tsx | 28 +- .../empty_value/empty_value.test.tsx | 21 +- .../add_exception_modal/index.test.tsx | 26 +- .../exceptions/builder/and_badge.test.tsx | 9 +- .../builder/exception_item.test.tsx | 15 +- .../exceptions/builder/index.test.tsx | 25 +- .../edit_exception_modal/index.test.tsx | 26 +- .../exceptions/error_callout.test.tsx | 142 ++- .../exception_item/exception_details.test.tsx | 29 +- .../exception_item/exception_entries.test.tsx | 19 +- .../viewer/exception_item/index.test.tsx | 20 +- .../viewer/exceptions_pagination.test.tsx | 122 +- .../viewer/exceptions_utility.test.tsx | 25 +- .../viewer/exceptions_viewer_header.test.tsx | 200 ++-- .../viewer/exceptions_viewer_items.test.tsx | 43 +- .../exceptions/viewer/index.test.tsx | 21 +- .../common/components/inspect/modal.test.tsx | 20 +- .../__snapshots__/index.test.tsx.snap | 1010 +++-------------- .../components/paginated_table/index.test.tsx | 81 +- .../components/stat_items/index.test.tsx | 7 +- .../threat_match/and_badge.test.tsx | 13 +- .../components/threat_match/index.test.tsx | 23 +- .../threat_match/list_item.test.tsx | 15 +- .../query_preview/custom_histogram.test.tsx | 82 +- .../query_preview/eql_histogram.test.tsx | 114 +- .../rules/query_preview/histogram.test.tsx | 92 +- .../rules/query_preview/index.test.tsx | 33 +- .../threshold_histogram.test.tsx | 72 +- .../rules/rule_switch/index.test.tsx | 67 +- .../rules/step_about_rule/index.test.tsx | 19 +- .../step_about_rule_details/index.test.tsx | 11 +- .../rules/all/utility_bar.test.tsx | 15 +- .../__snapshots__/index.test.tsx.snap | 3 - .../trusted_apps_grid/index.test.tsx | 11 +- .../__snapshots__/index.test.tsx.snap | 9 +- .../trusted_apps_list/index.test.tsx | 11 +- .../alerts_by_category/index.test.tsx | 19 +- .../fields_browser/categories_pane.test.tsx | 35 +- .../fields_browser/category_columns.test.tsx | 83 +- .../note_previews/index.test.tsx | 33 +- .../open_timeline/open_timeline.test.tsx | 66 +- .../open_timeline_modal_body.test.tsx | 24 +- .../open_timeline_modal_button.test.tsx | 16 +- .../open_timeline/search_row/index.test.tsx | 21 +- .../timelines_table/actions_columns.test.tsx | 34 +- .../timelines_table/common_columns.test.tsx | 94 +- .../timelines_table/extended_columns.test.tsx | 10 +- .../icon_header_columns.test.tsx | 25 +- .../timelines_table/index.test.tsx | 36 +- .../open_timeline/title_row/index.test.tsx | 18 +- .../renderers/plain_row_renderer.test.tsx | 8 +- .../unknown_column_renderer.test.tsx | 12 +- 63 files changed, 1664 insertions(+), 2585 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx index 11f424d83c530f..a6dd64737f5eee 100644 --- a/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx @@ -8,14 +8,15 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { AndOrBadge } from './'; +const mockTheme = { eui: { euiColorLightShade: '#ece' } }; + describe('AndOrBadge', () => { test('it renders top and bottom antenna bars when "includeAntennas" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -27,7 +28,7 @@ describe('AndOrBadge', () => { test('it does not render top and bottom antenna bars when "includeAntennas" is false', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -39,7 +40,7 @@ describe('AndOrBadge', () => { test('it renders "and" when "type" is "and"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -49,7 +50,7 @@ describe('AndOrBadge', () => { test('it renders "or" when "type" is "or"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); diff --git a/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge.test.tsx b/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge.test.tsx index 5bc89baa3d4159..489d02990b1f42 100644 --- a/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge.test.tsx @@ -6,29 +6,19 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { RoundedBadge } from './rounded_badge'; describe('RoundedBadge', () => { test('it renders "and" when "type" is "and"', () => { - const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - ); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); }); test('it renders "or" when "type" is "or"', () => { - const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - ); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); }); diff --git a/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.test.tsx b/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.test.tsx index 61cf44293005f7..c6536a05be45db 100644 --- a/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/and_or_badge/rounded_badge_antenna.test.tsx @@ -8,14 +8,15 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { RoundedBadgeAntenna } from './rounded_badge_antenna'; +const mockTheme = { eui: { euiColorLightShade: '#ece' } }; + describe('RoundedBadgeAntenna', () => { test('it renders top and bottom antenna bars', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -27,7 +28,7 @@ describe('RoundedBadgeAntenna', () => { test('it renders "and" when "type" is "and"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -37,7 +38,7 @@ describe('RoundedBadgeAntenna', () => { test('it renders "or" when "type" is "or"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx index 6a3d82d47045a3..79e6fe5506b84a 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { @@ -20,21 +18,19 @@ import { FieldComponent } from './field'; describe('FieldComponent', () => { test('it renders disabled if "isDisabled" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -44,21 +40,19 @@ describe('FieldComponent', () => { test('it renders loading if "isLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] button`).at(0).simulate('click'); expect( @@ -70,21 +64,19 @@ describe('FieldComponent', () => { test('it allows user to clear values if "isClearable" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -96,21 +88,19 @@ describe('FieldComponent', () => { test('it correctly displays selected field', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -121,21 +111,19 @@ describe('FieldComponent', () => { test('it invokes "onChange" when option selected', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx index f577799827b89b..b6300581f12dd8 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_exists.test.tsx @@ -6,19 +6,13 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { AutocompleteFieldExistsComponent } from './field_value_exists'; describe('AutocompleteFieldExistsComponent', () => { test('it renders field disabled', () => { - const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - ); + const wrapper = mount(); expect( wrapper diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx index e01cc5ff1e0423..c605a71c50e336 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { waitFor } from '@testing-library/react'; @@ -47,17 +45,15 @@ jest.mock('../../../lists_plugin_deps', () => { describe('AutocompleteFieldListsComponent', () => { test('it renders disabled if "isDisabled" is true', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -69,17 +65,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it renders loading if "isLoading" is true', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper @@ -97,17 +91,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it allows user to clear values if "isClearable" is true', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( wrapper @@ -118,17 +110,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it correctly displays lists that match the selected "keyword" field esType', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click'); @@ -142,17 +132,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it correctly displays lists that match the selected "ip" field esType', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click'); @@ -166,17 +154,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it correctly displays selected list', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -190,17 +176,15 @@ describe('AutocompleteFieldListsComponent', () => { test('it invokes "onChange" when option selected', async () => { const mockOnChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx index d4712092867e93..38d103fe651301 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount, ReactWrapper } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiSuperSelect, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { act } from '@testing-library/react'; @@ -44,23 +42,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it renders row label if one passed in', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -70,23 +66,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it renders disabled if "isDisabled" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -96,23 +90,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it renders loading if "isLoading" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="valuesAutocompleteMatch"] button').at(0).simulate('click'); expect( @@ -124,23 +116,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it allows user to clear values if "isClearable" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -152,23 +142,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it correctly displays selected value', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -179,23 +167,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it invokes "onChange" when new value created', async () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { @@ -208,23 +194,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it invokes "onChange" when new value selected', async () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { @@ -236,23 +220,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it refreshes autocomplete with search query when new value searched', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); act(() => { ((wrapper.find(EuiComboBox).props() as unknown) as { @@ -287,23 +269,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it displays only two options - "true" or "false"', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -326,23 +306,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it invokes "onChange" with "true" when selected', () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiSuperSelect).props() as unknown) as { @@ -355,23 +333,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it invokes "onChange" with "false" when selected', () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiSuperSelect).props() as unknown) as { @@ -396,23 +372,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it number input when field type is number', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -423,23 +397,21 @@ describe('AutocompleteFieldMatchComponent', () => { test('it invokes "onChange" with numeric value when inputted', () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx index aa2038262f40cb..6b479c5ab8c4c9 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount, ReactWrapper } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { act } from '@testing-library/react'; @@ -43,24 +41,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it renders disabled if "isDisabled" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -70,24 +66,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it renders loading if "isLoading" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find(`[data-test-subj="valuesAutocompleteMatchAny"] button`).at(0).simulate('click'); expect( @@ -99,24 +93,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it allows user to clear values if "isClearable" is true', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -128,24 +120,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it correctly displays selected value', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -156,24 +146,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it invokes "onChange" when new value created', async () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { @@ -186,24 +174,22 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it invokes "onChange" when new value selected', async () => { const mockOnChange = jest.fn(); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { @@ -215,23 +201,21 @@ describe('AutocompleteFieldMatchAnyComponent', () => { test('it refreshes autocomplete with search query when new value searched', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); act(() => { ((wrapper.find(EuiComboBox).props() as unknown) as { diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx index 56ae6d762e7ee9..db16cbde2acb44 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; @@ -18,17 +16,15 @@ import { isOperator, isNotOperator } from './operators'; describe('OperatorComponent', () => { test('it renders disabled if "isDisabled" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -38,17 +34,15 @@ describe('OperatorComponent', () => { test('it renders loading if "isLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] button`).at(0).simulate('click'); expect( @@ -60,17 +54,15 @@ describe('OperatorComponent', () => { test('it allows user to clear values if "isClearable" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find(`button[data-test-subj="comboBoxClearButton"]`).exists()).toBeTruthy(); @@ -78,18 +70,16 @@ describe('OperatorComponent', () => { test('it displays "operatorOptions" if param is passed in with items', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -99,18 +89,16 @@ describe('OperatorComponent', () => { test('it does not display "operatorOptions" if param is passed in with no items', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -145,17 +133,15 @@ describe('OperatorComponent', () => { test('it correctly displays selected operator', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -165,27 +151,25 @@ describe('OperatorComponent', () => { test('it only displays subset of operators if field type is nested', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -195,17 +179,15 @@ describe('OperatorComponent', () => { test('it only displays subset of operators if field type is boolean', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -221,17 +203,15 @@ describe('OperatorComponent', () => { test('it invokes "onChange" when option selected', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); ((wrapper.find(EuiComboBox).props() as unknown) as { diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx index 096bea37566b32..6d87b5d3a68b90 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx @@ -6,10 +6,8 @@ */ import { Chart, BarSeries, Axis, ScaleType } from '@elastic/charts'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, ReactWrapper, shallow, ShallowWrapper } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { escapeDataProviderId } from '../drag_and_drop/helpers'; import { TestProviders } from '../../mock'; @@ -37,8 +35,6 @@ jest.mock('uuid', () => { }; }); -const theme = () => ({ eui: euiDarkVars, darkMode: true }); - const customHeight = '100px'; const customWidth = '120px'; const chartDataSets = [ @@ -323,11 +319,9 @@ describe.each(chartDataSets)('BarChart with stackByField', () => { beforeAll(() => { wrapper = mount( - - - - - + + + ); }); @@ -407,11 +401,9 @@ describe.each(chartDataSets)('BarChart with custom color', () => { beforeAll(() => { wrapper = mount( - - - - - + + + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx index fb91a4a3ce92b3..544f9b1abf8f2b 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx @@ -5,10 +5,8 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; import '../../mock/match_media'; import '../../mock/react_beautiful_dnd'; @@ -26,8 +24,6 @@ jest.mock('@elastic/eui', () => { }; }); -const theme = () => ({ eui: euiDarkVars, darkMode: true }); - const allOthersDataProviderId = 'draggable-legend-item-527adabe-8e1c-4a1f-965c-2f3d65dda9e1-event_dataset-All others'; @@ -74,11 +70,9 @@ describe('DraggableLegend', () => { beforeEach(() => { wrapper = mount( - - - - - + + + ); }); @@ -120,11 +114,9 @@ describe('DraggableLegend', () => { it('does NOT render the legend when an empty collection of legendItems is provided', () => { const wrapper = mount( - - - - - + + + ); expect(wrapper.find('[data-test-subj="draggable-legend"]').exists()).toBe(false); @@ -132,11 +124,9 @@ describe('DraggableLegend', () => { it(`renders a legend with the minimum height when 'height' is zero`, () => { const wrapper = mount( - - - - - + + + ); expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx index 15c164e59557de..4958f6bae4a307 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx @@ -5,10 +5,8 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; import '../../mock/match_media'; import '../../mock/react_beautiful_dnd'; @@ -25,8 +23,6 @@ jest.mock('@elastic/eui', () => { }; }); -const theme = () => ({ eui: euiDarkVars, darkMode: true }); - describe('DraggableLegendItem', () => { describe('rendering a regular (non "All others") legend item', () => { const legendItem: LegendItem = { @@ -41,11 +37,9 @@ describe('DraggableLegendItem', () => { beforeEach(() => { wrapper = mount( - - - - - + + + ); }); @@ -79,11 +73,9 @@ describe('DraggableLegendItem', () => { beforeEach(() => { wrapper = mount( - - - - - + + + ); }); @@ -118,11 +110,9 @@ describe('DraggableLegendItem', () => { }; const wrapper = mount( - - - - - + + + ); expect(wrapper.find('[data-test-subj="legend-color"]').exists()).toBe(false); diff --git a/x-pack/plugins/security_solution/public/common/components/empty_value/empty_value.test.tsx b/x-pack/plugins/security_solution/public/common/components/empty_value/empty_value.test.tsx index 764d9109816b58..e3c74bf4256288 100644 --- a/x-pack/plugins/security_solution/public/common/components/empty_value/empty_value.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/empty_value/empty_value.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, shallow } from 'enzyme'; import React from 'react'; import { ThemeProvider } from 'styled-components'; @@ -21,7 +20,7 @@ import { } from '.'; describe('EmptyValue', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); + const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; test('it renders against snapshot', () => { const wrapper = shallow(

{getEmptyString()}

); @@ -35,7 +34,7 @@ describe('EmptyValue', () => { describe('#getEmptyString', () => { test('should turn into an empty string place holder', () => { const wrapper = mountWithIntl( - +

{getEmptyString()}

); @@ -45,7 +44,7 @@ describe('EmptyValue', () => { describe('#getEmptyTagValue', () => { const wrapper = mount( - +

{getEmptyTagValue()}

); @@ -55,7 +54,7 @@ describe('EmptyValue', () => { describe('#getEmptyStringTag', () => { test('should turn into an span that has length of 1', () => { const wrapper = mountWithIntl( - +

{getEmptyStringTag()}

); @@ -64,7 +63,7 @@ describe('EmptyValue', () => { test('should turn into an empty string tag place holder', () => { const wrapper = mountWithIntl( - +

{getEmptyStringTag()}

); @@ -75,7 +74,7 @@ describe('EmptyValue', () => { describe('#defaultToEmptyTag', () => { test('should default to an empty value when a value is null', () => { const wrapper = mount( - +

{defaultToEmptyTag(null)}

); @@ -84,7 +83,7 @@ describe('EmptyValue', () => { test('should default to an empty value when a value is undefined', () => { const wrapper = mount( - +

{defaultToEmptyTag(undefined)}

); @@ -114,7 +113,7 @@ describe('EmptyValue', () => { }, }; const wrapper = mount( - +

{getOrEmptyTag('a.b.c', test)}

); @@ -130,7 +129,7 @@ describe('EmptyValue', () => { }, }; const wrapper = mount( - +

{getOrEmptyTag('a.b.c', test)}

); @@ -144,7 +143,7 @@ describe('EmptyValue', () => { }, }; const wrapper = mount( - +

{getOrEmptyTag('a.b.c', test)}

); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx index af76a79f0e330a..9ba6fe104be45a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount, ReactWrapper } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { waitFor } from '@testing-library/react'; import { AddExceptionModal } from './'; @@ -32,6 +31,17 @@ import { import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; import { AlertData } from '../types'; +const mockTheme = { + eui: { + euiBreakpoints: { + l: '1200px', + }, + paddingSizes: { + m: '10px', + }, + }, +}; + jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index'); jest.mock('../../../../common/lib/kibana'); jest.mock('../../../containers/source'); @@ -101,7 +111,7 @@ describe('When the add exception modal is opened', () => { }, ]); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { file: { path: 'test/path' }, }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { file: { path: 'test/path' }, }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { file: { path: 'test/path' }, }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { file: { path: 'test/path' }, }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('when there are exception builder errors submit button is disabled', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders exceptionItemEntryFirstRowAndBadge for very first exception item in builder', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -27,7 +28,7 @@ describe('BuilderAndBadgeComponent', () => { test('it renders exceptionItemEntryInvisibleAndBadge if "entriesLength" is 1 or less', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -39,7 +40,7 @@ describe('BuilderAndBadgeComponent', () => { test('it renders regular "and" badge if exception item is not the first one and includes more than one entry', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx index 6505c5eb2b3104..1ea54473032cc3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { useKibana } from '../../../../common/lib/kibana'; import { fields } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; @@ -18,6 +17,12 @@ import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/typ import { BuilderExceptionListItemComponent } from './exception_item'; +const mockTheme = { + eui: { + euiColorLightShade: '#ece', + }, +}; + jest.mock('../../../../common/lib/kibana'); describe('BuilderExceptionListItemComponent', () => { @@ -46,7 +51,7 @@ describe('BuilderExceptionListItemComponent', () => { entries: [getEntryMatchMock(), getEntryMatchMock()], }; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { @@ -50,7 +55,7 @@ describe('ExceptionBuilderComponent', () => { test('it displays empty entry if no "exceptionListItems" are passed in', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "exceptionListItems" that are passed in', async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "or", "and" and "add nested button" enabled', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it adds an entry when "and" clicked', async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it adds an exception item when "or" clicked', async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays empty entry if user deletes last remaining entry', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "and" badge if at least one exception item includes more than one entry', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it does not display "and" badge if none of the exception items include more than one entry', () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { describe('nested entry', () => { test('it adds a nested entry when "add nested entry" clicked', async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { }, ]); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { ] as EntriesArray, }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { }, })); wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { beforeEach(async () => { const exceptionItemMock = { ...getExceptionListItemSchemaMock(), entries: [] }; wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('when there are exception builder errors has the add exception button disabled', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders error details', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -57,21 +53,19 @@ describe('ErrorCallout', () => { it('it invokes "onCancel" when cancel button clicked', () => { const mockOnCancel = jest.fn(); const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="errorCalloutCancelButton"]').at(0).simulate('click'); @@ -81,21 +75,19 @@ describe('ErrorCallout', () => { it('it does not render status code if not available', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -109,21 +101,19 @@ describe('ErrorCallout', () => { it('it renders specific missing exceptions list error', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -137,21 +127,19 @@ describe('ErrorCallout', () => { it('it dissasociates list from rule when remove exception list clicked ', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').at(0).simulate('click'); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx index c7d7d2d39393cc..b96ae5c06dd22c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx @@ -8,13 +8,18 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import moment from 'moment-timezone'; import { ExceptionDetails } from './exception_details'; import { getExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comment.mock'; +const mockTheme = { + eui: { + euiColorLightestShade: '#ece', + }, +}; + describe('ExceptionDetails', () => { beforeEach(() => { moment.tz.setDefault('UTC'); @@ -29,7 +34,7 @@ describe('ExceptionDetails', () => { exceptionItem.comments = []; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = [getCommentsArrayMock()[0]]; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders the operating system if one is specified in the exception item', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders the exception item creator', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders the exception item creation timestamp', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders the description if one is included on the exception item', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it does NOT render the and badge if only one exception item entry exists', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders the and badge if more than one exception item exists', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it invokes "onEdit" when edit button clicked', () => { const mockOnEdit = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it invokes "onDelete" when delete button clicked', () => { const mockOnDelete = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders edit button disabled if "disableDelete" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders delete button in loading state if "disableDelete" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { parentEntry.value = undefined; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders non-nested entries', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders ExceptionDetails and ExceptionEntries', () => { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders passed in "pageSize" as selected option', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="exceptionsPerPageBtn"]').at(0).text()).toEqual( @@ -35,17 +31,15 @@ describe('ExceptionsViewerPagination', () => { it('it renders all passed in page size options when per page button clicked', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsPerPageBtn"] button').simulate('click'); @@ -64,17 +58,15 @@ describe('ExceptionsViewerPagination', () => { it('it invokes "onPaginationChange" when per page item is clicked', () => { const mockOnPaginationChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsPerPageBtn"] button').simulate('click'); @@ -87,17 +79,15 @@ describe('ExceptionsViewerPagination', () => { it('it renders correct total page count', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="exceptionsPagination"]').at(0).prop('pageCount')).toEqual( @@ -111,17 +101,15 @@ describe('ExceptionsViewerPagination', () => { it('it invokes "onPaginationChange" when next clicked', () => { const mockOnPaginationChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="pagination-button-next"]').at(1).simulate('click'); @@ -134,17 +122,15 @@ describe('ExceptionsViewerPagination', () => { it('it invokes "onPaginationChange" when page clicked', () => { const mockOnPaginationChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('button[data-test-subj="pagination-button-3"]').simulate('click'); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx index 6167a29a4a17d3..42ce0c792dfa3a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_utility.test.tsx @@ -8,14 +8,25 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mountWithIntl } from '@kbn/test/jest'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { ExceptionsViewerUtility } from './exceptions_utility'; +const mockTheme = { + eui: { + euiBreakpoints: { + l: '1200px', + }, + paddingSizes: { + m: '10px', + }, + euiBorderThin: '1px solid #ece', + }, +}; + describe('ExceptionsViewerUtility', () => { it('it renders correct pluralized text when more than one exception exists', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders correct singular text when less than two exceptions exists', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it invokes "onRefreshClick" when refresh button clicked', () => { const mockOnRefreshClick = jest.fn(); const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it does not render any messages when "showEndpointList" and "showDetectionsList" are "false"', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it does render detections messages when "showDetectionsList" is "true"', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it does render endpoint messages when "showEndpointList" is "true"', () => { const wrapper = mountWithIntl( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders all disabled if "isInitLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -47,16 +43,14 @@ describe('ExceptionsViewerHeader', () => { it('it displays toggles and add exception popover when more than one list type available', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="exceptionsFilterGroupBtns"]').exists()).toBeTruthy(); @@ -67,16 +61,14 @@ describe('ExceptionsViewerHeader', () => { it('it does not display toggles and add exception popover if only one list type is available', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="exceptionsFilterGroupBtns"]')).toHaveLength(0); @@ -87,16 +79,14 @@ describe('ExceptionsViewerHeader', () => { it('it displays add exception button without popover if only one list type is available', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect( @@ -107,16 +97,14 @@ describe('ExceptionsViewerHeader', () => { it('it renders detections filter toggle selected when clicked', () => { const mockOnFilterChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsDetectionFilterBtn"] button').simulate('click'); @@ -149,16 +137,14 @@ describe('ExceptionsViewerHeader', () => { it('it renders endpoint filter toggle selected and invokes "onFilterChange" when clicked', () => { const mockOnFilterChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsEndpointFilterBtn"] button').simulate('click'); @@ -191,16 +177,14 @@ describe('ExceptionsViewerHeader', () => { it('it invokes "onAddExceptionClick" when user selects to add an exception item and only endpoint exception lists are available', () => { const mockOnAddExceptionClick = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"] button').simulate('click'); @@ -211,16 +195,14 @@ describe('ExceptionsViewerHeader', () => { it('it invokes "onAddDetectionsExceptionClick" when user selects to add an exception item and only endpoint detections lists are available', () => { const mockOnAddExceptionClick = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"] button').simulate('click'); @@ -231,16 +213,14 @@ describe('ExceptionsViewerHeader', () => { it('it invokes "onAddEndpointExceptionClick" when user selects to add an exception item to endpoint list from popover', () => { const mockOnAddExceptionClick = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper @@ -254,16 +234,14 @@ describe('ExceptionsViewerHeader', () => { it('it invokes "onAddDetectionsExceptionClick" when user selects to add an exception item to endpoint list from popover', () => { const mockOnAddExceptionClick = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper @@ -277,16 +255,14 @@ describe('ExceptionsViewerHeader', () => { it('it invokes "onFilterChange" when search used and "Enter" pressed', () => { const mockOnFilterChange = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('EuiFieldSearch').at(0).simulate('keyup', { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.test.tsx index 3171735d905dee..167b95995212bd 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exceptions_viewer_items.test.tsx @@ -8,27 +8,32 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import * as i18n from '../translations'; import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { ExceptionsViewerItems } from './exceptions_viewer_items'; +const mockTheme = { + eui: { + euiSize: '10px', + euiColorPrimary: '#ece', + euiColorDanger: '#ece', + }, +}; + describe('ExceptionsViewerItems', () => { it('it renders empty prompt if "showEmpty" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeTruthy(); @@ -43,7 +48,7 @@ describe('ExceptionsViewerItems', () => { it('it renders no search results found prompt if "showNoResults" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders exceptions if "showEmpty" and "isInitLoading" is "false", and exceptions exist', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { it('it does not render exceptions if "isInitLoading" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { exception2.id = 'newId'; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { exception2.id = 'newId'; const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { const mockOnDeleteException = jest.fn(); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { jest.fn(), ]); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { it('it renders empty prompt if no "exceptionListMeta" passed in', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { ]); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); const closeModal = jest.fn(); describe('rendering', () => { test('when isShowing is positive and request and response are not null', () => { const wrapper = mount( - + { describe('functionality from tab statistics/request/response', () => { test('Click on statistic Tab', () => { const wrapper = mount( - + { test('Click on request Tab', () => { const wrapper = mount( - + { test('Click on response Tab', () => { const wrapper = mount( - + { describe('events', () => { test('Make sure that toggle function has been called when you click on the close button', () => { const wrapper = mount( - + - +

My test supplement.

- } - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={ - Array [ - Object { - "numberOfRow": 2, - "text": "2 rows", - }, - Object { - "numberOfRow": 5, - "text": "5 rows", - }, - Object { - "numberOfRow": 10, - "text": "10 rows", - }, - Object { - "numberOfRow": 20, - "text": "20 rows", - }, - Object { - "numberOfRow": 50, - "text": "50 rows", - }, - ] - } - limit={1} - loadPage={[MockFunction]} - loading={false} - pageOfItems={ - Array [ - Object { - "cursor": Object { - "value": "98966fa2013c396155c460d35c0902be", - }, - "host": Object { - "_id": "cPsuhGcB0WOhS6qyTKC0", - "firstSeen": "2018-12-06T15:40:53.319Z", - "name": "elrond.elstc.co", - "os": "Ubuntu", - "version": "18.04.1 LTS (Bionic Beaver)", - }, - }, - Object { - "cursor": Object { - "value": "aa7ca589f1b8220002f2fc61c64cfbf1", - }, - "host": Object { - "_id": "KwQDiWcB0WOhS6qyXmrW", - "firstSeen": "2018-12-07T14:12:38.560Z", - "name": "siem-kibana", - "os": "Debian GNU/Linux", - "version": "9 (stretch)", - }, - }, - ] - } - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={[MockFunction]} - updateLimitPagination={[Function]} - /> - +
+ + + + + Rows per page: 1 + + } + closePopover={[Function]} + data-test-subj="loadingMoreSizeRowPopover" + display="inlineBlock" + hasArrow={true} + id="customizablePagination" + isOpen={false} + ownFocus={false} + panelPaddingSize="none" + repositionOnScroll={true} + > + + 2 rows + , + + 5 rows + , + + 10 rows + , + + 20 rows + , + + 50 rows + , + ] + } + /> + + + + + + + + `; diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx index 57d4c8451de245..c20f1ae66c7974 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx @@ -14,7 +14,6 @@ import { Direction } from '../../../graphql/types'; import { BasicTableProps, PaginatedTable } from './index'; import { getHostsColumns, mockData, rowItems, sortedHosts } from './index.mock'; import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; jest.mock('react', () => { const r = jest.requireActual('react'); @@ -22,8 +21,20 @@ jest.mock('react', () => { return { ...r, memo: (x: any) => x }; }); +const mockTheme = { + eui: { + euiColorEmptyShade: '#ece', + euiSizeL: '10px', + euiBreakpoints: { + s: '450px', + }, + paddingSizes: { + m: '10px', + }, + }, +}; + describe('Paginated Table Component', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let loadPage: jest.Mock; let updateLimitPagination: jest.Mock; let updateActivePage: jest.Mock; @@ -36,26 +47,24 @@ describe('Paginated Table Component', () => { describe('rendering', () => { test('it renders the default load more table', () => { const wrapper = shallow( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> -
+ {'My test supplement.'}

} + headerTitle="Hosts" + headerTooltip="My test tooltip" + headerUnit="Test Unit" + itemsPerRow={rowItems} + limit={1} + loading={false} + loadPage={loadPage} + pageOfItems={mockData.Hosts.edges} + showMorePagesIndicator={true} + totalCount={10} + updateActivePage={updateActivePage} + updateLimitPagination={(limit) => updateLimitPagination({ limit })} + /> ); expect(wrapper).toMatchSnapshot(); @@ -63,7 +72,7 @@ describe('Paginated Table Component', () => { test('it renders the loading panel at the beginning ', () => { const wrapper = mount( - + { test('it renders the over loading panel after data has been in the table ', () => { const wrapper = mount( - + { test('it renders the correct amount of pages and starts at activePage: 0', () => { const wrapper = mount( - + { test('it render popover to select new limit in table', () => { const wrapper = mount( - + { test('it will NOT render popover to select new limit in table if props itemsPerRow is empty', () => { const wrapper = mount( - + { test('It should render a sort icon if sorting is defined', () => { const mockOnChange = jest.fn(); const wrapper = mount( - + { test('Should display toast when user reaches end of results max', () => { const wrapper = mount( - + { test('Should show items per row if totalCount is greater than items', () => { const wrapper = mount( - + { test('Should hide items per row if totalCount is less than items', () => { const wrapper = mount( - + { describe('Events', () => { test('should call updateActivePage with 1 when clicking to the first page', () => { const wrapper = mount( - + { test('Should call updateActivePage with 0 when you pick a new limit', () => { const wrapper = mount( - + { // eslint-disable-next-line @typescript-eslint/no-explicit-any const ComponentWithContext = (props: BasicTableProps) => { return ( - + ); @@ -424,7 +433,7 @@ describe('Paginated Table Component', () => { test('Should call updateLimitPagination when you pick a new limit', () => { const wrapper = mount( - + { test('Should call onChange when you choose a new sort in the table', () => { const mockOnChange = jest.fn(); const wrapper = mount( - + { }); describe('Stat Items Component', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); + const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); const store = createStore( @@ -71,7 +70,7 @@ describe('Stat Items Component', () => { describe.each([ [ mount( - + { ], [ mount( - + { test('it renders entryItemIndexItemEntryFirstRowAndBadge for very first item', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -25,7 +30,7 @@ describe('AndBadgeComponent', () => { test('it renders entryItemEntryInvisibleAndBadge if "entriesLength" is 1 or less', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); @@ -37,7 +42,7 @@ describe('AndBadgeComponent', () => { test('it renders regular "and" badge if item is not the first one and includes more than one entry', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/index.test.tsx index 73174bc5fc113e..6aa33c3bcf4ca2 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/index.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { waitFor } from '@testing-library/react'; import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; @@ -19,6 +18,12 @@ import { ThreatMatchComponent } from './'; import { ThreatMapEntries } from './types'; import { IndexPattern } from 'src/plugins/data/public'; +const mockTheme = { + eui: { + euiColorLightShade: '#ece', + }, +}; + jest.mock('../../../common/lib/kibana'); const getPayLoad = (): ThreatMapEntries[] => [ @@ -51,7 +56,7 @@ describe('ThreatMatchComponent', () => { test('it displays empty entry if no "listItems" are passed in', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "Search" for "listItems" that are passed in', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "or", "and" enabled', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it adds an entry when "and" clicked', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it adds an item when "or" clicked', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it removes one row if user deletes a row', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it displays "and" badge if at least one item includes more than one entry', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it does not display "and" badge if none of the items include more than one entry', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + ({ @@ -66,7 +71,7 @@ describe('ListItemComponent', () => { describe('and badge logic', () => { test('it renders "and" badge with extra top padding for the first item when "andLogicIncluded" is "true"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders "and" badge when more than one item entry exists and it is not the first item', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders indented "and" badge when "andLogicIncluded" is "true" and only one entry exists', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders no "and" badge when "andLogicIncluded" is "false"', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders loader when isLoading is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); @@ -58,23 +54,21 @@ describe('PreviewCustomQueryHistogram', () => { test('it configures data and subtitle', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); @@ -111,19 +105,17 @@ describe('PreviewCustomQueryHistogram', () => { const mockRefetch = jest.fn(); mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(mockSetQuery).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx index 65bb029e2e32f5..df6a8975a5b974 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import * as i18n from './translations'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; @@ -35,19 +33,17 @@ describe('PreviewEqlQueryHistogram', () => { test('it renders loader when isLoading is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); @@ -58,23 +54,21 @@ describe('PreviewEqlQueryHistogram', () => { test('it configures data and subtitle', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); @@ -109,19 +103,17 @@ describe('PreviewEqlQueryHistogram', () => { const mockRefetch = jest.fn(); mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(mockSetQuery).toHaveBeenCalledWith({ @@ -134,23 +126,21 @@ describe('PreviewEqlQueryHistogram', () => { test('it displays histogram', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.test.tsx index d9bd32ce082caf..85e31e7ed36e56 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/histogram.test.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { TestProviders } from '../../../../common/mock'; import { PreviewHistogram } from './histogram'; @@ -17,19 +15,17 @@ import { getHistogramConfig } from './helpers'; describe('PreviewHistogram', () => { test('it renders loading icon if "isLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); @@ -38,40 +34,38 @@ describe('PreviewHistogram', () => { test('it renders chart if "isLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx index bb87242d9bf108..700c2d516b995d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { of } from 'rxjs'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { TestProviders } from '../../../../common/mock'; import { useKibana } from '../../../../common/lib/kibana'; @@ -18,6 +17,12 @@ import { getMockEqlResponse } from '../../../../common/hooks/eql/eql_search_resp import { useMatrixHistogram } from '../../../../common/containers/matrix_histogram'; import { useEqlPreview } from '../../../../common/hooks/eql/'; +const mockTheme = { + eui: { + euiSuperDatePickerWidth: '180px', + }, +}; + jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/containers/matrix_histogram'); jest.mock('../../../../common/hooks/eql/'); @@ -63,7 +68,7 @@ describe('PreviewQuery', () => { test('it renders timeframe select and preview button on render', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders preview button disabled if "isDisabled" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders preview button disabled if "query" is undefined', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders query histogram when rule type is query and preview button clicked', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders noise warning when rule type is query, timeframe is last hour and hit average is greater than 1/hour', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders query histogram when rule type is saved_query and preview button clicked', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders eql histogram when preview button clicked and rule type is eql', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders noise warning when rule type is eql, timeframe is last hour and hit average is greater than 1/hour', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders threshold histogram when preview button clicked, rule type is threshold, and threshold field is defined', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders noise warning when rule type is threshold, and threshold field is defined, timeframe is last hour and hit average is greater than 1/hour', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is not defined', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is empty string', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it hides histogram when timeframe changes', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> + { test('it renders loader when isLoading is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); @@ -51,20 +47,18 @@ describe('PreviewThresholdQueryHistogram', () => { test('it configures buckets data', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); @@ -86,20 +80,18 @@ describe('PreviewThresholdQueryHistogram', () => { const mockRefetch = jest.fn(); mount( - ({ eui: euiLightVars, darkMode: false })}> - - - - + + + ); expect(mockSetQuery).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx index 71670658c88a98..fc91c26148c172 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx @@ -7,8 +7,6 @@ import { mount } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { waitFor } from '@testing-library/react'; import { enableRules } from '../../../containers/detection_engine/rules'; @@ -34,9 +32,7 @@ describe('RuleSwitch', () => { test('it renders loader if "isLoading" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="ruleSwitchLoader"]').exists()).toBeTruthy(); @@ -45,42 +41,27 @@ describe('RuleSwitch', () => { test('it renders switch disabled if "isDisabled" is true', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="ruleSwitch"]').at(0).props().disabled).toBeTruthy(); }); test('it renders switch enabled if "enabled" is true', () => { - const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - - ); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="ruleSwitch"]').at(0).props().checked).toBeTruthy(); }); test('it renders switch disabled if "enabled" is false', () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); expect(wrapper.find('[data-test-subj="ruleSwitch"]').at(0).props().checked).toBeFalsy(); }); test('it renders an off switch enabled on click', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); @@ -96,9 +77,7 @@ describe('RuleSwitch', () => { (enableRules as jest.Mock).mockResolvedValue([rule]); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); @@ -113,14 +92,7 @@ describe('RuleSwitch', () => { (enableRules as jest.Mock).mockRejectedValue(mockError); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); @@ -138,14 +110,7 @@ describe('RuleSwitch', () => { ]); const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); @@ -157,15 +122,13 @@ describe('RuleSwitch', () => { test('it invokes "enableRulesAction" if dispatch is passed through', async () => { const wrapper = mount( - ({ eui: euiLightVars, darkMode: false })}> - - + ); wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx index edd5c0d4e6e4cc..c1773b2fffbab6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_light.json'; import { act } from '@testing-library/react'; import { stubIndexPattern } from 'src/plugins/data/common/index_patterns/index_pattern.stub'; @@ -24,8 +23,13 @@ import { } from '../../../pages/detection_engine/rules/types'; import { fillEmptySeverityMappings } from '../../../pages/detection_engine/rules/helpers'; +const mockTheme = { + eui: { + euiColorLightestShade: '#ece', + }, +}; + jest.mock('../../../../common/containers/source'); -const theme = () => ({ eui: euiDarkVars, darkMode: true }); jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); return { @@ -37,6 +41,7 @@ jest.mock('@elastic/eui', () => { }, }; }); + describe('StepAboutRuleComponent', () => { let formHook: RuleStepsFormHooks[RuleStep.aboutRule] | null = null; const setFormHook = ( @@ -72,7 +77,7 @@ describe('StepAboutRuleComponent', () => { it('is invalid if description is not present', async () => { const wrapper = mount( - + { it('is invalid if no "name" is present', async () => { const wrapper = mount( - + { it('is valid if both "name" and "description" are present', async () => { const wrapper = mount( - + { it('it allows user to set the risk score as a number (and not a string)', async () => { const wrapper = mount( - + { it('does not modify the provided risk score until the user changes the severity', async () => { const wrapper = mount( - + ({ eui: euiDarkVars, darkMode: true }); +const mockTheme = { + eui: { euiSizeL: '10px', euiBreakpoints: { s: '450px' }, paddingSizes: { m: '10px' } }, +}; describe('StepAboutRuleToggleDetails', () => { let mockRule: AboutStepRule; @@ -93,7 +94,7 @@ describe('StepAboutRuleToggleDetails', () => { describe('note value does exist', () => { test('it renders toggle buttons, defaulted to "details"', () => { const wrapper = mount( - + { test('it allows users to toggle between "details" and "note"', () => { const wrapper = mount( - + { test('it displays notes markdown when user toggles to "notes"', () => { const wrapper = mount( - + ({ eui: euiDarkVars, darkMode: true }); +const mockTheme = { eui: { euiBreakpoints: { l: '1200px' }, paddingSizes: { m: '10px' } } }; describe('AllRules', () => { it('renders AllRulesUtilityBar total rules and selected rules', () => { const wrapper = mount( - + { it('does not render total selected and bulk actions when "showBulkActions" is false', () => { const wrapper = mount( - + { it('renders utility actions if user has permissions', () => { const wrapper = mount( - + { it('renders no utility actions if user has no permissions', () => { const wrapper = mount( - + { it('invokes refresh on refresh action click', () => { const mockRefresh = jest.fn(); const wrapper = mount( - + { it('invokes onRefreshSwitch when auto refresh switch is clicked', async () => { const mockSwitch = jest.fn(); const wrapper = mount( - + ({ htmlIdGenerator: () => () => 'mockId', })); @@ -32,9 +37,7 @@ const now = 111111; const renderList = (store: ReturnType) => { const Wrapper: React.FC = ({ children }) => ( - ({ eui: euiLightVars, darkMode: false })}> - {children} - + {children} ); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap index 8f70c61ba4afc3..5a176018f0e3fe 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap @@ -486,12 +486,7 @@ exports[`TrustedAppsList renders correctly when failed loading data for the seco exports[`TrustedAppsList renders correctly when item details expanded 1`] = ` .c0 { - background-color: #f5f7fa; - padding: 16px; -} - -.c3 { - padding: 16px; + background-color: #ece; } .c1.c1.c1 { @@ -864,7 +859,7 @@ exports[`TrustedAppsList renders correctly when item details expanded 1`] = `
({ @@ -32,9 +37,7 @@ const now = 111111; const renderList = (store: ReturnType) => { const Wrapper: React.FC = ({ children }) => ( - ({ eui: euiLightVars, darkMode: false })}> - {children} - + {children} ); diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx index 4b62139a8679f5..0ec12a00d578bd 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx @@ -5,10 +5,8 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; import '../../../common/mock/match_media'; import '../../../common/mock/react_beautiful_dnd'; @@ -24,7 +22,6 @@ jest.mock('../../../common/containers/matrix_histogram', () => ({ useMatrixHistogram: jest.fn(), })); -const theme = () => ({ eui: { ...euiDarkVars, euiSizeL: '24px' }, darkMode: true }); const from = '2020-03-31T06:00:00.000Z'; const to = '2019-03-31T06:00:00.000Z'; @@ -55,11 +52,9 @@ describe('Alerts by category', () => { ]); wrapper = mount( - - - - - + + + ); await waitFor(() => { @@ -123,11 +118,9 @@ describe('Alerts by category', () => { ]); wrapper = mount( - - - - - + + + ); wrapper.update(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.test.tsx index 278e01bcd89230..0f7a2070b8ef42 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/categories_pane.test.tsx @@ -7,8 +7,6 @@ import { mount } from 'enzyme'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mockBrowserFields } from '../../../common/containers/source/mock'; @@ -19,18 +17,15 @@ import * as i18n from './translations'; const timelineId = 'test'; describe('CategoriesPane', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); test('it renders the expected title', () => { const wrapper = mount( - - - + ); expect(wrapper.find('[data-test-subj="categories-pane-title"]').first().text()).toEqual( @@ -40,15 +35,13 @@ describe('CategoriesPane', () => { test('it renders a "No fields match" message when filteredBrowserFields is empty', () => { const wrapper = mount( - - - + ); expect(wrapper.find('[data-test-subj="categories-container"] tbody').first().text()).toEqual( diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.test.tsx index 44b65185627ff8..7b00b768b56a0a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category_columns.test.tsx @@ -12,25 +12,20 @@ import { mockBrowserFields } from '../../../common/containers/source/mock'; import { CATEGORY_PANE_WIDTH, getFieldCount } from './helpers'; import { CategoriesPane } from './categories_pane'; -import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; const timelineId = 'test'; -const theme = () => ({ eui: euiDarkVars, darkMode: true }); describe('getCategoryColumns', () => { Object.keys(mockBrowserFields).forEach((categoryId) => { test(`it renders the ${categoryId} category name (from filteredBrowserFields)`, () => { const wrapper = mount( - - - + ); const fieldCount = Object.keys(mockBrowserFields[categoryId].fields ?? {}).length; @@ -44,15 +39,13 @@ describe('getCategoryColumns', () => { Object.keys(mockBrowserFields).forEach((categoryId) => { test(`it renders the correct field count for the ${categoryId} category (from filteredBrowserFields)`, () => { const wrapper = mount( - - - + ); expect( @@ -65,15 +58,13 @@ describe('getCategoryColumns', () => { const selectedCategoryId = 'auditd'; const wrapper = mount( - - - + ); expect( @@ -89,15 +80,13 @@ describe('getCategoryColumns', () => { const notTheSelectedCategoryId = 'base'; const wrapper = mount( - - - + ); expect( @@ -115,15 +104,13 @@ describe('getCategoryColumns', () => { const onCategorySelected = jest.fn(); const wrapper = mount( - - - + ); wrapper diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx index 8e754b3d04654c..0c611ca5106e86 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx @@ -5,12 +5,10 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep } from 'lodash/fp'; import moment from 'moment'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; -import { ThemeProvider } from 'styled-components'; import '../../../../common/mock/formatted_relative'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; @@ -18,7 +16,6 @@ import { OpenTimelineResult, TimelineResultNote } from '../types'; import { NotePreviews } from '.'; describe('NotePreviews', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; let note1updated: number; let note2updated: number; @@ -34,11 +31,7 @@ describe('NotePreviews', () => { test('it renders a note preview for each note when isModal is false', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); hasNotes[0].notes!.forEach(({ savedObjectId }) => { expect(wrapper.find(`[data-test-subj="note-preview-${savedObjectId}"]`).exists()).toBe(true); @@ -48,11 +41,7 @@ describe('NotePreviews', () => { test('it renders a note preview for each note when isModal is true', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); hasNotes[0].notes!.forEach(({ savedObjectId }) => { expect(wrapper.find(`[data-test-subj="note-preview-${savedObjectId}"]`).exists()).toBe(true); @@ -99,11 +88,7 @@ describe('NotePreviews', () => { }, ]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('.euiCommentEvent__headerUsername').at(1).text()).toEqual('bob'); }); @@ -130,11 +115,7 @@ describe('NotePreviews', () => { }, ]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); @@ -160,11 +141,7 @@ describe('NotePreviews', () => { }, ]; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx index 1aac1f21f2d504..0cf7f2891dfbfd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep } from 'lodash/fp'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -32,8 +31,19 @@ jest.mock('react-router-dom', () => { }; }); +const mockTheme = { + eui: { + euiSizeL: '10px', + paddingSizes: { + s: '10px', + }, + euiBreakpoints: { + l: '1200px', + }, + }, +}; + describe('OpenTimeline', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); const title = 'All Timelines / Open Timelines'; let mockResults: OpenTimelineResult[]; @@ -73,7 +83,7 @@ describe('OpenTimeline', () => { test('it renders the search row', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -84,7 +94,7 @@ describe('OpenTimeline', () => { test('it renders the timelines table', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -95,7 +105,7 @@ describe('OpenTimeline', () => { test('it shows the delete action columns when onDeleteSelected and deleteTimelines are specified', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -115,7 +125,7 @@ describe('OpenTimeline', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); @@ -135,7 +145,7 @@ describe('OpenTimeline', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); @@ -155,7 +165,7 @@ describe('OpenTimeline', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); @@ -174,7 +184,7 @@ describe('OpenTimeline', () => { query: '', }; const wrapper = mountWithIntl( - + ); @@ -188,7 +198,7 @@ describe('OpenTimeline', () => { query: ' ', }; const wrapper = mountWithIntl( - + ); @@ -202,7 +212,7 @@ describe('OpenTimeline', () => { query: 'Would you like to go to Denver?', }; const wrapper = mountWithIntl( - + ); @@ -218,7 +228,7 @@ describe('OpenTimeline', () => { query: ' Is it starting to feel cramped in here? ', }; const wrapper = mountWithIntl( - + ); @@ -234,7 +244,7 @@ describe('OpenTimeline', () => { query: '', }; const wrapper = mountWithIntl( - + ); @@ -250,7 +260,7 @@ describe('OpenTimeline', () => { query: ' ', }; const wrapper = mountWithIntl( - + ); @@ -266,7 +276,7 @@ describe('OpenTimeline', () => { query: 'How was your day?', }; const wrapper = mountWithIntl( - + ); @@ -282,7 +292,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.active, }; const wrapper = mountWithIntl( - + ); @@ -297,7 +307,7 @@ describe('OpenTimeline', () => { selectedItems: [], }; const wrapper = mountWithIntl( - + ); @@ -317,7 +327,7 @@ describe('OpenTimeline', () => { selectedItems: [], }; const wrapper = mountWithIntl( - + ); @@ -337,7 +347,7 @@ describe('OpenTimeline', () => { selectedItems: [{}], }; const wrapper = mountWithIntl( - + ); @@ -357,7 +367,7 @@ describe('OpenTimeline', () => { selectedItems: [{}], }; const wrapper = mountWithIntl( - + ); @@ -376,7 +386,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.active, }; const wrapper = mountWithIntl( - + ); @@ -392,7 +402,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.active, }; const wrapper = mountWithIntl( - + ); @@ -406,7 +416,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.immutable, }; const wrapper = mountWithIntl( - + ); @@ -420,7 +430,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.immutable, }; const wrapper = mountWithIntl( - + ); @@ -436,7 +446,7 @@ describe('OpenTimeline', () => { timelineStatus: TimelineStatus.immutable, }; const wrapper = mountWithIntl( - + ); @@ -450,7 +460,7 @@ describe('OpenTimeline', () => { timelineStatus: null, }; const wrapper = mountWithIntl( - + ); @@ -464,7 +474,7 @@ describe('OpenTimeline', () => { timelineStatus: null, }; const wrapper = mountWithIntl( - + ); @@ -480,7 +490,7 @@ describe('OpenTimeline', () => { timelineStatus: null, }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx index 5babecb3acb699..38186d35d2d2da 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep } from 'lodash/fp'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -23,7 +22,14 @@ import { TimelineType, TimelineStatus } from '../../../../../common/types/timeli jest.mock('../../../../common/lib/kibana'); describe('OpenTimelineModal', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); + const mockTheme = { + eui: { + euiColorMediumShade: '#ece', + euiBreakpoints: { + s: '500px', + }, + }, + }; const title = 'All Timelines / Open Timelines'; let mockResults: OpenTimelineResult[]; @@ -62,7 +68,7 @@ describe('OpenTimelineModal', () => { test('it renders the title row', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -73,7 +79,7 @@ describe('OpenTimelineModal', () => { test('it renders the search row', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -84,7 +90,7 @@ describe('OpenTimelineModal', () => { test('it renders the timelines table', () => { const defaultProps = getDefaultTestProps(mockResults); const wrapper = mountWithIntl( - + ); @@ -99,7 +105,7 @@ describe('OpenTimelineModal', () => { deleteTimelines: jest.fn(), }; const wrapper = mountWithIntl( - + ); @@ -119,7 +125,7 @@ describe('OpenTimelineModal', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); @@ -139,7 +145,7 @@ describe('OpenTimelineModal', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); @@ -159,7 +165,7 @@ describe('OpenTimelineModal', () => { deleteTimelines: undefined, }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx index 837dcbe1d6bfd2..62cdda6070b322 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx @@ -5,11 +5,9 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount } from 'enzyme'; import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; -import { ThemeProvider } from 'styled-components'; import { waitFor } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock/test_providers'; @@ -19,8 +17,6 @@ import * as i18n from '../translations'; import { OpenTimelineModalButton } from './open_timeline_modal_button'; describe('OpenTimelineModalButton', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - test('it renders the expected button text', async () => { const wrapper = mount( @@ -43,13 +39,11 @@ describe('OpenTimelineModalButton', () => { test('it invokes onClick function provided as a prop when the button is clicked', async () => { const onClick = jest.fn(); const wrapper = mount( - - - - - - - + + + + + ); await waitFor(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx index e0b252e112fc6f..d75823b7716812 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.test.tsx @@ -6,7 +6,6 @@ */ import { EuiFilterButtonProps } from '@elastic/eui'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; import { ThemeProvider } from 'styled-components'; @@ -17,12 +16,16 @@ import { SearchRow } from '.'; import * as i18n from '../translations'; -describe('SearchRow', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); +const mockTheme = { + eui: { + euiSizeL: '10px', + }, +}; +describe('SearchRow', () => { test('it renders a search input with the expected placeholder when the query is empty', () => { const wrapper = mountWithIntl( - + { describe('Only Favorites Button', () => { test('it renders the expected button text', () => { const wrapper = mountWithIntl( - + { const onToggleOnlyFavorites = jest.fn(); const wrapper = mountWithIntl( - + { test('it sets the button to the toggled state when onlyFavorites is true', () => { const wrapper = mountWithIntl( - + { test('it sets the button to the NON-toggled state when onlyFavorites is false', () => { const wrapper = mountWithIntl( - + { test('it invokes onQueryChange when the user enters a query', () => { const wrapper = mountWithIntl( - + { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; beforeEach(() => { @@ -37,7 +37,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['delete'], }; const wrapper = mountWithIntl( - + ); @@ -51,7 +51,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: [], }; const wrapper = mountWithIntl( - + ); @@ -65,7 +65,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['duplicate'], }; const wrapper = mountWithIntl( - + ); @@ -79,7 +79,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['duplicate'], }; const wrapper = mountWithIntl( - + ); @@ -93,7 +93,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: [], }; const wrapper = mountWithIntl( - + ); @@ -107,7 +107,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['delete'], }; const wrapper = mountWithIntl( - + ); @@ -124,7 +124,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(missingSavedObjectId), }; const wrapper = mountWithIntl( - + ); @@ -141,7 +141,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -161,7 +161,7 @@ describe('#getActionsColumns', () => { onOpenTimeline, }; const wrapper = mountWithIntl( - + ); @@ -177,7 +177,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['export'], }; const wrapper = mountWithIntl( - + ); @@ -189,7 +189,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -206,7 +206,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['export'], }; const wrapper = mountWithIntl( - + ); @@ -226,7 +226,7 @@ describe('#getActionsColumns', () => { enableExportTimelineDownloader, }; const wrapper = mountWithIntl( - + ); @@ -242,7 +242,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['createFrom', 'duplicate'], }; const wrapper = mountWithIntl( - + ); @@ -256,7 +256,7 @@ describe('#getActionsColumns', () => { actionTimelineToShow: ['createFrom', 'duplicate'], }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx index 3108dd09ea6873..3c70cc70a66de9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx @@ -6,7 +6,6 @@ */ import { EuiButtonIconProps } from '@elastic/eui'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep, omit } from 'lodash/fp'; import React from 'react'; import { ThemeProvider } from 'styled-components'; @@ -23,10 +22,11 @@ import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; import { getMockTimelinesTableProps } from './mocks'; +const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; + jest.mock('../../../../common/lib/kibana'); describe('#getCommonColumns', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; beforeEach(() => { @@ -40,11 +40,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(hasNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(true); }); @@ -55,11 +51,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(missingNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -70,11 +62,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(nullNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -85,11 +73,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(emptylNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -101,11 +85,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(missingSavedObjectId), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -116,11 +96,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(nullSavedObjectId), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); }); @@ -131,11 +107,7 @@ describe('#getCommonColumns', () => { const testProps: TimelinesTableProps = { ...getMockTimelinesTableProps(hasNotes), }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); const props = wrapper .find('[data-test-subj="expand-notes"]') @@ -156,11 +128,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(hasNotes), itemIdToExpandedNotesRowMap, }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); const props = wrapper .find('[data-test-subj="expand-notes"]') @@ -184,11 +152,7 @@ describe('#getCommonColumns', () => { itemIdToExpandedNotesRowMap, onToggleShowNotes, }; - const wrapper = mountWithIntl( - - - - ); + const wrapper = mountWithIntl(); wrapper.find('[data-test-subj="expand-notes"]').first().simulate('click'); @@ -214,7 +178,7 @@ describe('#getCommonColumns', () => { onToggleShowNotes, }; const wrapper = mountWithIntl( - + ); @@ -233,7 +197,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -246,7 +210,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -265,7 +229,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(missingSavedObjectId), }; const wrapper = mountWithIntl( - + ); @@ -285,7 +249,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(missingTitle), }; const wrapper = mountWithIntl( - + ); @@ -304,7 +268,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(withMissingSavedObjectIdAndTitle), }; const wrapper = mountWithIntl( - + ); @@ -323,7 +287,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(withJustWhitespaceTitle), }; const wrapper = mountWithIntl( - + ); @@ -345,7 +309,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(withMissingSavedObjectId), }; const wrapper = mountWithIntl( - + ); @@ -360,7 +324,7 @@ describe('#getCommonColumns', () => { test('it renders a hyperlink when the timeline has a saved object id', () => { const wrapper = mountWithIntl( - + ); @@ -379,7 +343,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(missingSavedObjectId), }; const wrapper = mountWithIntl( - + ); @@ -397,7 +361,7 @@ describe('#getCommonColumns', () => { onOpenTimeline, }; const wrapper = mountWithIntl( - + ); @@ -417,7 +381,7 @@ describe('#getCommonColumns', () => { describe('Description column', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -427,7 +391,7 @@ describe('#getCommonColumns', () => { test('it renders the description when the timeline has a description', () => { const wrapper = mountWithIntl( - + ); @@ -441,7 +405,7 @@ describe('#getCommonColumns', () => { const missingDescription: OpenTimelineResult[] = [omit('description', { ...mockResults[0] })]; const wrapper = mountWithIntl( - + ); @@ -459,7 +423,7 @@ describe('#getCommonColumns', () => { ...getMockTimelinesTableProps(justWhitespaceDescription), }; const wrapper = mountWithIntl( - + ); @@ -472,7 +436,7 @@ describe('#getCommonColumns', () => { describe('Last Modified column', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -482,7 +446,7 @@ describe('#getCommonColumns', () => { test('it renders the last modified (updated) date when the timeline has an updated property', () => { const wrapper = mountWithIntl( - + ); @@ -497,7 +461,7 @@ describe('#getCommonColumns', () => { const missingUpdated: OpenTimelineResult[] = [omit('updated', { ...mockResults[0] })]; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx index 66556296c42ac8..83e21267bce287 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep, omit } from 'lodash/fp'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -21,10 +20,11 @@ import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; import { getMockTimelinesTableProps } from './mocks'; +const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; + jest.mock('../../../../common/lib/kibana'); describe('#getExtendedColumns', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; beforeEach(() => { @@ -37,7 +37,7 @@ describe('#getExtendedColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -50,7 +50,7 @@ describe('#getExtendedColumns', () => { ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( - + ); @@ -66,7 +66,7 @@ describe('#getExtendedColumns', () => { ...getMockTimelinesTableProps(missingUpdatedBy), }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx index c3681753c7732b..a8ed5f02fa3ef4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep, omit } from 'lodash/fp'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -16,10 +15,12 @@ import { mockTimelineResults } from '../../../../common/mock/timeline_results'; import { TimelinesTable, TimelinesTableProps } from '.'; import { OpenTimelineResult } from '../types'; import { getMockTimelinesTableProps } from './mocks'; + +const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; + jest.mock('../../../../common/lib/kibana'); describe('#getActionsColumns', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; beforeEach(() => { @@ -28,7 +29,7 @@ describe('#getActionsColumns', () => { test('it renders the pinned events header icon', () => { const wrapper = mountWithIntl( - + ); @@ -42,7 +43,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(with6Events), }; const wrapper = mountWithIntl( - + ); @@ -52,7 +53,7 @@ describe('#getActionsColumns', () => { test('it renders the notes count header icon', () => { const wrapper = mountWithIntl( - + ); @@ -66,7 +67,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(with4Notes), }; const wrapper = mountWithIntl( - + ); @@ -76,7 +77,7 @@ describe('#getActionsColumns', () => { test('it renders the favorites header icon', () => { const wrapper = mountWithIntl( - + ); @@ -90,7 +91,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(undefinedFavorite), }; const wrapper = mountWithIntl( - + ); @@ -104,7 +105,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(nullFavorite), }; const wrapper = mountWithIntl( - + ); @@ -118,7 +119,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(emptyFavorite), }; const wrapper = mountWithIntl( - + ); @@ -143,7 +144,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(favorite), }; const wrapper = mountWithIntl( - + ); @@ -172,7 +173,7 @@ describe('#getActionsColumns', () => { ...getMockTimelinesTableProps(favorite), }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx index 2d5949ae41125d..01a855524ac0d8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { cloneDeep } from 'lodash/fp'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -19,10 +18,11 @@ import { getMockTimelinesTableProps } from './mocks'; import * as i18n from '../translations'; +const mockTheme = { eui: { euiColorMediumShade: '#ece' } }; + jest.mock('../../../../common/lib/kibana'); describe('TimelinesTable', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; beforeEach(() => { @@ -31,7 +31,7 @@ describe('TimelinesTable', () => { test('it renders the select all timelines header checkbox when actionTimelineToShow has the action selectable', () => { const wrapper = mountWithIntl( - + ); @@ -45,7 +45,7 @@ describe('TimelinesTable', () => { actionTimelineToShow: ['delete', 'duplicate'], }; const wrapper = mountWithIntl( - + ); @@ -59,7 +59,7 @@ describe('TimelinesTable', () => { showExtendedColumns: true, }; const wrapper = mountWithIntl( - + ); @@ -73,7 +73,7 @@ describe('TimelinesTable', () => { showExtendedColumns: false, }; const wrapper = mountWithIntl( - + ); @@ -90,7 +90,7 @@ describe('TimelinesTable', () => { test('it renders the delete timeline (trash icon) when actionTimelineToShow has the delete action', () => { const wrapper = mountWithIntl( - + ); @@ -104,7 +104,7 @@ describe('TimelinesTable', () => { actionTimelineToShow: ['duplicate', 'selectable'], }; const wrapper = mountWithIntl( - + ); @@ -114,7 +114,7 @@ describe('TimelinesTable', () => { test('it renders the rows per page selector when showExtendedColumns is true', () => { const wrapper = mountWithIntl( - + ); @@ -128,7 +128,7 @@ describe('TimelinesTable', () => { showExtendedColumns: false, }; const wrapper = mountWithIntl( - + ); @@ -144,7 +144,7 @@ describe('TimelinesTable', () => { pageSize: defaultPageSize, }; const wrapper = mountWithIntl( - + ); @@ -156,7 +156,7 @@ describe('TimelinesTable', () => { test('it sorts the Last Modified column in descending order when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( - + ); @@ -170,7 +170,7 @@ describe('TimelinesTable', () => { showExtendedColumns: false, }; const wrapper = mountWithIntl( - + ); @@ -184,7 +184,7 @@ describe('TimelinesTable', () => { searchResults: [], }; const wrapper = mountWithIntl( - + ); @@ -199,7 +199,7 @@ describe('TimelinesTable', () => { onTableChange, }; const wrapper = mountWithIntl( - + ); @@ -221,7 +221,7 @@ describe('TimelinesTable', () => { onSelectionChange, }; const wrapper = mountWithIntl( - + ); @@ -242,7 +242,7 @@ describe('TimelinesTable', () => { loading: true, }; const wrapper = mountWithIntl( - + ); @@ -257,7 +257,7 @@ describe('TimelinesTable', () => { test('it disables the table loading animation when isLoading is false', () => { const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx index 5621a2287f3a21..4661f72901eb6e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { EuiButtonProps } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; @@ -13,13 +12,16 @@ import { ThemeProvider } from 'styled-components'; import { TitleRow } from '.'; +const mockTheme = { + eui: { euiSizeS: '10px', euiLineHeight: '20px', euiBreakpoints: { s: '10px' }, euiSize: '10px' }, +}; + describe('TitleRow', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); const title = 'All Timelines / Open Timelines'; test('it renders the title', () => { const wrapper = mountWithIntl( - + ); @@ -30,7 +32,7 @@ describe('TitleRow', () => { describe('Favorite Selected button', () => { test('it renders the Favorite Selected button when onAddTimelinesToFavorites is provided', () => { const wrapper = mountWithIntl( - + { test('it does NOT render the Favorite Selected button when onAddTimelinesToFavorites is NOT provided', () => { const wrapper = mountWithIntl( - + ); @@ -54,7 +56,7 @@ describe('TitleRow', () => { test('it disables the Favorite Selected button when the selectedTimelinesCount is 0', () => { const wrapper = mountWithIntl( - + { test('it enables the Favorite Selected button when the selectedTimelinesCount is greater than 0', () => { const wrapper = mountWithIntl( - + { const onAddTimelinesToFavorites = jest.fn(); const wrapper = mountWithIntl( - + { data: mockDatum, timelineId: 'test', }); - const wrapper = mount( - ({ eui: euiDarkVars, darkMode: true })}> - {children} - - ); + const wrapper = mount({children}); expect(wrapper.text()).toEqual(''); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/unknown_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/unknown_column_renderer.test.tsx index d98a724ebf9cb5..6d7a9e5aecfd97 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/unknown_column_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/unknown_column_renderer.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { mount, shallow } from 'enzyme'; import { cloneDeep } from 'lodash'; import React from 'react'; @@ -17,8 +16,13 @@ import { getEmptyValue } from '../../../../../common/components/empty_value'; import { unknownColumnRenderer } from './unknown_column_renderer'; import { getValues } from './helpers'; +const mockTheme = { + eui: { + euiColorMediumShade: '#ece', + }, +}; + describe('unknown_column_renderer', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockDatum: TimelineNonEcsData[]; const _id = mockTimelineData[0]._id; beforeEach(() => { @@ -50,7 +54,7 @@ describe('unknown_column_renderer', () => { timelineId: 'test', }); const wrapper = mount( - + {emptyColumn} ); @@ -66,7 +70,7 @@ describe('unknown_column_renderer', () => { timelineId: 'test', }); const wrapper = mount( - + {emptyColumn} ); From 6c96fbbddc7e9dc957220e4693fa0e74c0d4d4d1 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Thu, 25 Feb 2021 16:21:49 +0000 Subject: [PATCH 07/40] [Security Solution] Update wordings and breadcrumb for timelines page (#90809) * update wordings * unit tests * remove tabName from breadcrumbs * update wordings * change default status of timelines to null * fix loading message * update select timeline wording Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../navigation/breadcrumbs/index.ts | 5 +-- .../components/recent_timelines/index.tsx | 2 +- .../delete_timeline_modal.test.tsx | 38 ++++++++++++++++++- .../delete_timeline_modal.tsx | 10 ++++- .../delete_timeline_modal/index.test.tsx | 14 +++++++ .../open_timeline/open_timeline.tsx | 8 ++-- .../open_timeline/timelines_table/index.tsx | 19 ++++++++-- .../components/open_timeline/translations.ts | 29 +++++++++++--- .../components/open_timeline/types.ts | 6 +-- .../timeline/selectable_timeline/index.tsx | 6 +-- .../public/timelines/containers/all/index.tsx | 9 +++-- .../public/timelines/pages/index.tsx | 36 ++++-------------- .../public/timelines/pages/translations.ts | 2 +- 13 files changed, 126 insertions(+), 58 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index 5cea37347b565c..571cef8f6c4844 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -146,10 +146,7 @@ export const getBreadcrumbsForRoute = ( } if (isTimelinesRoutes(spyState) && object.navTabs) { const tempNav: SearchNavTab = { urlKey: 'timeline', isDetailPage: false }; - let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; - if (spyState.tabName != null) { - urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; - } + const urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; return [ siemRootBreadcrumb, diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx index ae8a00d2b4aa01..004e675cb35165 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx @@ -113,7 +113,7 @@ const StatefulRecentTimelinesComponent: React.FC = ({ apolloClient, filte )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx index 49f68281ae1033..c130ea4c968143 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx @@ -7,12 +7,26 @@ import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; +import { useParams } from 'react-router-dom'; import { DeleteTimelineModal } from './delete_timeline_modal'; import * as i18n from '../translations'; +import { TimelineType } from '../../../../../common/types/timeline'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useParams: jest.fn(), + }; +}); describe('DeleteTimelineModal', () => { + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.default }); + }); + test('it renders the expected title when a timeline is selected', () => { const wrapper = mountWithIntl( { /> ); - expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual(i18n.DELETE_WARNING); + expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual( + i18n.DELETE_TIMELINE_WARNING + ); }); test('it invokes closeModal when the Cancel button is clicked', () => { @@ -115,3 +131,23 @@ describe('DeleteTimelineModal', () => { expect(onDelete).toBeCalled(); }); }); + +describe('DeleteTimelineTemplateModal', () => { + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.template }); + }); + + test('it renders a deletion warning', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual( + i18n.DELETE_TIMELINE_TEMPLATE_WARNING + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 55386104878990..f0efda65285076 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -10,7 +10,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback } from 'react'; import { isEmpty } from 'lodash/fp'; +import { useParams } from 'react-router-dom'; import * as i18n from '../translations'; +import { TimelineType } from '../../../../../common/types/timeline'; interface Props { title?: string | null; @@ -24,6 +26,12 @@ export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px * Renders a modal that confirms deletion of a timeline */ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDelete }) => { + const { tabName } = useParams<{ tabName: TimelineType }>(); + const warning = + tabName === TimelineType.template + ? i18n.DELETE_TIMELINE_TEMPLATE_WARNING + : i18n.DELETE_TIMELINE_WARNING; + const getTitle = useCallback(() => { const trimmedTitle = title != null ? title.trim() : ''; const titleResult = !isEmpty(trimmedTitle) ? trimmedTitle : i18n.UNTITLED_TIMELINE; @@ -48,7 +56,7 @@ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDel onConfirm={onDelete} title={getTitle()} > -
{i18n.DELETE_WARNING}
+
{warning}
); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx index 18a6ffc06941cf..cfbc7d255062f7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -7,8 +7,18 @@ import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; +import { useParams } from 'react-router-dom'; import { DeleteTimelineModalOverlay } from '.'; +import { TimelineType } from '../../../../../common/types/timeline'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useParams: jest.fn(), + }; +}); describe('DeleteTimelineModal', () => { const savedObjectId = 'abcd'; @@ -20,6 +30,10 @@ describe('DeleteTimelineModal', () => { title: 'Privilege Escalation', }; + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.default }); + }); + describe('showModalState', () => { test('it does NOT render the modal when isModalOpen is false', () => { const testProps = { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index 24b0702770d3c1..5a3da748bea1d0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -122,9 +122,9 @@ export const OpenTimeline = React.memo( const onRefreshBtnClick = useCallback(() => { if (refetch != null) { - refetch(searchResults, totalSearchResultsCount); + refetch(); } - }, [refetch, searchResults, totalSearchResultsCount]); + }, [refetch]); const handleCloseModal = useCallback(() => { if (setImportDataModalToggle != null) { @@ -137,9 +137,9 @@ export const OpenTimeline = React.memo( setImportDataModalToggle(false); } if (refetch != null) { - refetch(searchResults, totalSearchResultsCount); + refetch(); } - }, [setImportDataModalToggle, refetch, searchResults, totalSearchResultsCount]); + }, [setImportDataModalToggle, refetch]); const actionTimelineToShow = useMemo(() => { const timelineActions: ActionTimelineToShow[] = ['createFrom', 'duplicate']; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index 8c553bb95e9bd5..c1b30f3e68cf41 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -25,7 +25,11 @@ import { getActionsColumns } from './actions_columns'; import { getCommonColumns } from './common_columns'; import { getExtendedColumns } from './extended_columns'; import { getIconHeaderColumns } from './icon_header_columns'; -import { TimelineTypeLiteralWithNull, TimelineStatus } from '../../../../../common/types/timeline'; +import { + TimelineTypeLiteralWithNull, + TimelineStatus, + TimelineType, +} from '../../../../../common/types/timeline'; // there are a number of type mismatches across this file const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any @@ -103,7 +107,7 @@ export interface TimelinesTableProps { onToggleShowNotes: OnToggleShowNotes; pageIndex: number; pageSize: number; - searchResults: OpenTimelineResult[]; + searchResults: OpenTimelineResult[] | null; showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; @@ -196,6 +200,13 @@ export const TimelinesTable = React.memo( ] ); + const noItemsMessage = + isLoading || searchResults == null + ? i18n.LOADING + : timelineType === TimelineType.template + ? i18n.ZERO_TIMELINE_TEMPLATES_MATCH + : i18n.ZERO_TIMELINES_MATCH; + return ( ( isSelectable={actionTimelineToShow.includes('selectable')} itemId="savedObjectId" itemIdToExpandedRowMap={itemIdToExpandedNotesRowMap} - items={searchResults} + items={searchResults ?? []} loading={isLoading} - noItemsMessage={i18n.ZERO_TIMELINES_MATCH} + noItemsMessage={noItemsMessage} onChange={onTableChange} pagination={pagination} selection={actionTimelineToShow.includes('selectable') ? selection : undefined} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts index ae743ad30eef1c..4858bf3ed60838 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts @@ -33,13 +33,21 @@ export const DELETE_SELECTED = i18n.translate( } ); -export const DELETE_WARNING = i18n.translate( +export const DELETE_TIMELINE_WARNING = i18n.translate( 'xpack.securitySolution.open.timeline.deleteWarningLabel', { defaultMessage: 'You will not be able to recover this timeline or its notes once deleted.', } ); +export const DELETE_TIMELINE_TEMPLATE_WARNING = i18n.translate( + 'xpack.securitySolution.open.timeline.deleteTemplateWarningLabel', + { + defaultMessage: + 'You will not be able to recover this timeline template or its notes once deleted.', + } +); + export const DESCRIPTION = i18n.translate( 'xpack.securitySolution.open.timeline.descriptionTableHeader', { @@ -204,6 +212,10 @@ export const WITH = i18n.translate('xpack.securitySolution.open.timeline.withLab defaultMessage: 'with', }); +export const LOADING = i18n.translate('xpack.securitySolution.open.timeline.loadingLabel', { + defaultMessage: 'Loading...', +}); + export const ZERO_TIMELINES_MATCH = i18n.translate( 'xpack.securitySolution.open.timeline.zeroTimelinesMatchLabel', { @@ -211,6 +223,13 @@ export const ZERO_TIMELINES_MATCH = i18n.translate( } ); +export const ZERO_TIMELINE_TEMPLATES_MATCH = i18n.translate( + 'xpack.securitySolution.open.timeline.zeroTimelineTemplatesMatchLabel', + { + defaultMessage: '0 timeline templates match the search criteria', + } +); + export const SINGLE_TIMELINE = i18n.translate( 'xpack.securitySolution.open.timeline.singleTimelineLabel', { @@ -305,14 +324,14 @@ export const FILTER_CUSTOM_TIMELINES = i18n.translate( export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importTimelineTitle', { - defaultMessage: 'Import timeline', + defaultMessage: 'Import', } ); export const SELECT_TIMELINE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.selectTimelineDescription', { - defaultMessage: 'Select a Security timeline (as exported from the Timeline view) to import', + defaultMessage: 'Select a timeline or timeline template file to import', } ); @@ -343,14 +362,14 @@ export const SUCCESSFULLY_IMPORTED_TIMELINES = (totalCount: number) => export const IMPORT_FAILED = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importFailedTitle', { - defaultMessage: 'Failed to import timelines', + defaultMessage: 'Failed to import', } ); export const IMPORT_TIMELINE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importTitle', { - defaultMessage: 'Import timeline…', + defaultMessage: 'Import…', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index ad62bda4c9783d..47e1da2d240eaa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -167,9 +167,9 @@ export interface OpenTimelineProps { /** The currently applied search criteria */ query: string; /** Refetch table */ - refetch?: (existingTimeline?: OpenTimelineResult[], existingCount?: number) => void; - /** The results of executing a search */ - searchResults: OpenTimelineResult[]; + refetch?: () => void; + /** The results of executing a search, null is the status before data fatched */ + searchResults: OpenTimelineResult[] | null; /** the currently-selected timelines in the table */ selectedItems: OpenTimelineResult[]; /** Toggle export timelines modal*/ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx index d4de77e04e9f7b..7ccce80bbe9a4b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx @@ -222,7 +222,7 @@ const SelectableTimelineComponent: React.FC = ({ windowProps: { onScroll: ({ scrollOffset }) => handleOnScroll( - timelines.filter((t) => !hideUntitled || t.title !== '').length, + (timelines ?? []).filter((t) => !hideUntitled || t.title !== '').length, timelineCount, scrollOffset ), @@ -254,7 +254,7 @@ const SelectableTimelineComponent: React.FC = ({ = ({ searchProps={searchProps} singleSelection={true} options={getSelectableOptions({ - timelines, + timelines: timelines ?? [], onlyFavorites, searchTimelineValue, timelineType, diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx index b14ccbd3193991..82b41a95bd5371 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx @@ -38,7 +38,7 @@ export interface AllTimelinesArgs { status, timelineType, }: AllTimelinesVariables) => void; - timelines: OpenTimelineResult[]; + timelines: OpenTimelineResult[] | null; loading: boolean; totalCount: number; customTemplateTimelineCount: number; @@ -105,7 +105,7 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { const [allTimelines, setAllTimelines] = useState>({ loading: false, totalCount: 0, - timelines: [], + timelines: null, // use null as initial state to distinguish between empty result and haven't started loading. customTemplateTimelineCount: 0, defaultTimelineCount: 0, elasticTemplateTimelineCount: 0, @@ -128,7 +128,10 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { const fetchData = async () => { try { if (apolloClient != null) { - setAllTimelines((prevState) => ({ ...prevState, loading: true })); + setAllTimelines((prevState) => ({ + ...prevState, + loading: true, + })); const variables: GetAllTimeline.Variables = { onlyUserFavorite, diff --git a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx index 53ea28832f47f0..806ac57df1f65c 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx @@ -12,7 +12,6 @@ import { Switch, Route, useHistory } from 'react-router-dom'; import { ChromeBreadcrumb } from '../../../../../../src/core/public'; import { TimelineType } from '../../../common/types/timeline'; -import { TAB_TIMELINES, TAB_TEMPLATES } from '../components/open_timeline/translations'; import { TimelineRouteSpyState } from '../../common/utils/route/types'; import { TimelinesPage } from './timelines_page'; @@ -25,37 +24,18 @@ import { SecurityPageName } from '../../app/types'; const timelinesPagePath = `/:tabName(${TimelineType.default}|${TimelineType.template})`; const timelinesDefaultPath = `/${TimelineType.default}`; -const TabNameMappedToI18nKey: Record = { - [TimelineType.default]: TAB_TIMELINES, - [TimelineType.template]: TAB_TEMPLATES, -}; - export const getBreadcrumbs = ( params: TimelineRouteSpyState, search: string[], getUrlForApp: GetUrlForApp -): ChromeBreadcrumb[] => { - let breadcrumb = [ - { - text: PAGE_TITLE, - href: getUrlForApp(`${APP_ID}:${SecurityPageName.timelines}`, { - path: !isEmpty(search[0]) ? search[0] : '', - }), - }, - ]; - - const tabName = params?.tabName; - if (!tabName) return breadcrumb; - - breadcrumb = [ - ...breadcrumb, - { - text: TabNameMappedToI18nKey[tabName], - href: '', - }, - ]; - return breadcrumb; -}; +): ChromeBreadcrumb[] => [ + { + text: PAGE_TITLE, + href: getUrlForApp(`${APP_ID}:${SecurityPageName.timelines}`, { + path: !isEmpty(search[0]) ? search[0] : '', + }), + }, +]; export const Timelines = React.memo(() => { const history = useHistory(); diff --git a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts index f3bff987856196..199fc27c2663aa 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts @@ -21,7 +21,7 @@ export const ALL_TIMELINES_PANEL_TITLE = i18n.translate( export const ALL_TIMELINES_IMPORT_TIMELINE_TITLE = i18n.translate( 'xpack.securitySolution.timelines.allTimelines.importTimelineTitle', { - defaultMessage: 'Import Timeline', + defaultMessage: 'Import', } ); From a3760ce9884972618fd220fc6dceccdd974cf43d Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 25 Feb 2021 18:30:02 +0200 Subject: [PATCH 08/40] [Security Solution][Case] Improve hooks (#89580) --- .../jira/use_get_fields_by_issue_type.tsx | 25 ++- .../connectors/jira/use_get_issue_types.tsx | 30 +-- .../connectors/jira/use_get_issues.tsx | 26 +-- .../connectors/jira/use_get_single_issue.tsx | 20 +- .../resilient/use_get_incident_types.tsx | 26 +-- .../connectors/resilient/use_get_severity.tsx | 28 +-- .../connectors/servicenow/use_get_choices.tsx | 26 +-- .../containers/configure/use_action_types.tsx | 20 +- .../containers/configure/use_configure.tsx | 197 +++++++++--------- .../containers/configure/use_connectors.tsx | 49 +++-- .../cases/containers/use_bulk_update_case.tsx | 83 ++++---- .../cases/containers/use_delete_cases.tsx | 65 +++--- .../containers/use_get_action_license.tsx | 65 +++--- .../public/cases/containers/use_get_case.tsx | 47 ++--- .../containers/use_get_case_user_actions.tsx | 101 ++++----- .../public/cases/containers/use_get_cases.tsx | 121 +++++------ .../cases/containers/use_get_cases_status.tsx | 67 +++--- .../cases/containers/use_get_reporters.tsx | 74 +++---- .../public/cases/containers/use_get_tags.tsx | 48 +++-- .../public/cases/containers/use_post_case.tsx | 48 +++-- .../cases/containers/use_post_comment.tsx | 42 ++-- .../containers/use_post_push_to_service.tsx | 24 ++- .../cases/containers/use_update_case.tsx | 26 +-- .../cases/containers/use_update_comment.tsx | 42 ++-- 24 files changed, 692 insertions(+), 608 deletions(-) diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx index b7a8a45edce5e8..03000e89166177 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx @@ -35,19 +35,20 @@ export const useGetFieldsByIssueType = ({ }: Props): UseGetFieldsByIssueType => { const [isLoading, setIsLoading] = useState(true); const [fields, setFields] = useState({}); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector || !issueType) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getFieldsByIssueType({ http, signal: abortCtrl.current.signal, @@ -55,7 +56,7 @@ export const useGetFieldsByIssueType = ({ id: issueType, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setFields(res.data ?? {}); if (res.status && res.status === 'error') { @@ -66,22 +67,24 @@ export const useGetFieldsByIssueType = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.FIELDS_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.FIELDS_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, connector, issueType, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx index 4b60a9840c82bf..3c35d315a2bcd7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx @@ -35,27 +35,27 @@ export const useGetIssueTypes = ({ }: Props): UseGetIssueTypes => { const [isLoading, setIsLoading] = useState(true); const [issueTypes, setIssueTypes] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getIssueTypes({ http, signal: abortCtrl.current.signal, connectorId: connector.id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); const asOptions = (res.data ?? []).map((type) => ({ text: type.name ?? '', @@ -71,25 +71,29 @@ export const useGetIssueTypes = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.ISSUE_TYPES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.ISSUE_TYPES_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; - }, [http, connector, toastNotifications, handleIssueType]); + // handleIssueType unmounts the component at init causing the request to be aborted + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [http, connector, toastNotifications]); return { issueTypes, diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx index 170cf2b53395ec..b44b0558f15363 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx @@ -36,20 +36,20 @@ export const useGetIssues = ({ }: Props): UseGetIssues => { const [isLoading, setIsLoading] = useState(false); const [issues, setIssues] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = debounce(500, async () => { if (!actionConnector || isEmpty(query)) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getIssues({ http, signal: abortCtrl.current.signal, @@ -57,7 +57,7 @@ export const useGetIssues = ({ title: query ?? '', }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setIssues(res.data ?? []); if (res.status && res.status === 'error') { @@ -68,22 +68,24 @@ export const useGetIssues = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.ISSUES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.ISSUES_API_ERROR, + text: error.message, + }); + } } } }); + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, actionConnector, toastNotifications, query]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx index 89b42b1a88c1e3..6c70286426168c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx @@ -35,10 +35,10 @@ export const useGetSingleIssue = ({ }: Props): UseGetSingleIssue => { const [isLoading, setIsLoading] = useState(false); const [issue, setIssue] = useState(null); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!actionConnector || !id) { setIsLoading(false); @@ -55,7 +55,7 @@ export const useGetSingleIssue = ({ id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setIssue(res.data ?? null); if (res.status && res.status === 'error') { @@ -66,22 +66,24 @@ export const useGetSingleIssue = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.GET_ISSUE_API_ERROR(id), - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.GET_ISSUE_API_ERROR(id), + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, actionConnector, id, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx index 99964f466058f2..34cbb0a69b0f4d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx @@ -34,27 +34,27 @@ export const useGetIncidentTypes = ({ }: Props): UseGetIncidentTypes => { const [isLoading, setIsLoading] = useState(true); const [incidentTypes, setIncidentTypes] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getIncidentTypes({ http, signal: abortCtrl.current.signal, connectorId: connector.id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setIncidentTypes(res.data ?? []); if (res.status && res.status === 'error') { @@ -65,22 +65,24 @@ export const useGetIncidentTypes = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.INCIDENT_TYPES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.INCIDENT_TYPES_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, connector, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx index 0a71891ae41b2a..5b44c6b4a32b26 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx @@ -7,9 +7,9 @@ import { useState, useEffect, useRef } from 'react'; import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; import { getSeverity } from './api'; import * as i18n from './translations'; -import { ActionConnector } from '../../../containers/types'; type Severity = Array<{ id: number; name: string }>; @@ -31,26 +31,26 @@ export const useGetSeverity = ({ http, toastNotifications, connector }: Props): const [isLoading, setIsLoading] = useState(true); const [severity, setSeverity] = useState([]); const abortCtrl = useRef(new AbortController()); + const didCancel = useRef(false); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getSeverity({ http, signal: abortCtrl.current.signal, connectorId: connector.id, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setSeverity(res.data ?? []); @@ -62,22 +62,24 @@ export const useGetSeverity = ({ http, toastNotifications, connector }: Props): } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.SEVERITY_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.SEVERITY_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; }, [http, connector, toastNotifications]); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx index 16e905bdabfee9..a979f96d84ab23 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx @@ -37,20 +37,20 @@ export const useGetChoices = ({ }: UseGetChoicesProps): UseGetChoices => { const [isLoading, setIsLoading] = useState(false); const [choices, setChoices] = useState([]); + const didCancel = useRef(false); const abortCtrl = useRef(new AbortController()); useEffect(() => { - let didCancel = false; const fetchData = async () => { if (!connector) { setIsLoading(false); return; } - abortCtrl.current = new AbortController(); - setIsLoading(true); - try { + abortCtrl.current = new AbortController(); + setIsLoading(true); + const res = await getChoices({ http, signal: abortCtrl.current.signal, @@ -58,7 +58,7 @@ export const useGetChoices = ({ fields, }); - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); setChoices(res.data ?? []); if (res.status && res.status === 'error') { @@ -71,22 +71,24 @@ export const useGetChoices = ({ } } } catch (error) { - if (!didCancel) { + if (!didCancel.current) { setIsLoading(false); - toastNotifications.addDanger({ - title: i18n.CHOICES_API_ERROR, - text: error.message, - }); + if (error.name !== 'AbortError') { + toastNotifications.addDanger({ + title: i18n.CHOICES_API_ERROR, + text: error.message, + }); + } } } }; + didCancel.current = false; abortCtrl.current.abort(); fetchData(); return () => { - didCancel = true; - setIsLoading(false); + didCancel.current = true; abortCtrl.current.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx index ff5762b8476de9..3590fffdef5b22 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx @@ -22,25 +22,25 @@ export const useActionTypes = (): UseActionTypesResponse => { const [, dispatchToaster] = useStateToaster(); const [loading, setLoading] = useState(true); const [actionTypes, setActionTypes] = useState([]); - const didCancel = useRef(false); - const abortCtrl = useRef(new AbortController()); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const queryFirstTime = useRef(true); const refetchActionTypes = useCallback(async () => { try { setLoading(true); - didCancel.current = false; - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); - const res = await fetchActionTypes({ signal: abortCtrl.current.signal }); + const res = await fetchActionTypes({ signal: abortCtrlRef.current.signal }); - if (!didCancel.current) { + if (!isCancelledRef.current) { setLoading(false); setActionTypes(res); } } catch (error) { - if (!didCancel.current) { + if (!isCancelledRef.current) { setLoading(false); setActionTypes([]); errorToToaster({ @@ -59,8 +59,8 @@ export const useActionTypes = (): UseActionTypesResponse => { } return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); queryFirstTime.current = true; }; }, [refetchActionTypes]); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx index cc8c93fc990eb4..21d1832796ba8e 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_configure.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useEffect, useCallback, useReducer } from 'react'; +import { useEffect, useCallback, useReducer, useRef } from 'react'; import { getCaseConfigure, patchCaseConfigure, postCaseConfigure } from './api'; import { @@ -207,129 +207,128 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }, []); const [, dispatchToaster] = useStateToaster(); + const isCancelledRefetchRef = useRef(false); + const abortCtrlRefetchRef = useRef(new AbortController()); - const refetchCaseConfigure = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); + const isCancelledPersistRef = useRef(false); + const abortCtrlPersistRef = useRef(new AbortController()); - const fetchCaseConfiguration = async () => { - try { - setLoading(true); - const res = await getCaseConfigure({ signal: abortCtrl.signal }); - if (!didCancel) { - if (res != null) { - setConnector(res.connector); - if (setClosureType != null) { - setClosureType(res.closureType); - } - setVersion(res.version); - setMappings(res.mappings); + const refetchCaseConfigure = useCallback(async () => { + try { + isCancelledRefetchRef.current = false; + abortCtrlRefetchRef.current.abort(); + abortCtrlRefetchRef.current = new AbortController(); - if (!state.firstLoad) { - setFirstLoad(true); - if (setCurrentConfiguration != null) { - setCurrentConfiguration({ - closureType: res.closureType, - connector: { - ...res.connector, - }, - }); - } - } - if (res.error != null) { - errorToToaster({ - dispatchToaster, - error: new Error(res.error), - title: i18n.ERROR_TITLE, + setLoading(true); + const res = await getCaseConfigure({ signal: abortCtrlRefetchRef.current.signal }); + + if (!isCancelledRefetchRef.current) { + if (res != null) { + setConnector(res.connector); + if (setClosureType != null) { + setClosureType(res.closureType); + } + setVersion(res.version); + setMappings(res.mappings); + + if (!state.firstLoad) { + setFirstLoad(true); + if (setCurrentConfiguration != null) { + setCurrentConfiguration({ + closureType: res.closureType, + connector: { + ...res.connector, + }, }); } } - setLoading(false); + if (res.error != null) { + errorToToaster({ + dispatchToaster, + error: new Error(res.error), + title: i18n.ERROR_TITLE, + }); + } } - } catch (error) { - if (!didCancel) { - setLoading(false); + setLoading(false); + } + } catch (error) { + if (!isCancelledRefetchRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ dispatchToaster, error: error.body && error.body.message ? new Error(error.body.message) : error, title: i18n.ERROR_TITLE, }); } + setLoading(false); } - }; - - fetchCaseConfiguration(); - - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.firstLoad]); const persistCaseConfigure = useCallback( async ({ connector, closureType }: ConnectorConfiguration) => { - let didCancel = false; - const abortCtrl = new AbortController(); - const saveCaseConfiguration = async () => { - try { - setPersistLoading(true); - const connectorObj = { - connector, - closure_type: closureType, - }; - const res = - state.version.length === 0 - ? await postCaseConfigure(connectorObj, abortCtrl.signal) - : await patchCaseConfigure( - { - ...connectorObj, - version: state.version, - }, - abortCtrl.signal - ); - if (!didCancel) { - setConnector(res.connector); - if (setClosureType) { - setClosureType(res.closureType); - } - setVersion(res.version); - setMappings(res.mappings); - if (setCurrentConfiguration != null) { - setCurrentConfiguration({ - closureType: res.closureType, - connector: { - ...res.connector, + try { + isCancelledPersistRef.current = false; + abortCtrlPersistRef.current.abort(); + abortCtrlPersistRef.current = new AbortController(); + setPersistLoading(true); + + const connectorObj = { + connector, + closure_type: closureType, + }; + + const res = + state.version.length === 0 + ? await postCaseConfigure(connectorObj, abortCtrlPersistRef.current.signal) + : await patchCaseConfigure( + { + ...connectorObj, + version: state.version, }, - }); - } - if (res.error != null) { - errorToToaster({ - dispatchToaster, - error: new Error(res.error), - title: i18n.ERROR_TITLE, - }); - } - displaySuccessToast(i18n.SUCCESS_CONFIGURE, dispatchToaster); - setPersistLoading(false); + abortCtrlPersistRef.current.signal + ); + + if (!isCancelledPersistRef.current) { + setConnector(res.connector); + if (setClosureType) { + setClosureType(res.closureType); + } + setVersion(res.version); + setMappings(res.mappings); + if (setCurrentConfiguration != null) { + setCurrentConfiguration({ + closureType: res.closureType, + connector: { + ...res.connector, + }, + }); } - } catch (error) { - if (!didCancel) { - setConnector(state.currentConfiguration.connector); - setPersistLoading(false); + if (res.error != null) { + errorToToaster({ + dispatchToaster, + error: new Error(res.error), + title: i18n.ERROR_TITLE, + }); + } + displaySuccessToast(i18n.SUCCESS_CONFIGURE, dispatchToaster); + setPersistLoading(false); + } + } catch (error) { + if (!isCancelledPersistRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); } + setConnector(state.currentConfiguration.connector); + setPersistLoading(false); } - }; - saveCaseConfiguration(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } }, [ dispatchToaster, @@ -345,6 +344,12 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { useEffect(() => { refetchCaseConfigure(); + return () => { + isCancelledRefetchRef.current = true; + abortCtrlRefetchRef.current.abort(); + isCancelledPersistRef.current = true; + abortCtrlPersistRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx index d21e50902ca83b..338d04f702c633 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_connectors.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useStateToaster, errorToToaster } from '../../../common/components/toasters'; import * as i18n from '../translations'; @@ -22,40 +22,45 @@ export const useConnectors = (): UseConnectorsResponse => { const [, dispatchToaster] = useStateToaster(); const [loading, setLoading] = useState(true); const [connectors, setConnectors] = useState([]); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const refetchConnectors = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const getConnectors = async () => { - try { - setLoading(true); - const res = await fetchConnectors({ signal: abortCtrl.signal }); - if (!didCancel) { - setLoading(false); - setConnectors(res); - } - } catch (error) { - if (!didCancel) { - setLoading(false); - setConnectors([]); + const refetchConnectors = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + + setLoading(true); + const res = await fetchConnectors({ signal: abortCtrlRef.current.signal }); + + if (!isCancelledRef.current) { + setLoading(false); + setConnectors(res); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); } + + setLoading(false); + setConnectors([]); } - }; - getConnectors(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { refetchConnectors(); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx index 0fe45aaab799b9..da069ee6f10753 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_bulk_update_case.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useReducer } from 'react'; +import { useCallback, useReducer, useRef, useEffect } from 'react'; import { CaseStatuses } from '../../../../case/common/api'; import { displaySuccessToast, @@ -87,49 +87,45 @@ export const useUpdateCases = (): UseUpdateCases => { isUpdated: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const dispatchUpdateCases = useCallback((cases: BulkUpdateStatus[], action: string) => { - let cancel = false; - const abortCtrl = new AbortController(); + const dispatchUpdateCases = useCallback(async (cases: BulkUpdateStatus[], action: string) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); - const patchData = async () => { - try { - dispatch({ type: 'FETCH_INIT' }); - const patchResponse = await patchCasesStatus(cases, abortCtrl.signal); - if (!cancel) { - const resultCount = Object.keys(patchResponse).length; - const firstTitle = patchResponse[0].title; + dispatch({ type: 'FETCH_INIT' }); + const patchResponse = await patchCasesStatus(cases, abortCtrlRef.current.signal); - dispatch({ type: 'FETCH_SUCCESS', payload: true }); + if (!isCancelledRef.current) { + const resultCount = Object.keys(patchResponse).length; + const firstTitle = patchResponse[0].title; - const messageArgs = { - totalCases: resultCount, - caseTitle: resultCount === 1 ? firstTitle : '', - }; + dispatch({ type: 'FETCH_SUCCESS', payload: true }); + const messageArgs = { + totalCases: resultCount, + caseTitle: resultCount === 1 ? firstTitle : '', + }; - const message = - action === 'status' - ? getStatusToasterMessage(patchResponse[0].status, messageArgs) - : ''; + const message = + action === 'status' ? getStatusToasterMessage(patchResponse[0].status, messageArgs) : ''; - displaySuccessToast(message, dispatchToaster); - } - } catch (error) { - if (!cancel) { + displaySuccessToast(message, dispatchToaster); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - patchData(); - return () => { - cancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -137,14 +133,25 @@ export const useUpdateCases = (): UseUpdateCases => { dispatch({ type: 'RESET_IS_UPDATED' }); }, []); - const updateBulkStatus = useCallback((cases: Case[], status: string) => { - const updateCasesStatus: BulkUpdateStatus[] = cases.map((theCase) => ({ - status, - id: theCase.id, - version: theCase.version, - })); - dispatchUpdateCases(updateCasesStatus, 'status'); + const updateBulkStatus = useCallback( + (cases: Case[], status: string) => { + const updateCasesStatus: BulkUpdateStatus[] = cases.map((theCase) => ({ + status, + id: theCase.id, + version: theCase.version, + })); + dispatchUpdateCases(updateCasesStatus, 'status'); + }, // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; }, []); + return { ...state, updateBulkStatus, dispatchResetIsUpdated }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx index 923c20dcf8ebd5..f3d59a2883f2a3 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_delete_cases.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useReducer } from 'react'; +import { useCallback, useReducer, useRef, useEffect } from 'react'; import { displaySuccessToast, errorToToaster, @@ -78,45 +78,43 @@ export const useDeleteCases = (): UseDeleteCase => { isDeleted: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const dispatchDeleteCases = useCallback((cases: DeleteCase[]) => { - let cancel = false; - const abortCtrl = new AbortController(); + const dispatchDeleteCases = useCallback(async (cases: DeleteCase[]) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + dispatch({ type: 'FETCH_INIT' }); - const deleteData = async () => { - try { - dispatch({ type: 'FETCH_INIT' }); - const caseIds = cases.map((theCase) => theCase.id); - // We don't allow user batch delete sub cases on UI at the moment. - if (cases[0].type != null || cases.length > 1) { - await deleteCases(caseIds, abortCtrl.signal); - } else { - await deleteSubCases(caseIds, abortCtrl.signal); - } + const caseIds = cases.map((theCase) => theCase.id); + // We don't allow user batch delete sub cases on UI at the moment. + if (cases[0].type != null || cases.length > 1) { + await deleteCases(caseIds, abortCtrlRef.current.signal); + } else { + await deleteSubCases(caseIds, abortCtrlRef.current.signal); + } - if (!cancel) { - dispatch({ type: 'FETCH_SUCCESS', payload: true }); - displaySuccessToast( - i18n.DELETED_CASES(cases.length, cases.length === 1 ? cases[0].title : ''), - dispatchToaster - ); - } - } catch (error) { - if (!cancel) { + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: true }); + displaySuccessToast( + i18n.DELETED_CASES(cases.length, cases.length === 1 ? cases[0].title : ''), + dispatchToaster + ); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_DELETING, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - deleteData(); - return () => { - abortCtrl.abort(); - cancel = true; - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -142,5 +140,12 @@ export const useDeleteCases = (): UseDeleteCase => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.isDisplayConfirmDeleteModal]); + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, []); + return { ...state, dispatchResetIsDeleted, handleOnDeleteConfirm, handleToggleModal }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx index 9b536f32e7eb80..9b10247794c8d3 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getActionLicense } from './api'; @@ -28,53 +28,58 @@ const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira'; export const useGetActionLicense = (): ActionLicenseState => { const [actionLicenseState, setActionLicensesState] = useState(initialData); - const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const fetchActionLicense = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const fetchData = async () => { + const fetchActionLicense = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); setActionLicensesState({ - ...actionLicenseState, + ...initialData, isLoading: true, }); - try { - const response = await getActionLicense(abortCtrl.signal); - if (!didCancel) { - setActionLicensesState({ - actionLicense: - response.find((l) => l.id === MINIMUM_LICENSE_REQUIRED_CONNECTOR) ?? null, - isLoading: false, - isError: false, - }); - } - } catch (error) { - if (!didCancel) { + + const response = await getActionLicense(abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + setActionLicensesState({ + actionLicense: response.find((l) => l.id === MINIMUM_LICENSE_REQUIRED_CONNECTOR) ?? null, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setActionLicensesState({ - actionLicense: null, - isLoading: false, - isError: true, - }); } + + setActionLicensesState({ + actionLicense: null, + isLoading: false, + isError: true, + }); } - }; - fetchData(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionLicenseState]); useEffect(() => { fetchActionLicense(); + + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + return { ...actionLicenseState }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index 1c4476e3cb2b7c..fb8da8d0663ee1 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { isEmpty } from 'lodash'; import { useEffect, useReducer, useCallback, useRef } from 'react'; import { CaseStatuses, CaseType } from '../../../../case/common/api'; @@ -96,48 +95,48 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => { data: initialData, }); const [, dispatchToaster] = useStateToaster(); - const abortCtrl = useRef(new AbortController()); - const didCancel = useRef(false); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const updateCase = useCallback((newCase: Case) => { dispatch({ type: 'UPDATE_CASE', payload: newCase }); }, []); const callFetch = useCallback(async () => { - const fetchData = async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT' }); - try { - const response = await (subCaseId - ? getSubCase(caseId, subCaseId, true, abortCtrl.current.signal) - : getCase(caseId, true, abortCtrl.current.signal)); - if (!didCancel.current) { - dispatch({ type: 'FETCH_SUCCESS', payload: response }); - } - } catch (error) { - if (!didCancel.current) { + + const response = await (subCaseId + ? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal) + : getCase(caseId, true, abortCtrlRef.current.signal)); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: response }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - didCancel.current = false; - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); - fetchData(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [caseId, subCaseId]); useEffect(() => { - if (!isEmpty(caseId)) { - callFetch(); - } + callFetch(); + return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [caseId, subCaseId]); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx index 12e5f6643351ff..cc8deaf72eef6b 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case_user_actions.tsx @@ -6,7 +6,7 @@ */ import { isEmpty, uniqBy } from 'lodash/fp'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import deepEqual from 'fast-deep-equal'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; @@ -244,64 +244,67 @@ export const useGetCaseUserActions = ( const [caseUserActionsState, setCaseUserActionsState] = useState( initialData ); - const abortCtrl = useRef(new AbortController()); - const didCancel = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + const isCancelledRef = useRef(false); const [, dispatchToaster] = useStateToaster(); const fetchCaseUserActions = useCallback( - (thisCaseId: string, thisSubCaseId?: string) => { - const fetchData = async () => { - try { + async (thisCaseId: string, thisSubCaseId?: string) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + setCaseUserActionsState({ + ...caseUserActionsState, + isLoading: true, + }); + + const response = await (thisSubCaseId + ? getSubCaseUserActions(thisCaseId, thisSubCaseId, abortCtrlRef.current.signal) + : getCaseUserActions(thisCaseId, abortCtrlRef.current.signal)); + + if (!isCancelledRef.current) { + // Attention Future developer + // We are removing the first item because it will always be the creation of the case + // and we do not want it to simplify our life + const participants = !isEmpty(response) + ? uniqBy('actionBy.username', response).map((cau) => cau.actionBy) + : []; + + const caseUserActions = !isEmpty(response) + ? thisSubCaseId + ? response + : response.slice(1) + : []; + setCaseUserActionsState({ - ...caseUserActionsState, - isLoading: true, + caseUserActions, + ...getPushedInfo(caseUserActions, caseConnectorId), + isLoading: false, + isError: false, + participants, }); - - const response = await (thisSubCaseId - ? getSubCaseUserActions(thisCaseId, thisSubCaseId, abortCtrl.current.signal) - : getCaseUserActions(thisCaseId, abortCtrl.current.signal)); - if (!didCancel.current) { - // Attention Future developer - // We are removing the first item because it will always be the creation of the case - // and we do not want it to simplify our life - const participants = !isEmpty(response) - ? uniqBy('actionBy.username', response).map((cau) => cau.actionBy) - : []; - - const caseUserActions = !isEmpty(response) - ? thisSubCaseId - ? response - : response.slice(1) - : []; - setCaseUserActionsState({ - caseUserActions, - ...getPushedInfo(caseUserActions, caseConnectorId), - isLoading: false, - isError: false, - participants, - }); - } - } catch (error) { - if (!didCancel.current) { + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setCaseUserActionsState({ - caseServices: {}, - caseUserActions: [], - hasDataToPush: false, - isError: true, - isLoading: false, - participants: [], - }); } + + setCaseUserActionsState({ + caseServices: {}, + caseUserActions: [], + hasDataToPush: false, + isError: true, + isLoading: false, + participants: [], + }); } - }; - abortCtrl.current.abort(); - abortCtrl.current = new AbortController(); - fetchData(); + } }, // eslint-disable-next-line react-hooks/exhaustive-deps [caseConnectorId] @@ -313,8 +316,8 @@ export const useGetCaseUserActions = ( } return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [caseId, subCaseId]); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx index 298d817fffa88a..c83cc02dedb977 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useReducer } from 'react'; +import { useCallback, useEffect, useReducer, useRef } from 'react'; import { CaseStatuses } from '../../../../case/common/api'; import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case, UpdateByKey } from './types'; @@ -139,6 +139,10 @@ export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { selectedCases: [], }); const [, dispatchToaster] = useStateToaster(); + const didCancelFetchCases = useRef(false); + const didCancelUpdateCases = useRef(false); + const abortCtrlFetchCases = useRef(new AbortController()); + const abortCtrlUpdateCases = useRef(new AbortController()); const setSelectedCases = useCallback((mySelectedCases: Case[]) => { dispatch({ type: 'UPDATE_TABLE_SELECTIONS', payload: mySelectedCases }); @@ -152,81 +156,69 @@ export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: newFilters }); }, []); - const fetchCases = useCallback((filterOptions: FilterOptions, queryParams: QueryParams) => { - let didCancel = false; - const abortCtrl = new AbortController(); - - const fetchData = async () => { + const fetchCases = useCallback(async (filterOptions: FilterOptions, queryParams: QueryParams) => { + try { + didCancelFetchCases.current = false; + abortCtrlFetchCases.current.abort(); + abortCtrlFetchCases.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: 'cases' }); - try { - const response = await getCases({ - filterOptions, - queryParams, - signal: abortCtrl.signal, + + const response = await getCases({ + filterOptions, + queryParams, + signal: abortCtrlFetchCases.current.signal, + }); + + if (!didCancelFetchCases.current) { + dispatch({ + type: 'FETCH_CASES_SUCCESS', + payload: response, }); - if (!didCancel) { - dispatch({ - type: 'FETCH_CASES_SUCCESS', - payload: response, - }); - } - } catch (error) { - if (!didCancel) { + } + } catch (error) { + if (!didCancelFetchCases.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); } + dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); } - }; - fetchData(); - return () => { - abortCtrl.abort(); - didCancel = true; - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => fetchCases(state.filterOptions, state.queryParams), [ - state.queryParams, - state.filterOptions, - ]); - const dispatchUpdateCaseProperty = useCallback( - ({ updateKey, updateValue, caseId, refetchCasesStatus, version }: UpdateCase) => { - let didCancel = false; - const abortCtrl = new AbortController(); - - const fetchData = async () => { + async ({ updateKey, updateValue, caseId, refetchCasesStatus, version }: UpdateCase) => { + try { + didCancelUpdateCases.current = false; + abortCtrlUpdateCases.current.abort(); + abortCtrlUpdateCases.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: 'caseUpdate' }); - try { - await patchCase( - caseId, - { [updateKey]: updateValue }, - // saved object versions are typed as string | undefined, hope that's not true - version ?? '', - abortCtrl.signal - ); - if (!didCancel) { - dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); - fetchCases(state.filterOptions, state.queryParams); - refetchCasesStatus(); - } - } catch (error) { - if (!didCancel) { + + await patchCase( + caseId, + { [updateKey]: updateValue }, + // saved object versions are typed as string | undefined, hope that's not true + version ?? '', + abortCtrlUpdateCases.current.signal + ); + + if (!didCancelUpdateCases.current) { + dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); + fetchCases(state.filterOptions, state.queryParams); + refetchCasesStatus(); + } + } catch (error) { + if (!didCancelUpdateCases.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); - dispatch({ type: 'FETCH_FAILURE', payload: 'caseUpdate' }); } + dispatch({ type: 'FETCH_FAILURE', payload: 'caseUpdate' }); } - }; - fetchData(); - return () => { - abortCtrl.abort(); - didCancel = true; - }; + } }, // eslint-disable-next-line react-hooks/exhaustive-deps [state.filterOptions, state.queryParams] @@ -237,6 +229,17 @@ export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.filterOptions, state.queryParams]); + useEffect(() => { + fetchCases(state.filterOptions, state.queryParams); + return () => { + didCancelFetchCases.current = true; + didCancelUpdateCases.current = true; + abortCtrlFetchCases.current.abort(); + abortCtrlUpdateCases.current.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.queryParams, state.filterOptions]); + return { ...state, dispatchUpdateCaseProperty, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx index 057fc05008bb02..087f7ef455cbae 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases_status.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useRef } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getCasesStatus } from './api'; @@ -32,51 +32,56 @@ export interface UseGetCasesStatus extends CasesStatusState { export const useGetCasesStatus = (): UseGetCasesStatus => { const [casesStatusState, setCasesStatusState] = useState(initialData); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const fetchCasesStatus = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const fetchData = async () => { + const fetchCasesStatus = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); setCasesStatusState({ - ...casesStatusState, + ...initialData, isLoading: true, }); - try { - const response = await getCasesStatus(abortCtrl.signal); - if (!didCancel) { - setCasesStatusState({ - ...response, - isLoading: false, - isError: false, - }); - } - } catch (error) { - if (!didCancel) { + + const response = await getCasesStatus(abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + setCasesStatusState({ + ...response, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setCasesStatusState({ - countClosedCases: 0, - countInProgressCases: 0, - countOpenCases: 0, - isLoading: false, - isError: true, - }); } + setCasesStatusState({ + countClosedCases: 0, + countInProgressCases: 0, + countOpenCases: 0, + isLoading: false, + isError: true, + }); } - }; - fetchData(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [casesStatusState]); + }, []); useEffect(() => { fetchCasesStatus(); + + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx index 25c483045b84f8..f2c33ec4730fe8 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_reporters.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { useCallback, useEffect, useState } from 'react'; - +import { useCallback, useEffect, useState, useRef } from 'react'; import { isEmpty } from 'lodash/fp'; + import { User } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getReporters } from './api'; @@ -35,57 +35,61 @@ export const useGetReporters = (): UseGetReporters => { const [reportersState, setReporterState] = useState(initialData); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const fetchReporters = useCallback(() => { - let didCancel = false; - const abortCtrl = new AbortController(); - const fetchData = async () => { + const fetchReporters = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); setReporterState({ ...reportersState, isLoading: true, }); - try { - const response = await getReporters(abortCtrl.signal); - const myReporters = response - .map((r) => - r.full_name == null || isEmpty(r.full_name) ? r.username ?? '' : r.full_name - ) - .filter((u) => !isEmpty(u)); - if (!didCancel) { - setReporterState({ - reporters: myReporters, - respReporters: response, - isLoading: false, - isError: false, - }); - } - } catch (error) { - if (!didCancel) { + + const response = await getReporters(abortCtrlRef.current.signal); + const myReporters = response + .map((r) => (r.full_name == null || isEmpty(r.full_name) ? r.username ?? '' : r.full_name)) + .filter((u) => !isEmpty(u)); + + if (!isCancelledRef.current) { + setReporterState({ + reporters: myReporters, + respReporters: response, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - setReporterState({ - reporters: [], - respReporters: [], - isLoading: false, - isError: true, - }); } + + setReporterState({ + reporters: [], + respReporters: [], + isLoading: false, + isError: true, + }); } - }; - fetchData(); - return () => { - didCancel = true; - abortCtrl.abort(); - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportersState]); useEffect(() => { fetchReporters(); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + return { ...reportersState, fetchReporters }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx index 208516d302eb4b..4a7a298e2cd868 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_tags.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import { useEffect, useReducer } from 'react'; - +import { useEffect, useReducer, useRef, useCallback } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getTags } from './api'; import * as i18n from './translations'; @@ -59,37 +58,42 @@ export const useGetTags = (): UseGetTags => { tags: initialData, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); - const callFetch = () => { - let didCancel = false; - const abortCtrl = new AbortController(); - - const fetchData = async () => { + const callFetch = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT' }); - try { - const response = await getTags(abortCtrl.signal); - if (!didCancel) { - dispatch({ type: 'FETCH_SUCCESS', payload: response }); - } - } catch (error) { - if (!didCancel) { + + const response = await getTags(abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS', payload: response }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }; - fetchData(); - return () => { - abortCtrl.abort(); - didCancel = true; - }; - }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { callFetch(); + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { ...state, fetchTags: callFetch }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx index c4fa0304735341..d890c050f5034c 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx @@ -6,7 +6,6 @@ */ import { useReducer, useCallback, useRef, useEffect } from 'react'; - import { CasePostRequest } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { postCase } from './api'; @@ -51,38 +50,41 @@ export const usePostCase = (): UsePostCase => { isError: false, }); const [, dispatchToaster] = useStateToaster(); - const cancel = useRef(false); - const abortCtrl = useRef(new AbortController()); - const postMyCase = useCallback( - async (data: CasePostRequest) => { - try { - dispatch({ type: 'FETCH_INIT' }); - abortCtrl.current.abort(); - cancel.current = false; - abortCtrl.current = new AbortController(); - const response = await postCase(data, abortCtrl.current.signal); - if (!cancel.current) { - dispatch({ type: 'FETCH_SUCCESS' }); - } - return response; - } catch (error) { - if (!cancel.current) { + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const postMyCase = useCallback(async (data: CasePostRequest) => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + + dispatch({ type: 'FETCH_INIT' }); + const response = await postCase(data, abortCtrlRef.current.signal); + + if (!isCancelledRef.current) { + dispatch({ type: 'FETCH_SUCCESS' }); + } + return response; + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, dispatchToaster, }); - dispatch({ type: 'FETCH_FAILURE' }); } + dispatch({ type: 'FETCH_FAILURE' }); } - }, - [dispatchToaster] - ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { return () => { - abortCtrl.current.abort(); - cancel.current = true; + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; }, []); return { ...state, postCase: postMyCase }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx index 8fc8053c14f70d..5eb875287ba888 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import { useReducer, useCallback } from 'react'; - +import { useReducer, useCallback, useRef, useEffect } from 'react'; import { CommentRequest } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; @@ -58,38 +57,47 @@ export const usePostComment = (): UsePostComment => { isError: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const postMyComment = useCallback( async ({ caseId, data, updateCase, subCaseId }: PostComment) => { - let cancel = false; - const abortCtrl = new AbortController(); - try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT' }); - const response = await postComment(data, caseId, abortCtrl.signal, subCaseId); - if (!cancel) { + + const response = await postComment(data, caseId, abortCtrlRef.current.signal, subCaseId); + + if (!isCancelledRef.current) { dispatch({ type: 'FETCH_SUCCESS' }); if (updateCase) { updateCase(response); } } } catch (error) { - if (!cancel) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } dispatch({ type: 'FETCH_FAILURE' }); } } - return () => { - abortCtrl.abort(); - cancel = true; - }; }, [dispatchToaster] ); + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, []); + return { ...state, postComment: postMyComment }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx index 03d881d7934e91..27a02d9300cc0a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx @@ -67,17 +67,17 @@ export const usePostPushToService = (): UsePostPushToService => { }); const [, dispatchToaster] = useStateToaster(); const cancel = useRef(false); - const abortCtrl = useRef(new AbortController()); + const abortCtrlRef = useRef(new AbortController()); const pushCaseToExternalService = useCallback( async ({ caseId, connector }: PushToServiceRequest) => { try { - dispatch({ type: 'FETCH_INIT' }); - abortCtrl.current.abort(); + abortCtrlRef.current.abort(); cancel.current = false; - abortCtrl.current = new AbortController(); + abortCtrlRef.current = new AbortController(); + dispatch({ type: 'FETCH_INIT' }); - const response = await pushCase(caseId, connector.id, abortCtrl.current.signal); + const response = await pushCase(caseId, connector.id, abortCtrlRef.current.signal); if (!cancel.current) { dispatch({ type: 'FETCH_SUCCESS' }); @@ -90,11 +90,13 @@ export const usePostPushToService = (): UsePostPushToService => { return response; } catch (error) { if (!cancel.current) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } dispatch({ type: 'FETCH_FAILURE' }); } } @@ -105,7 +107,7 @@ export const usePostPushToService = (): UsePostPushToService => { useEffect(() => { return () => { - abortCtrl.current.abort(); + abortCtrlRef.current.abort(); cancel.current = true; }; }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx index 23a23caeb71bdf..e8de2257009e62 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx @@ -5,10 +5,9 @@ * 2.0. */ -import { useReducer, useCallback, useEffect, useRef } from 'react'; +import { useReducer, useCallback, useRef, useEffect } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; - import { patchCase, patchSubCase } from './api'; import { UpdateKey, UpdateByKey, CaseStatuses } from './types'; import * as i18n from './translations'; @@ -70,8 +69,8 @@ export const useUpdateCase = ({ updateKey: null, }); const [, dispatchToaster] = useStateToaster(); - const abortCtrl = useRef(new AbortController()); - const didCancel = useRef(false); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const dispatchUpdateCaseProperty = useCallback( async ({ @@ -84,24 +83,27 @@ export const useUpdateCase = ({ onError, }: UpdateByKey) => { try { - didCancel.current = false; - abortCtrl.current = new AbortController(); + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: updateKey }); + const response = await (updateKey === 'status' && subCaseId ? patchSubCase( caseId, subCaseId, { status: updateValue as CaseStatuses }, caseData.version, - abortCtrl.current.signal + abortCtrlRef.current.signal ) : patchCase( caseId, { [updateKey]: updateValue }, caseData.version, - abortCtrl.current.signal + abortCtrlRef.current.signal )); - if (!didCancel.current) { + + if (!isCancelledRef.current) { if (fetchCaseUserActions != null) { fetchCaseUserActions(caseId, subCaseId); } @@ -119,7 +121,7 @@ export const useUpdateCase = ({ } } } catch (error) { - if (!didCancel.current) { + if (!isCancelledRef.current) { if (error.name !== 'AbortError') { errorToToaster({ title: i18n.ERROR_TITLE, @@ -140,8 +142,8 @@ export const useUpdateCase = ({ useEffect(() => { return () => { - didCancel.current = true; - abortCtrl.current.abort(); + isCancelledRef.current = true; + abortCtrlRef.current.abort(); }; }, []); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx index e36b21823310eb..81bce248852fec 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_comment.tsx @@ -5,10 +5,8 @@ * 2.0. */ -import { useReducer, useCallback } from 'react'; - +import { useReducer, useCallback, useRef, useEffect } from 'react'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; - import { patchComment } from './api'; import * as i18n from './translations'; import { Case } from './types'; @@ -72,6 +70,8 @@ export const useUpdateComment = (): UseUpdateComment => { isError: false, }); const [, dispatchToaster] = useStateToaster(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); const dispatchUpdateComment = useCallback( async ({ @@ -83,41 +83,49 @@ export const useUpdateComment = (): UseUpdateComment => { updateCase, version, }: UpdateComment) => { - let cancel = false; - const abortCtrl = new AbortController(); try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: commentId }); + const response = await patchComment( caseId, commentId, commentUpdate, version, - abortCtrl.signal, + abortCtrlRef.current.signal, subCaseId ); - if (!cancel) { + + if (!isCancelledRef.current) { updateCase(response); fetchUserActions(); dispatch({ type: 'FETCH_SUCCESS', payload: { commentId } }); } } catch (error) { - if (!cancel) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } dispatch({ type: 'FETCH_FAILURE', payload: commentId }); } } - return () => { - cancel = true; - abortCtrl.abort(); - }; }, // eslint-disable-next-line react-hooks/exhaustive-deps [] ); + useEffect(() => { + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, []); + return { ...state, patchComment: dispatchUpdateComment }; }; From 0b15a06f8a6fb6930dcaa490c3a520a3e97d1949 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 25 Feb 2021 18:43:02 +0200 Subject: [PATCH 09/40] [XY] Enables page reload toast for the legacyChartsLibrary setting (#92811) --- src/plugins/vis_type_xy/server/plugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/vis_type_xy/server/plugin.ts b/src/plugins/vis_type_xy/server/plugin.ts index fd670e288ff5b6..a9e6020cf3ee8d 100644 --- a/src/plugins/vis_type_xy/server/plugin.ts +++ b/src/plugins/vis_type_xy/server/plugin.ts @@ -20,6 +20,7 @@ export const uiSettingsConfig: Record> = { name: i18n.translate('visTypeXy.advancedSettings.visualization.legacyChartsLibrary.name', { defaultMessage: 'Legacy charts library', }), + requiresPageReload: true, value: false, description: i18n.translate( 'visTypeXy.advancedSettings.visualization.legacyChartsLibrary.description', From bd38d4aab2e60c68c600449a6fe993e9ea60c77a Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Thu, 25 Feb 2021 08:59:30 -0800 Subject: [PATCH 10/40] [Event Log] Extended README.md with the documentation for a REST API and Start plugin contract. (#92562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Event Log] Extended README.md with the documentation for a REST API and Start plugin contract. * Apply suggestions from code review Co-authored-by: Mike Côté Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * fixed due to comments Co-authored-by: Mike Côté Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- x-pack/plugins/event_log/README.md | 79 +++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/event_log/README.md b/x-pack/plugins/event_log/README.md index eb7fbc9d590fab..9c806680f68a20 100644 --- a/x-pack/plugins/event_log/README.md +++ b/x-pack/plugins/event_log/README.md @@ -164,10 +164,12 @@ history records associated with specific saved object ids. ## API +Event Log plugin returns a service instance from setup() and client service from start() methods. + +### Setup ```typescript // IEvent is a TS type generated from the subset of ECS supported -// the NP plugin returns a service instance from setup() and start() export interface IEventLogService { registerProviderActions(provider: string, actions: string[]): void; isProviderActionRegistered(provider: string, action: string): boolean; @@ -237,6 +239,80 @@ properties `start`, `end`, and `duration` in the event. For example: It's anticipated that more "helper" methods like this will be provided in the future. +### Start +```typescript + +export interface IEventLogClientService { + getClient(request: KibanaRequest): IEventLogClient; +} + +export interface IEventLogClient { + findEventsBySavedObjectIds( + type: string, + ids: string[], + options?: Partial + ): Promise; +} +``` + +The plugin exposes an `IEventLogClientService` object to plugins that request it. +These plugins must call `getClient(request)` to get the event log client. + +## Experimental RESTful API + +Usage of the event log allows you to retrieve the events for a given saved object type by the specified set of IDs. +The following API is experimental and can change or be removed in a future release. + +### `GET /api/event_log/{type}/{id}/_find`: Get events for a given saved object type by the ID + +Collects event information from the event log for the selected saved object by type and ID. + +Params: + +|Property|Description|Type| +|---|---|---| +|type|The type of the saved object whose events you're trying to get.|string| +|id|The id of the saved object.|string| + +Query: + +|Property|Description|Type| +|---|---|---| +|page|The page number.|number| +|per_page|The number of events to return per page.|number| +|sort_field|Sorts the response. Could be an event fields returned in the response.|string| +|sort_order|Sort direction, either `asc` or `desc`.|string| +|filter|A KQL string that you filter with an attribute from the event. It should look like `event.action:(execute)`.|string| +|start|The date to start looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string| +|end|The date to stop looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string| + +### `POST /api/event_log/{type}/_find`: Retrive events for a given saved object type by the IDs + +Collects event information from the event log for the selected saved object by type and by IDs. + +Params: + +|Property|Description|Type| +|---|---|---| +|type|The type of the saved object whose events you're trying to get.|string| + +Query: + +|Property|Description|Type| +|---|---|---| +|page|The page number.|number| +|per_page|The number of events to return per page.|number| +|sort_field|Sorts the response. Could be an event field returned in the response.|string| +|sort_order|Sort direction, either `asc` or `desc`.|string| +|filter|A KQL string that you filter with an attribute from the event. It should look like `event.action:(execute)`.|string| +|start|The date to start looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string| +|end|The date to stop looking for saved object events in the event log. Either an ISO date string, or a duration string that indicates the time since now.|string| + +Body: + +|Property|Description|Type| +|---|---|---| +|ids|The array ids of the saved object.|string array| ## Stored data @@ -303,4 +379,3 @@ For more relevant information on ILM, see: [getting started with ILM doc]: https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-index-lifecycle-management.html [write index alias behavior]: https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-rollover-index.html#indices-rollover-is-write-index - From bc347d8633fae3c7fb305a5d7bb2a4f6bf18367e Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 25 Feb 2021 18:05:19 +0100 Subject: [PATCH 11/40] [ILM] Fix replicas not showing (#92782) * remove logic that disables SS action in cold if no rollover and always show replicas field * update test coverage to be consistent with new form behaviour and expand hot phase without rollover test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../edit_policy/edit_policy.helpers.tsx | 2 -- .../edit_policy/edit_policy.test.ts | 24 +++++++------------ .../phases/cold_phase/cold_phase.tsx | 16 +------------ .../searchable_snapshot_field.tsx | 20 ++-------------- .../phases/warm_phase/warm_phase.tsx | 14 +++++------ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 7 files changed, 18 insertions(+), 60 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 53871eae6dba1c..c61b431eed46dc 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -229,12 +229,10 @@ export const setup = async (arg?: { const createSearchableSnapshotActions = (phase: Phases) => { const fieldSelector = `searchableSnapshotField-${phase}`; const licenseCalloutSelector = `${fieldSelector}.searchableSnapshotDisabledDueToLicense`; - const rolloverCalloutSelector = `${fieldSelector}.searchableSnapshotFieldsNoRolloverCallout`; const toggleSelector = `${fieldSelector}.searchableSnapshotToggle`; const toggleSearchableSnapshot = createFormToggleAction(toggleSelector); return { - searchableSnapshotDisabledDueToRollover: () => exists(rolloverCalloutSelector), searchableSnapshotDisabled: () => exists(licenseCalloutSelector) && find(licenseCalloutSelector).props().disabled === true, searchableSnapshotsExists: () => exists(fieldSelector), diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index ffa9b1aa236a0a..740aeebb852f18 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -405,6 +405,7 @@ describe('', () => { await actions.cold.setMinAgeUnits('s'); await actions.cold.setDataAllocation('node_attrs'); await actions.cold.setSelectedNodeAttribute('test:123'); + await actions.cold.setSearchableSnapshot('my-repo'); await actions.cold.setReplicas('123'); await actions.cold.setFreeze(true); await actions.cold.setIndexPriority('123'); @@ -426,6 +427,9 @@ describe('', () => { }, }, "freeze": Object {}, + "searchable_snapshot": Object { + "snapshot_repository": "my-repo", + }, "set_priority": Object { "priority": 123, }, @@ -445,19 +449,6 @@ describe('', () => { } `); }); - - // Setting searchable snapshot field disables setting replicas so we test this separately - test('setting searchable snapshot', async () => { - const { actions } = testBed; - await actions.cold.enable(true); - await actions.cold.setSearchableSnapshot('my-repo'); - await actions.savePolicy(); - const latestRequest2 = server.requests[server.requests.length - 1]; - const entirePolicy2 = JSON.parse(JSON.parse(latestRequest2.requestBody).body); - expect(entirePolicy2.phases.cold.actions.searchable_snapshot.snapshot_repository).toEqual( - 'my-repo' - ); - }); }); }); @@ -926,14 +917,15 @@ describe('', () => { const { component } = testBed; component.update(); }); - test('hiding and disabling searchable snapshot field', async () => { + test('hides fields in hot phase', async () => { const { actions } = testBed; await actions.hot.toggleDefaultRollover(false); await actions.hot.toggleRollover(false); - await actions.cold.enable(true); + expect(actions.hot.forceMergeFieldExists()).toBeFalsy(); + expect(actions.hot.shrinkExists()).toBeFalsy(); expect(actions.hot.searchableSnapshotsExists()).toBeFalsy(); - expect(actions.cold.searchableSnapshotDisabledDueToRollover()).toBeTruthy(); + expect(actions.hot.readonlyExists()).toBeFalsy(); }); test('hiding rollover tip on minimum age', async () => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx index 27aacef1a368bd..1dbc30674eaa58 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx @@ -8,12 +8,9 @@ import React, { FunctionComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { get } from 'lodash'; import { EuiTextColor } from '@elastic/eui'; -import { useFormData } from '../../../../../../shared_imports'; - import { useConfigurationIssues } from '../../../form'; import { LearnMoreLink, ToggleFieldWithDescribedFormRow } from '../../'; @@ -36,23 +33,12 @@ const i18nTexts = { }, }; -const formFieldPaths = { - enabled: '_meta.cold.enabled', - searchableSnapshot: 'phases.cold.actions.searchable_snapshot.snapshot_repository', -}; - export const ColdPhase: FunctionComponent = () => { const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); - const [formData] = useFormData({ - watch: [formFieldPaths.searchableSnapshot], - }); - - const showReplicasField = get(formData, formFieldPaths.searchableSnapshot) == null; - return ( }> - {showReplicasField && } + {/* Freeze section */} {!isUsingSearchableSnapshotInHotPhase && ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx index 97e7d0bcc27de9..1a78149521e63c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx @@ -52,7 +52,7 @@ export const SearchableSnapshotField: FunctionComponent = ({ phase }) => services: { cloud }, } = useKibana(); const { getUrlForApp, policy, license, isNewPolicy } = useEditPolicyContext(); - const { isUsingSearchableSnapshotInHotPhase, isUsingRollover } = useConfigurationIssues(); + const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); const searchableSnapshotPath = `phases.${phase}.actions.searchable_snapshot.snapshot_repository`; @@ -62,10 +62,8 @@ export const SearchableSnapshotField: FunctionComponent = ({ phase }) => const isColdPhase = phase === 'cold'; const isDisabledDueToLicense = !license.canUseSearchableSnapshot(); const isDisabledInColdDueToHotPhase = isColdPhase && isUsingSearchableSnapshotInHotPhase; - const isDisabledInColdDueToRollover = isColdPhase && !isUsingRollover; - const isDisabled = - isDisabledDueToLicense || isDisabledInColdDueToHotPhase || isDisabledInColdDueToRollover; + const isDisabled = isDisabledDueToLicense || isDisabledInColdDueToHotPhase; const [isFieldToggleChecked, setIsFieldToggleChecked] = useState(() => Boolean( @@ -294,20 +292,6 @@ export const SearchableSnapshotField: FunctionComponent = ({ phase }) => )} /> ); - } else if (isDisabledInColdDueToRollover) { - infoCallout = ( - - ); } return infoCallout ? ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx index e5bf34890a4a77..577dab6804147c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx @@ -33,22 +33,22 @@ export const WarmPhase: FunctionComponent = () => { const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); return ( - - + + - {!isUsingSearchableSnapshotInHotPhase && } + {!isUsingSearchableSnapshotInHotPhase && } - {!isUsingSearchableSnapshotInHotPhase && } + {!isUsingSearchableSnapshotInHotPhase && } - + {/* Data tier allocation section */} - + ); }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 5e553e3cfe7a17..42552d756313bd 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9459,7 +9459,6 @@ "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotFieldTitle": "検索可能スナップショット", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotLicenseCalloutBody": "検索可能なスナップショットを作成するには、エンタープライズライセンスが必要です。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotLicenseCalloutTitle": "エンタープライズライセンスが必要です", - "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotNoRolloverCalloutBody": "ロールオーバーがホットフェーズで無効な時には、検索可能なスナップショットを作成できません。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotRepoRequiredError": "スナップショットリポジトリ名が必要です。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotsToggleLabel": "検索可能スナップショットを作成", "xpack.indexLifecycleMgmt.editPolicy.showPolicyJsonButto": "リクエストを表示", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e5c57dc0e2ec67..ee9f1aefeae9bc 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9483,7 +9483,6 @@ "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotFieldTitle": "可搜索快照", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotLicenseCalloutBody": "要创建可搜索快照,需要企业许可证。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotLicenseCalloutTitle": "需要企业许可证", - "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotNoRolloverCalloutBody": "在热阶段禁用滚动更新后,无法创建可搜索快照。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotRepoRequiredError": "快照存储库名称必填。", "xpack.indexLifecycleMgmt.editPolicy.searchableSnapshotsToggleLabel": "创建可搜索快照", "xpack.indexLifecycleMgmt.editPolicy.showPolicyJsonButto": "显示请求", From 2d011e2b95bd92dedee9780305a3292880fc547f Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 25 Feb 2021 18:09:14 +0100 Subject: [PATCH 12/40] [Rollup] Fix use of undefined value in JS import (#92791) * Remove use of undefined import * added component integration test for default value in frequency --- .../crud_app/sections/job_create/steps_config/index.js | 4 +--- .../test/client_integration/job_create_logistics.test.js | 5 +++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_create/steps_config/index.js b/x-pack/plugins/rollup/public/crud_app/sections/job_create/steps_config/index.js index 02342c895f077b..323d267899bdf7 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_create/steps_config/index.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_create/steps_config/index.js @@ -7,8 +7,6 @@ import { cloneDeep, get, pick } from 'lodash'; -import { WEEK } from '../../../../../../../../src/plugins/es_ui_shared/public'; - import { validateId } from './validate_id'; import { validateIndexPattern } from './validate_index_pattern'; import { validateRollupIndex } from './validate_rollup_index'; @@ -66,7 +64,7 @@ export const stepIdToStepConfigMap = { // a few hours as they're being restarted. A delay of 1d would allow them that period to reboot // and the "expense" is pretty negligible in most cases: 1 day of extra non-rolled-up data. rollupDelay: '1d', - cronFrequency: WEEK, + cronFrequency: 'WEEK', fieldToPreferredValueMap: {}, }; diff --git a/x-pack/plugins/rollup/public/test/client_integration/job_create_logistics.test.js b/x-pack/plugins/rollup/public/test/client_integration/job_create_logistics.test.js index fabd2a0be24541..1c54a42cbee63b 100644 --- a/x-pack/plugins/rollup/public/test/client_integration/job_create_logistics.test.js +++ b/x-pack/plugins/rollup/public/test/client_integration/job_create_logistics.test.js @@ -181,6 +181,11 @@ describe('Create Rollup Job, step 1: Logistics', () => { expect(options).toEqual(['minute', 'hour', 'day', 'week', 'month', 'year']); }); + it('should default to "WEEK"', () => { + const frequencySelect = find('cronFrequencySelect'); + expect(frequencySelect.props().value).toBe('WEEK'); + }); + describe('every minute', () => { it('should not have any additional configuration', () => { changeFrequency('MINUTE'); From 37d7ea271378818f321059d47aed355d564fe8a8 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 25 Feb 2021 11:11:15 -0700 Subject: [PATCH 13/40] [Maps] fix index pattern references not extracted for tracks source (#92226) * [Maps] fix index pattern references not extracted for tracks source * merge with master * tslint * tslint Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../maps/common/migrations/references.js | 107 ---------------- ...{references.test.js => references.test.ts} | 2 +- .../maps/common/migrations/references.ts | 120 ++++++++++++++++++ .../embeddable/map_embeddable_factory.ts | 5 +- .../maps/public/map_attribute_service.ts | 1 - .../server/maps_telemetry/maps_telemetry.ts | 6 +- 6 files changed, 128 insertions(+), 113 deletions(-) delete mode 100644 x-pack/plugins/maps/common/migrations/references.js rename x-pack/plugins/maps/common/migrations/{references.test.js => references.test.ts} (98%) create mode 100644 x-pack/plugins/maps/common/migrations/references.ts diff --git a/x-pack/plugins/maps/common/migrations/references.js b/x-pack/plugins/maps/common/migrations/references.js deleted file mode 100644 index ab8edbefb27c21..00000000000000 --- a/x-pack/plugins/maps/common/migrations/references.js +++ /dev/null @@ -1,107 +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. - */ - -// Can not use public Layer classes to extract references since this logic must run in both client and server. - -import _ from 'lodash'; -import { SOURCE_TYPES } from '../constants'; - -function doesSourceUseIndexPattern(layerDescriptor) { - const sourceType = _.get(layerDescriptor, 'sourceDescriptor.type'); - return ( - sourceType === SOURCE_TYPES.ES_GEO_GRID || - sourceType === SOURCE_TYPES.ES_SEARCH || - sourceType === SOURCE_TYPES.ES_PEW_PEW - ); -} - -export function extractReferences({ attributes, references = [] }) { - if (!attributes.layerListJSON) { - return { attributes, references }; - } - - const extractedReferences = []; - - const layerList = JSON.parse(attributes.layerListJSON); - layerList.forEach((layer, layerIndex) => { - // Extract index-pattern references from source descriptor - if (doesSourceUseIndexPattern(layer) && _.has(layer, 'sourceDescriptor.indexPatternId')) { - const refName = `layer_${layerIndex}_source_index_pattern`; - extractedReferences.push({ - name: refName, - type: 'index-pattern', - id: layer.sourceDescriptor.indexPatternId, - }); - delete layer.sourceDescriptor.indexPatternId; - layer.sourceDescriptor.indexPatternRefName = refName; - } - - // Extract index-pattern references from join - const joins = _.get(layer, 'joins', []); - joins.forEach((join, joinIndex) => { - if (_.has(join, 'right.indexPatternId')) { - const refName = `layer_${layerIndex}_join_${joinIndex}_index_pattern`; - extractedReferences.push({ - name: refName, - type: 'index-pattern', - id: join.right.indexPatternId, - }); - delete join.right.indexPatternId; - join.right.indexPatternRefName = refName; - } - }); - }); - - return { - attributes: { - ...attributes, - layerListJSON: JSON.stringify(layerList), - }, - references: references.concat(extractedReferences), - }; -} - -function findReference(targetName, references) { - const reference = references.find((reference) => reference.name === targetName); - if (!reference) { - throw new Error(`Could not find reference "${targetName}"`); - } - return reference; -} - -export function injectReferences({ attributes, references }) { - if (!attributes.layerListJSON) { - return { attributes }; - } - - const layerList = JSON.parse(attributes.layerListJSON); - layerList.forEach((layer) => { - // Inject index-pattern references into source descriptor - if (doesSourceUseIndexPattern(layer) && _.has(layer, 'sourceDescriptor.indexPatternRefName')) { - const reference = findReference(layer.sourceDescriptor.indexPatternRefName, references); - layer.sourceDescriptor.indexPatternId = reference.id; - delete layer.sourceDescriptor.indexPatternRefName; - } - - // Inject index-pattern references into join - const joins = _.get(layer, 'joins', []); - joins.forEach((join) => { - if (_.has(join, 'right.indexPatternRefName')) { - const reference = findReference(join.right.indexPatternRefName, references); - join.right.indexPatternId = reference.id; - delete join.right.indexPatternRefName; - } - }); - }); - - return { - attributes: { - ...attributes, - layerListJSON: JSON.stringify(layerList), - }, - }; -} diff --git a/x-pack/plugins/maps/common/migrations/references.test.js b/x-pack/plugins/maps/common/migrations/references.test.ts similarity index 98% rename from x-pack/plugins/maps/common/migrations/references.test.js rename to x-pack/plugins/maps/common/migrations/references.test.ts index 3b8b7de441be4f..5b749022bb62b7 100644 --- a/x-pack/plugins/maps/common/migrations/references.test.js +++ b/x-pack/plugins/maps/common/migrations/references.test.ts @@ -128,7 +128,7 @@ describe('injectReferences', () => { const attributes = { title: 'my map', }; - expect(injectReferences({ attributes })).toEqual({ + expect(injectReferences({ attributes, references: [] })).toEqual({ attributes: { title: 'my map', }, diff --git a/x-pack/plugins/maps/common/migrations/references.ts b/x-pack/plugins/maps/common/migrations/references.ts new file mode 100644 index 00000000000000..d48be6bd56fbed --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/references.ts @@ -0,0 +1,120 @@ +/* + * 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. + */ + +// Can not use public Layer classes to extract references since this logic must run in both client and server. + +import { SavedObjectReference } from '../../../../../src/core/types'; +import { MapSavedObjectAttributes } from '../map_saved_object_type'; +import { LayerDescriptor } from '../descriptor_types'; + +interface IndexPatternReferenceDescriptor { + indexPatternId?: string; + indexPatternRefName?: string; +} + +export function extractReferences({ + attributes, + references = [], +}: { + attributes: MapSavedObjectAttributes; + references?: SavedObjectReference[]; +}) { + if (!attributes.layerListJSON) { + return { attributes, references }; + } + + const extractedReferences: SavedObjectReference[] = []; + + const layerList: LayerDescriptor[] = JSON.parse(attributes.layerListJSON); + layerList.forEach((layer, layerIndex) => { + // Extract index-pattern references from source descriptor + if (layer.sourceDescriptor && 'indexPatternId' in layer.sourceDescriptor) { + const sourceDescriptor = layer.sourceDescriptor as IndexPatternReferenceDescriptor; + const refName = `layer_${layerIndex}_source_index_pattern`; + extractedReferences.push({ + name: refName, + type: 'index-pattern', + id: sourceDescriptor.indexPatternId!, + }); + delete sourceDescriptor.indexPatternId; + sourceDescriptor.indexPatternRefName = refName; + } + + // Extract index-pattern references from join + const joins = layer.joins ? layer.joins : []; + joins.forEach((join, joinIndex) => { + if ('indexPatternId' in join.right) { + const sourceDescriptor = join.right as IndexPatternReferenceDescriptor; + const refName = `layer_${layerIndex}_join_${joinIndex}_index_pattern`; + extractedReferences.push({ + name: refName, + type: 'index-pattern', + id: sourceDescriptor.indexPatternId!, + }); + delete sourceDescriptor.indexPatternId; + sourceDescriptor.indexPatternRefName = refName; + } + }); + }); + + return { + attributes: { + ...attributes, + layerListJSON: JSON.stringify(layerList), + }, + references: references.concat(extractedReferences), + }; +} + +function findReference(targetName: string, references: SavedObjectReference[]) { + const reference = references.find(({ name }) => name === targetName); + if (!reference) { + throw new Error(`Could not find reference "${targetName}"`); + } + return reference; +} + +export function injectReferences({ + attributes, + references, +}: { + attributes: MapSavedObjectAttributes; + references: SavedObjectReference[]; +}) { + if (!attributes.layerListJSON) { + return { attributes }; + } + + const layerList: LayerDescriptor[] = JSON.parse(attributes.layerListJSON); + layerList.forEach((layer) => { + // Inject index-pattern references into source descriptor + if (layer.sourceDescriptor && 'indexPatternRefName' in layer.sourceDescriptor) { + const sourceDescriptor = layer.sourceDescriptor as IndexPatternReferenceDescriptor; + const reference = findReference(sourceDescriptor.indexPatternRefName!, references); + sourceDescriptor.indexPatternId = reference.id; + delete sourceDescriptor.indexPatternRefName; + } + + // Inject index-pattern references into join + const joins = layer.joins ? layer.joins : []; + joins.forEach((join) => { + if ('indexPatternRefName' in join.right) { + const sourceDescriptor = join.right as IndexPatternReferenceDescriptor; + const reference = findReference(sourceDescriptor.indexPatternRefName!, references); + sourceDescriptor.indexPatternId = reference.id; + delete sourceDescriptor.indexPatternRefName; + } + }); + }); + + return { + attributes: { + ...attributes, + layerListJSON: JSON.stringify(layerList), + }, + }; +} diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts index 7e15bfa9a340e9..b1944f8136709a 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts @@ -16,7 +16,6 @@ import { MAP_SAVED_OBJECT_TYPE, APP_ICON } from '../../common/constants'; import { getMapEmbeddableDisplayName } from '../../common/i18n_getters'; import { MapByReferenceInput, MapEmbeddableInput, MapByValueInput } from './types'; import { lazyLoadMapModules } from '../lazy_load_bundle'; -// @ts-expect-error import { extractReferences } from '../../common/migrations/references'; export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { @@ -69,7 +68,9 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { const maybeMapByValueInput = state as EmbeddableStateWithType | MapByValueInput; if ((maybeMapByValueInput as MapByValueInput).attributes !== undefined) { - const { references } = extractReferences(maybeMapByValueInput); + const { references } = extractReferences({ + attributes: (maybeMapByValueInput as MapByValueInput).attributes, + }); return { state, references }; } diff --git a/x-pack/plugins/maps/public/map_attribute_service.ts b/x-pack/plugins/maps/public/map_attribute_service.ts index 71b0b7f28a2ff6..5f7c45b1b42d72 100644 --- a/x-pack/plugins/maps/public/map_attribute_service.ts +++ b/x-pack/plugins/maps/public/map_attribute_service.ts @@ -12,7 +12,6 @@ import { MAP_SAVED_OBJECT_TYPE } from '../common/constants'; import { getMapEmbeddableDisplayName } from '../common/i18n_getters'; import { checkForDuplicateTitle, OnSaveProps } from '../../../../src/plugins/saved_objects/public'; import { getCoreOverlays, getEmbeddableService, getSavedObjectsClient } from './kibana_services'; -// @ts-expect-error import { extractReferences, injectReferences } from '../common/migrations/references'; import { MapByValueInput, MapByReferenceInput } from './embeddable/types'; diff --git a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts index 8f9b529ae30f5b..bf180c514c56fa 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts @@ -24,7 +24,6 @@ import { import { MapSavedObject, MapSavedObjectAttributes } from '../../common/map_saved_object_type'; import { getIndexPatternsService, getInternalRepository } from '../kibana_server_services'; import { MapsConfigType } from '../../config'; -// @ts-expect-error import { injectReferences } from '././../../common/migrations/references'; interface Settings { @@ -314,7 +313,10 @@ export async function getMapsTelemetry(config: MapsConfigType): Promise { const savedObjectsWithIndexPatternIds = savedObjects.map((savedObject) => { - return injectReferences(savedObject); + return { + ...savedObject, + ...injectReferences(savedObject), + }; }); return layerLists.push(...getLayerLists(savedObjectsWithIndexPatternIds)); } From 3b66bf7cc553b3516093b5d6f6bb10abd157b61b Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Thu, 25 Feb 2021 18:24:39 +0000 Subject: [PATCH 14/40] [ML] Adding ml_capabilities api tests (#92786) * [ML] Adding ml_capabilities api tests * updating test text * changes based on review --- x-pack/test/api_integration/apis/ml/index.ts | 1 + .../apis/ml/system/capabilities.ts | 124 ++++++++++ .../api_integration/apis/ml/system/index.ts | 15 ++ .../apis/ml/system/space_capabilities.ts | 222 ++++++++++++++++++ 4 files changed, 362 insertions(+) create mode 100644 x-pack/test/api_integration/apis/ml/system/capabilities.ts create mode 100644 x-pack/test/api_integration/apis/ml/system/index.ts create mode 100644 x-pack/test/api_integration/apis/ml/system/space_capabilities.ts diff --git a/x-pack/test/api_integration/apis/ml/index.ts b/x-pack/test/api_integration/apis/ml/index.ts index f03756a2885bb5..41e94d69d2e9b2 100644 --- a/x-pack/test/api_integration/apis/ml/index.ts +++ b/x-pack/test/api_integration/apis/ml/index.ts @@ -70,5 +70,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./calendars')); loadTestFile(require.resolve('./annotations')); loadTestFile(require.resolve('./saved_objects')); + loadTestFile(require.resolve('./system')); }); } diff --git a/x-pack/test/api_integration/apis/ml/system/capabilities.ts b/x-pack/test/api_integration/apis/ml/system/capabilities.ts new file mode 100644 index 00000000000000..d8ab2a30ef7fba --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/system/capabilities.ts @@ -0,0 +1,124 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { MlCapabilitiesResponse } from '../../../../../plugins/ml/common/types/capabilities'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + async function runRequest(user: USER): Promise { + const { body } = await supertest + .get(`/api/ml/ml_capabilities`) + .auth(user, ml.securityCommon.getPasswordForUser(user)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + return body; + } + + describe('ml_capabilities', () => { + describe('get capabilities', function () { + it('should be enabled in space', async () => { + const { mlFeatureEnabledInSpace } = await runRequest(USER.ML_POWERUSER); + expect(mlFeatureEnabledInSpace).to.eql(true); + }); + + it('should have upgradeInProgress false', async () => { + const { upgradeInProgress } = await runRequest(USER.ML_POWERUSER); + expect(upgradeInProgress).to.eql(false); + }); + + it('should have full license', async () => { + const { isPlatinumOrTrialLicense } = await runRequest(USER.ML_POWERUSER); + expect(isPlatinumOrTrialLicense).to.eql(true); + }); + + it('should have the right number of capabilities', async () => { + const { capabilities } = await runRequest(USER.ML_POWERUSER); + expect(Object.keys(capabilities).length).to.eql(29); + }); + + it('should get viewer capabilities', async () => { + const { capabilities } = await runRequest(USER.ML_VIEWER); + + expect(capabilities).to.eql({ + canCreateJob: false, + canDeleteJob: false, + canOpenJob: false, + canCloseJob: false, + canUpdateJob: false, + canForecastJob: false, + canCreateDatafeed: false, + canDeleteDatafeed: false, + canStartStopDatafeed: false, + canUpdateDatafeed: false, + canPreviewDatafeed: false, + canGetFilters: false, + canCreateCalendar: false, + canDeleteCalendar: false, + canCreateFilter: false, + canDeleteFilter: false, + canCreateDataFrameAnalytics: false, + canDeleteDataFrameAnalytics: false, + canStartStopDataFrameAnalytics: false, + canCreateMlAlerts: false, + canAccessML: true, + canGetJobs: true, + canGetDatafeeds: true, + canGetCalendars: true, + canFindFileStructure: true, + canGetDataFrameAnalytics: true, + canGetAnnotations: true, + canCreateAnnotation: true, + canDeleteAnnotation: true, + }); + }); + + it('should get power user capabilities', async () => { + const { capabilities } = await runRequest(USER.ML_POWERUSER); + + expect(capabilities).to.eql({ + canCreateJob: true, + canDeleteJob: true, + canOpenJob: true, + canCloseJob: true, + canUpdateJob: true, + canForecastJob: true, + canCreateDatafeed: true, + canDeleteDatafeed: true, + canStartStopDatafeed: true, + canUpdateDatafeed: true, + canPreviewDatafeed: true, + canGetFilters: true, + canCreateCalendar: true, + canDeleteCalendar: true, + canCreateFilter: true, + canDeleteFilter: true, + canCreateDataFrameAnalytics: true, + canDeleteDataFrameAnalytics: true, + canStartStopDataFrameAnalytics: true, + canCreateMlAlerts: true, + canAccessML: true, + canGetJobs: true, + canGetDatafeeds: true, + canGetCalendars: true, + canFindFileStructure: true, + canGetDataFrameAnalytics: true, + canGetAnnotations: true, + canCreateAnnotation: true, + canDeleteAnnotation: true, + }); + }); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/system/index.ts b/x-pack/test/api_integration/apis/ml/system/index.ts new file mode 100644 index 00000000000000..68ffd5fa267e90 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/system/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('system', function () { + loadTestFile(require.resolve('./capabilities')); + loadTestFile(require.resolve('./space_capabilities')); + }); +} diff --git a/x-pack/test/api_integration/apis/ml/system/space_capabilities.ts b/x-pack/test/api_integration/apis/ml/system/space_capabilities.ts new file mode 100644 index 00000000000000..cd922bf4bae924 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/system/space_capabilities.ts @@ -0,0 +1,222 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { MlCapabilitiesResponse } from '../../../../../plugins/ml/common/types/capabilities'; + +const idSpaceWithMl = 'space_with_ml'; +const idSpaceNoMl = 'space_no_ml'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const spacesService = getService('spaces'); + const ml = getService('ml'); + + async function runRequest(user: USER, space?: string): Promise { + const { body } = await supertest + .get(`${space ? `/s/${space}` : ''}/api/ml/ml_capabilities`) + .auth(user, ml.securityCommon.getPasswordForUser(user)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + return body; + } + + describe('ml_capabilities in spaces', () => { + before(async () => { + await spacesService.create({ id: idSpaceWithMl, name: 'space_one', disabledFeatures: [] }); + await spacesService.create({ id: idSpaceNoMl, name: 'space_two', disabledFeatures: ['ml'] }); + }); + + after(async () => { + await spacesService.delete(idSpaceWithMl); + await spacesService.delete(idSpaceNoMl); + }); + + describe('get capabilities', function () { + it('should be enabled in space - space with ML', async () => { + const { mlFeatureEnabledInSpace } = await runRequest(USER.ML_POWERUSER, idSpaceWithMl); + expect(mlFeatureEnabledInSpace).to.eql(true); + }); + it('should not be enabled in space - space without ML', async () => { + const { mlFeatureEnabledInSpace } = await runRequest(USER.ML_POWERUSER, idSpaceNoMl); + expect(mlFeatureEnabledInSpace).to.eql(false); + }); + + it('should have upgradeInProgress false - space with ML', async () => { + const { upgradeInProgress } = await runRequest(USER.ML_POWERUSER, idSpaceWithMl); + expect(upgradeInProgress).to.eql(false); + }); + it('should have upgradeInProgress false - space without ML', async () => { + const { upgradeInProgress } = await runRequest(USER.ML_POWERUSER, idSpaceNoMl); + expect(upgradeInProgress).to.eql(false); + }); + + it('should have full license - space with ML', async () => { + const { isPlatinumOrTrialLicense } = await runRequest(USER.ML_POWERUSER, idSpaceWithMl); + expect(isPlatinumOrTrialLicense).to.eql(true); + }); + it('should have full license - space without ML', async () => { + const { isPlatinumOrTrialLicense } = await runRequest(USER.ML_POWERUSER, idSpaceNoMl); + expect(isPlatinumOrTrialLicense).to.eql(true); + }); + + it('should have the right number of capabilities - space with ML', async () => { + const { capabilities } = await runRequest(USER.ML_POWERUSER, idSpaceWithMl); + expect(Object.keys(capabilities).length).to.eql(29); + }); + it('should have the right number of capabilities - space without ML', async () => { + const { capabilities } = await runRequest(USER.ML_POWERUSER, idSpaceNoMl); + expect(Object.keys(capabilities).length).to.eql(29); + }); + + it('should get viewer capabilities - space with ML', async () => { + const { capabilities } = await runRequest(USER.ML_VIEWER, idSpaceWithMl); + expect(capabilities).to.eql({ + canCreateJob: false, + canDeleteJob: false, + canOpenJob: false, + canCloseJob: false, + canUpdateJob: false, + canForecastJob: false, + canCreateDatafeed: false, + canDeleteDatafeed: false, + canStartStopDatafeed: false, + canUpdateDatafeed: false, + canPreviewDatafeed: false, + canGetFilters: false, + canCreateCalendar: false, + canDeleteCalendar: false, + canCreateFilter: false, + canDeleteFilter: false, + canCreateDataFrameAnalytics: false, + canDeleteDataFrameAnalytics: false, + canStartStopDataFrameAnalytics: false, + canCreateMlAlerts: false, + canAccessML: true, + canGetJobs: true, + canGetDatafeeds: true, + canGetCalendars: true, + canFindFileStructure: true, + canGetDataFrameAnalytics: true, + canGetAnnotations: true, + canCreateAnnotation: true, + canDeleteAnnotation: true, + }); + }); + + it('should get viewer capabilities - space without ML', async () => { + const { capabilities } = await runRequest(USER.ML_VIEWER, idSpaceNoMl); + expect(capabilities).to.eql({ + canCreateJob: false, + canDeleteJob: false, + canOpenJob: false, + canCloseJob: false, + canUpdateJob: false, + canForecastJob: false, + canCreateDatafeed: false, + canDeleteDatafeed: false, + canStartStopDatafeed: false, + canUpdateDatafeed: false, + canPreviewDatafeed: false, + canGetFilters: false, + canCreateCalendar: false, + canDeleteCalendar: false, + canCreateFilter: false, + canDeleteFilter: false, + canCreateDataFrameAnalytics: false, + canDeleteDataFrameAnalytics: false, + canStartStopDataFrameAnalytics: false, + canCreateMlAlerts: false, + canAccessML: false, + canGetJobs: false, + canGetDatafeeds: false, + canGetCalendars: false, + canFindFileStructure: false, + canGetDataFrameAnalytics: false, + canGetAnnotations: false, + canCreateAnnotation: false, + canDeleteAnnotation: false, + }); + }); + + it('should get power user capabilities - space with ML', async () => { + const { capabilities } = await runRequest(USER.ML_POWERUSER, idSpaceWithMl); + expect(capabilities).to.eql({ + canCreateJob: true, + canDeleteJob: true, + canOpenJob: true, + canCloseJob: true, + canUpdateJob: true, + canForecastJob: true, + canCreateDatafeed: true, + canDeleteDatafeed: true, + canStartStopDatafeed: true, + canUpdateDatafeed: true, + canPreviewDatafeed: true, + canGetFilters: true, + canCreateCalendar: true, + canDeleteCalendar: true, + canCreateFilter: true, + canDeleteFilter: true, + canCreateDataFrameAnalytics: true, + canDeleteDataFrameAnalytics: true, + canStartStopDataFrameAnalytics: true, + canCreateMlAlerts: true, + canAccessML: true, + canGetJobs: true, + canGetDatafeeds: true, + canGetCalendars: true, + canFindFileStructure: true, + canGetDataFrameAnalytics: true, + canGetAnnotations: true, + canCreateAnnotation: true, + canDeleteAnnotation: true, + }); + }); + + it('should get power user capabilities - space without ML', async () => { + const { capabilities } = await runRequest(USER.ML_POWERUSER, idSpaceNoMl); + expect(capabilities).to.eql({ + canCreateJob: false, + canDeleteJob: false, + canOpenJob: false, + canCloseJob: false, + canUpdateJob: false, + canForecastJob: false, + canCreateDatafeed: false, + canDeleteDatafeed: false, + canStartStopDatafeed: false, + canUpdateDatafeed: false, + canPreviewDatafeed: false, + canGetFilters: false, + canCreateCalendar: false, + canDeleteCalendar: false, + canCreateFilter: false, + canDeleteFilter: false, + canCreateDataFrameAnalytics: false, + canDeleteDataFrameAnalytics: false, + canStartStopDataFrameAnalytics: false, + canCreateMlAlerts: false, + canAccessML: false, + canGetJobs: false, + canGetDatafeeds: false, + canGetCalendars: false, + canFindFileStructure: false, + canGetDataFrameAnalytics: false, + canGetAnnotations: false, + canCreateAnnotation: false, + canDeleteAnnotation: false, + }); + }); + }); + }); +}; From 06d966eca5730d9bcd1166a7c1021c340d9fdb0f Mon Sep 17 00:00:00 2001 From: joxley-elastic <73481302+joxley-elastic@users.noreply.github.com> Date: Thu, 25 Feb 2021 13:30:59 -0500 Subject: [PATCH 15/40] [Console] Autocompletion of component_templates (#91180) --- .../cluster.get_component_template.json | 16 ++++++++++++++++ .../cluster.put_component_template.json | 14 ++++++++++++++ .../cluster.put_component_template.json | 13 +++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json create mode 100644 src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json create mode 100644 src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.put_component_template.json diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json new file mode 100644 index 00000000000000..f2ef49f6e13e2b --- /dev/null +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json @@ -0,0 +1,16 @@ +{ + "indices.get_template": { + "url_params": { + "flat_settings": "__flag__", + "master_timeout": "", + "local": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_component_template", + "_component_template/{name}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-component-templates.html" } +} diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json new file mode 100644 index 00000000000000..fbd5a0905d4d83 --- /dev/null +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json @@ -0,0 +1,14 @@ +{ + "indices.put_template": { + "url_params": { + "create": "__flag__", + "master_timeout": "" + }, + "methods": [ + "PUT" + ], + "patterns": [ + "_component_template/{name}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-component-template.html" } +} diff --git a/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.put_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.put_component_template.json new file mode 100644 index 00000000000000..7fdd9ff23fd179 --- /dev/null +++ b/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.put_component_template.json @@ -0,0 +1,13 @@ +{ + "indices.put_template": { + "data_autocomplete_rules": { + "template": {}, + "aliases": {}, + "settings": {}, + "mappings": {}, + "version": 0, + "_meta": {}, + "allow_auto_create": false + } + } +} From c561a18b1e61eb5a96b4f35a04d355c254fb71f6 Mon Sep 17 00:00:00 2001 From: Kerry Gallagher <471693+Kerry350@users.noreply.github.com> Date: Thu, 25 Feb 2021 18:44:47 +0000 Subject: [PATCH 16/40] [Logs UI] Hide Create Alert option when user lacks privileges (#92000) * Hide Create Alerts option when lacking permissions --- .../components/alert_dropdown.tsx | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx index f13ce33a44b3da..7cd6295cdcf408 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/alert_dropdown.tsx @@ -6,12 +6,34 @@ */ import React, { useState, useCallback, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { AlertFlyout } from './alert_flyout'; import { useLinkProps } from '../../../hooks/use_link_props'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; + +const readOnlyUserTooltipContent = i18n.translate( + 'xpack.infra.logs.alertDropdown.readOnlyCreateAlertContent', + { + defaultMessage: 'Creating alerts requires more permissions in this application.', + } +); + +const readOnlyUserTooltipTitle = i18n.translate( + 'xpack.infra.logs.alertDropdown.readOnlyCreateAlertTitle', + { + defaultMessage: 'Read only', + } +); export const AlertDropdown = () => { + const { + services: { + application: { capabilities }, + }, + } = useKibanaContextForPlugin(); + const canCreateAlerts = capabilities?.logs?.save ?? false; const [popoverOpen, setPopoverOpen] = useState(false); const [flyoutVisible, setFlyoutVisible] = useState(false); const manageAlertsLinkProps = useLinkProps( @@ -34,7 +56,14 @@ export const AlertDropdown = () => { const menuItems = useMemo(() => { return [ - setFlyoutVisible(true)}> + setFlyoutVisible(true)} + toolTipContent={!canCreateAlerts ? readOnlyUserTooltipContent : undefined} + toolTipTitle={!canCreateAlerts ? readOnlyUserTooltipTitle : undefined} + > { /> , ]; - }, [manageAlertsLinkProps]); + }, [manageAlertsLinkProps, canCreateAlerts]); return ( <> From 83b22dc568dadb09eb2665bfc0464e4363b5e42e Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Thu, 25 Feb 2021 10:56:32 -0800 Subject: [PATCH 17/40] [Actions][Doc] Added user doc for default value for PagerDuty deduplication key. (#92746) * [Actions][Doc] Added user doc for default value for PagerDuty deduplication key. * Apply suggestions from code review Co-authored-by: Gidi Meir Morris Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> Co-authored-by: Gidi Meir Morris Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- docs/user/alerting/action-types/pagerduty.asciidoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/alerting/action-types/pagerduty.asciidoc b/docs/user/alerting/action-types/pagerduty.asciidoc index c3185aaad553a6..cadf8e0b16a44d 100644 --- a/docs/user/alerting/action-types/pagerduty.asciidoc +++ b/docs/user/alerting/action-types/pagerduty.asciidoc @@ -105,7 +105,7 @@ See <> for how to obtain the endpoint and + * The action’s type: Trigger, Resolve, or Acknowledge. * The event’s severity: Info, warning, error, or critical. -* An array of different fields, including the timestamp, group, class, component, and your dedup key. +* An array of different fields, including the timestamp, group, class, component, and your dedup key. By default, the dedup is configured to create a new PagerDuty incident for each alert instance and reuse the incident when a recovered alert instance reactivates. Depending on your custom needs, assign them variables from the alerting context. To see the available context variables, click on the *Add alert variable* icon next to each corresponding field. For more details on these parameters, see the @@ -179,7 +179,7 @@ PagerDuty actions have the following properties: Severity:: The perceived severity of on the affected system. This can be one of `Critical`, `Error`, `Warning` or `Info`(default). Event action:: One of `Trigger` (default), `Resolve`, or `Acknowledge`. See https://v2.developer.pagerduty.com/docs/events-api-v2#event-action[event action] for more details. -Dedup Key:: All actions sharing this key will be associated with the same PagerDuty alert. This value is used to correlate trigger and resolution. This value is *optional*, and if unset defaults to `action:`. The maximum length is *255* characters. See https://v2.developer.pagerduty.com/docs/events-api-v2#alert-de-duplication[alert deduplication] for details. +Dedup Key:: All actions sharing this key will be associated with the same PagerDuty alert. This value is used to correlate trigger and resolution. This value is *optional*, and if not set, defaults to `:`. The maximum length is *255* characters. See https://v2.developer.pagerduty.com/docs/events-api-v2#alert-de-duplication[alert deduplication] for details. Timestamp:: An *optional* https://v2.developer.pagerduty.com/v2/docs/types#datetime[ISO-8601 format date-time], indicating the time the event was detected or generated. Component:: An *optional* value indicating the component of the source machine that is responsible for the event, for example `mysql` or `eth0`. Group:: An *optional* value indicating the logical grouping of components of a service, for example `app-stack`. From 1f58bc2ebbe28744b9a250d82f03dbfd774b3a64 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 25 Feb 2021 11:59:02 -0700 Subject: [PATCH 18/40] [ts] disable forceConsistentCasingInFileNames, it seems broken (#92849) Co-authored-by: spalger --- tsconfig.base.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.base.json b/tsconfig.base.json index c63d43b4cb6ad5..865806cffe5bb1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -39,7 +39,7 @@ // "resolveJsonModule" allows for importing, extracting types from and generating .json files. "resolveJsonModule": true, // Disallow inconsistently-cased references to the same file. - "forceConsistentCasingInFileNames": true, + "forceConsistentCasingInFileNames": false, // Forbid unused local variables as the rule was deprecated by ts-lint "noUnusedLocals": true, // Provide full support for iterables in for..of, spread and destructuring when targeting ES5 or ES3. From 272bf97e311b5a019b267aac74bfe26a767ff927 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Thu, 25 Feb 2021 14:17:47 -0500 Subject: [PATCH 19/40] [Discover] Fix persistence of "hide/show chart" in saved search (#92731) --- .../application/helpers/persist_saved_search.ts | 2 +- .../apps/discover/_discover_histogram.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/plugins/discover/public/application/helpers/persist_saved_search.ts b/src/plugins/discover/public/application/helpers/persist_saved_search.ts index 2e4ab90ee58e54..f44d4650da56a2 100644 --- a/src/plugins/discover/public/application/helpers/persist_saved_search.ts +++ b/src/plugins/discover/public/application/helpers/persist_saved_search.ts @@ -49,7 +49,7 @@ export async function persistSavedSearch( if (state.grid) { savedSearch.grid = state.grid; } - if (state.hideChart) { + if (typeof state.hideChart !== 'undefined') { savedSearch.hideChart = state.hideChart; } diff --git a/test/functional/apps/discover/_discover_histogram.ts b/test/functional/apps/discover/_discover_histogram.ts index 9a6692dc793d67..2a6096f8d1a786 100644 --- a/test/functional/apps/discover/_discover_histogram.ts +++ b/test/functional/apps/discover/_discover_histogram.ts @@ -97,15 +97,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(canvasExists).to.be(false); await PageObjects.discover.saveSearch(savedSearch); await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.discover.clickNewSearchButton(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.discover.loadSavedSearch('persisted hidden histogram'); + await PageObjects.header.waitUntilLoadingHasFinished(); canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(false); await testSubjects.click('discoverChartToggle'); canvasExists = await elasticChart.canvasExists(); expect(canvasExists).to.be(true); - await PageObjects.discover.clickResetSavedSearchButton(); + await PageObjects.discover.saveSearch('persisted hidden histogram'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.discover.clickNewSearchButton(); + await PageObjects.discover.loadSavedSearch('persisted hidden histogram'); await PageObjects.header.waitUntilLoadingHasFinished(); canvasExists = await elasticChart.canvasExists(); - expect(canvasExists).to.be(false); + expect(canvasExists).to.be(true); }); }); } From fa4dda0defb99939bd7aba00a4427f2775bbf06a Mon Sep 17 00:00:00 2001 From: Sandra Gonzales Date: Thu, 25 Feb 2021 14:26:34 -0500 Subject: [PATCH 20/40] [Metrics UI] use global kibana time for metrics explorer in Default View (#92520) * use global kibana time for explorer, stop using local storage for time * fix inventory time --- .../metrics/inventory_view/hooks/use_waffle_view_state.ts | 5 ++++- .../metrics_explorer/hooks/use_metric_explorer_state.ts | 8 +++++++- .../hooks/use_metrics_explorer_options.test.tsx | 2 -- .../hooks/use_metrics_explorer_options.ts | 5 +---- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts index 3a0bc009bcfd08..3fa6bc065e7c11 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/hooks/use_waffle_view_state.ts @@ -78,7 +78,9 @@ export const useWaffleViewState = () => { region: newState.region, legend: newState.legend, }); - if (newState.time) { + // if this is the "Default View" view, don't update the time range to the view's time range, + // this way it will use the global Kibana time or the default time already set + if (newState.time && newState.id !== '0') { setWaffleTimeState({ currentTime: newState.time, isAutoReloading: newState.autoReload, @@ -100,4 +102,5 @@ export type WaffleViewState = WaffleOptionsState & { time: number; autoReload: boolean; filterQuery: WaffleFiltersState; + id?: string; }; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts index 424e456aa9dd85..eb5a4633d4fa9c 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metric_explorer_state.ts @@ -24,6 +24,7 @@ export interface MetricExplorerViewState { chartOptions: MetricsExplorerChartOptions; currentTimerange: MetricsExplorerTimeOptions; options: MetricsExplorerOptions; + id?: string; } export const useMetricsExplorerState = ( @@ -42,6 +43,7 @@ export const useMetricsExplorerState = ( setTimeRange, setOptions, } = useContext(MetricsExplorerOptionsContainer.Context); + const { loading, error, data, loadData } = useMetricsExplorerData( options, source, @@ -121,7 +123,11 @@ export const useMetricsExplorerState = ( setChartOptions(vs.chartOptions); } if (vs.currentTimerange) { - setTimeRange(vs.currentTimerange); + // if this is the "Default View" view, don't update the time range to the view's time range, + // this way it will use the global Kibana time or the default time already set + if (vs.id !== '0') { + setTimeRange(vs.currentTimerange); + } } if (vs.options) { setOptions(vs.options); diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx index 5f182203dd86cb..37200f75d109cf 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.test.tsx @@ -68,7 +68,6 @@ describe('useMetricExplorerOptions', () => { expect(result.current.currentTimerange).toEqual(DEFAULT_TIMERANGE); expect(result.current.isAutoReloading).toEqual(false); expect(STORE.MetricsExplorerOptions).toEqual(JSON.stringify(DEFAULT_OPTIONS)); - expect(STORE.MetricsExplorerTimeRange).toEqual(JSON.stringify(DEFAULT_TIMERANGE)); }); it('should change the store when options update', () => { @@ -96,7 +95,6 @@ describe('useMetricExplorerOptions', () => { }); rerender(); expect(result.current.currentTimerange).toEqual(newTimeRange); - expect(STORE.MetricsExplorerTimeRange).toEqual(JSON.stringify(newTimeRange)); }); it('should load from store when available', () => { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts index 06140dd976691a..c1e5be94acc03e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts @@ -163,10 +163,7 @@ export const useMetricsExplorerOptions = () => { 'MetricsExplorerOptions', DEFAULT_OPTIONS ); - const [currentTimerange, setTimeRange] = useStateWithLocalStorage( - 'MetricsExplorerTimeRange', - defaultTimeRange - ); + const [currentTimerange, setTimeRange] = useState(defaultTimeRange); useSyncKibanaTimeFilterTime(TIME_DEFAULTS, { from: currentTimerange.from, From 93066631159e3ec6c40c5d5d49702d60fe8e5d02 Mon Sep 17 00:00:00 2001 From: Pete Hampton Date: Thu, 25 Feb 2021 20:15:09 +0000 Subject: [PATCH 21/40] [7.12][Telemetry] Security telemetry allowlist fix. (#92850) * Security telemetry allowlist fix. * Also add process.thread. --- .../server/lib/telemetry/sender.ts | 115 +++++++++--------- 1 file changed, 58 insertions(+), 57 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts index e153c6d42225f6..6ce42eabeca5ee 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts @@ -364,74 +364,75 @@ const allowlistEventFields: AllowlistFields = { pid: true, ppid: true, }, - Target: { - process: { - Ext: { - architecture: true, - code_signature: true, - dll: true, - token: { - integrity_level_name: true, - }, + token: { + integrity_level_name: true, + }, + thread: true, + }, + Target: { + process: { + Ext: { + architecture: true, + code_signature: true, + dll: true, + token: { + integrity_level_name: true, }, - parent: { - process: { - Ext: { - architecture: true, - code_signature: true, - dll: true, - token: { - integrity_level_name: true, - }, + }, + parent: { + process: { + Ext: { + architecture: true, + code_signature: true, + dll: true, + token: { + integrity_level_name: true, }, }, }, - thread: { - Ext: { - call_stack: true, - start_address: true, - start_address_details: { - address_offset: true, - allocation_base: true, - allocation_protection: true, - allocation_size: true, - allocation_type: true, - base_address: true, - bytes_start_address: true, - compressed_bytes: true, - dest_bytes: true, - dest_bytes_disasm: true, - dest_bytes_disasm_hash: true, - pe: { - Ext: { - legal_copyright: true, - product_version: true, - code_signature: { - status: true, - subject_name: true, - trusted: true, - }, + }, + thread: { + Ext: { + call_stack: true, + start_address: true, + start_address_details: { + address_offset: true, + allocation_base: true, + allocation_protection: true, + allocation_size: true, + allocation_type: true, + base_address: true, + bytes_start_address: true, + compressed_bytes: true, + dest_bytes: true, + dest_bytes_disasm: true, + dest_bytes_disasm_hash: true, + pe: { + Ext: { + legal_copyright: true, + product_version: true, + code_signature: { + status: true, + subject_name: true, + trusted: true, }, - company: true, - description: true, - file_version: true, - imphash: true, - original_file_name: true, - product: true, }, - pe_detected: true, - region_protection: true, - region_size: true, - region_state: true, - strings: true, + company: true, + description: true, + file_version: true, + imphash: true, + original_file_name: true, + product: true, }, + pe_detected: true, + region_protection: true, + region_size: true, + region_state: true, + strings: true, }, }, }, }, - token: { - integrity_level_name: true, - }, }, }; From 0627573dbd3be2368dbe37c9c7e7a86e4783589c Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Thu, 25 Feb 2021 12:41:48 -0800 Subject: [PATCH 22/40] [Alerts][Docs] Extended README.md and the user docs with the licensing information. (#92564) * [Alerts][Docs] Extended README.md and the user docs with the licensing information. * Apply suggestions from code review Co-authored-by: Lisa Cawley Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * fixed due to comments Co-authored-by: Lisa Cawley Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- docs/user/alerting/alert-types.asciidoc | 8 ++++++++ x-pack/plugins/alerts/README.md | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/docs/user/alerting/alert-types.asciidoc b/docs/user/alerting/alert-types.asciidoc index 993d815c37f71e..5983804c5c862d 100644 --- a/docs/user/alerting/alert-types.asciidoc +++ b/docs/user/alerting/alert-types.asciidoc @@ -30,6 +30,14 @@ For domain-specific alerts, refer to the documentation for that app. * <> * <> +[NOTE] +============================================== +Some alert types are subscription features, while others are free features. +For a comparison of the Elastic subscription levels, +see {subscriptions}[the subscription page]. +============================================== + + include::stack-alerts/index-threshold.asciidoc[] include::stack-alerts/es-query.asciidoc[] include::maps-alerts/geo-alert-types.asciidoc[] diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index aab848d4555d2d..83a1ff952cb5de 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -17,6 +17,9 @@ Table of Contents - [Alert types](#alert-types) - [Methods](#methods) - [Executor](#executor) + - [Licensing](#licensing) + - [Documentation](#documentation) + - [Tests](#tests) - [Example](#example) - [Role Based Access-Control](#role-based-access-control) - [Alert Navigation](#alert-navigation) @@ -124,6 +127,19 @@ For example, if the `context` has one variable `foo` which is an object that has } ``` +## Licensing + +Currently most of the alerts are free features. But some alert types are subscription features, such as the tracking containment alert. + +## Documentation + +You should create documentation for the new alert type. Make an entry in the alert type index [`docs/user/alerting/alert-types.asciidoc`](../../../docs/user/alerting/alert-types.asciidoc) that points to a new document for the alert type that should be in the proper application directory. + +## Tests + +The alert type should have jest tests and optionaly functional tests. +In the the tests we recomend to test the expected alert execution result with a different input params, the structure of the created alert and the params validation. The rest will be guaranteed as a framework functionality. + ### Example This example receives server and threshold as parameters. It will read the CPU usage of the server and schedule actions to be executed (asynchronously by the task manager) if the reading is greater than the threshold. From 3894ecf8629c87f8d17190ea47780fccd003a806 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Thu, 25 Feb 2021 21:45:14 +0100 Subject: [PATCH 23/40] [Discover][docs] Add search for relevance (#90611) Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> Co-authored-by: Lisa Cawley --- .../images/discover-search-for-relevance.png | Bin 0 -> 282007 bytes docs/discover/search-for-relevance.asciidoc | 24 ++++++++++++++++++ docs/user/discover.asciidoc | 5 +++- 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 docs/discover/images/discover-search-for-relevance.png create mode 100644 docs/discover/search-for-relevance.asciidoc diff --git a/docs/discover/images/discover-search-for-relevance.png b/docs/discover/images/discover-search-for-relevance.png new file mode 100644 index 0000000000000000000000000000000000000000..4d59ad186ded42d4dfede73a9a80fa974e02fe89 GIT binary patch literal 282007 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, and you're a searching for +a flight that arrived or departed from `Warsaw` or `Venice` when the weather was clear. + +. In *Discover*, open the index pattern dropdown, and select `kibana_sample_data_flight`. +. In the query bar, click *KQL*, and switch to the <>. +. Search for `Warsaw OR Venice OR Clear`. +. If you don't see any results, open the time filter and select a time range that contains data. +. From the list of *Available fields*, add `_score` to the document table. +. In the document table, click the header for the `_score` column, and then sort the column by descending scores. ++ +The results are currently sorted by first `Time`, and then by `_score`. +. To sort only by `_score`, remove the `Time` field. ++ +Your table now shows documents with the best matches, from most to least relevant. ++ +[role="screenshot"] +image::images/discover-search-for-relevance.png["Example of a search for relevance"] diff --git a/docs/user/discover.asciidoc b/docs/user/discover.asciidoc index a08713421a7fbc..42ac1e22ce1675 100644 --- a/docs/user/discover.asciidoc +++ b/docs/user/discover.asciidoc @@ -198,7 +198,6 @@ image:images/visualize-from-discover.png[Visualization that opens from Discover [float] === What’s next? - * <>. * <> to better meet your needs. @@ -209,6 +208,8 @@ the table columns that display by default, and more. * <>. +* <>. + -- include::{kib-repo-dir}/management/index-patterns.asciidoc[] @@ -216,3 +217,5 @@ include::{kib-repo-dir}/management/index-patterns.asciidoc[] include::{kib-repo-dir}/discover/set-time-filter.asciidoc[] include::{kib-repo-dir}/discover/search.asciidoc[] + +include::{kib-repo-dir}/discover/search-for-relevance.asciidoc[] From ad19f821d6fc7ebda203086c8d8cd001adb00762 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Thu, 25 Feb 2021 12:49:00 -0800 Subject: [PATCH 24/40] [Alerts][Doc] Added README documentation for API key invalidation configuration options. (#92757) * [Alerts][Doc] Added README documentation for API key invalidation configuration options. * Apply suggestions from code review Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- x-pack/plugins/alerts/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index 83a1ff952cb5de..c57216603665dc 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -13,6 +13,7 @@ Table of Contents - [Kibana alerting](#kibana-alerting) - [Terminology](#terminology) - [Usage](#usage) + - [Alerts API keys](#alerts-api-keys) - [Limitations](#limitations) - [Alert types](#alert-types) - [Methods](#methods) @@ -53,6 +54,17 @@ A Kibana alert detects a condition and executes one or more actions when that co 2. Configure feature level privileges using RBAC 3. Create an alert using the RESTful API [Documentation](https://www.elastic.co/guide/en/kibana/master/alerts-api-update.html) (see alerts -> create). +## Alerts API keys + +When we create an alert, we generate a new API key. + +When we update, enable, or disable an alert, we must invalidate the old API key and create a new one. + +To manage the invalidation process for API keys, we use the saved object `api_key_pending_invalidation`. This object stores all API keys that were marked for invalidation when alerts were updated. +For security plugin invalidation, we schedule a task to check if the`api_key_pending_invalidation` saved object contains new API keys that are marked for invalidation earlier than the configured delay. The default value for running the task is 5 mins. +To change the schedule for the invalidation task, use the kibana.yml configuration option `xpack.alerts.invalidateApiKeysTask.interval`. +To change the default delay for the API key invalidation, use the kibana.yml configuration option `xpack.alerts.invalidateApiKeysTask.removalDelay`. + ## Limitations When security is enabled, an SSL connection to Elasticsearch is required in order to use alerting. From cb605845716b1f3a67f62f0568ab40e2e30d625a Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Thu, 25 Feb 2021 16:42:50 -0500 Subject: [PATCH 25/40] [Fleet] Add new index to fleet for artifacts being served out of fleet-server (#92860) * Added index definition for artifacts --- .../plugins/fleet/common/constants/index.ts | 1 + .../fleet_server/elastic_index.test.ts | 53 +++++++------------ .../services/fleet_server/elastic_index.ts | 2 + .../elasticsearch/fleet_artifacts.json | 47 ++++++++++++++++ 4 files changed, 68 insertions(+), 35 deletions(-) create mode 100644 x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_artifacts.json diff --git a/x-pack/plugins/fleet/common/constants/index.ts b/x-pack/plugins/fleet/common/constants/index.ts index d95bc9cf736a6b..abf6b3e1cbbd7f 100644 --- a/x-pack/plugins/fleet/common/constants/index.ts +++ b/x-pack/plugins/fleet/common/constants/index.ts @@ -27,6 +27,7 @@ export const FLEET_SERVER_INDICES_VERSION = 1; export const FLEET_SERVER_INDICES = [ '.fleet-actions', '.fleet-agents', + '.fleet-artifacts', '.fleet-enrollment-api-keys', '.fleet-policies', '.fleet-policies-leader', diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.test.ts b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.test.ts index 96e642ba9884e8..310db24b8184d1 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.test.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.test.ts @@ -14,42 +14,41 @@ import ESFleetPoliciesLeaderIndex from './elasticsearch/fleet_policies_leader.js import ESFleetServersIndex from './elasticsearch/fleet_servers.json'; import ESFleetEnrollmentApiKeysIndex from './elasticsearch/fleet_enrollment_api_keys.json'; import EsFleetActionsIndex from './elasticsearch/fleet_actions.json'; +import EsFleetArtifactsIndex from './elasticsearch/fleet_artifacts.json'; +import { FLEET_SERVER_INDICES } from '../../../common'; -const FLEET_INDEXES_MIGRATION_HASH = { +const FLEET_INDEXES_MIGRATION_HASH: Record = { '.fleet-actions': hash(EsFleetActionsIndex), '.fleet-agents': hash(ESFleetAgentIndex), + '.fleet-artifacts': hash(EsFleetArtifactsIndex), '.fleet-enrollment-apy-keys': hash(ESFleetEnrollmentApiKeysIndex), '.fleet-policies': hash(ESFleetPoliciesIndex), '.fleet-policies-leader': hash(ESFleetPoliciesLeaderIndex), '.fleet-servers': hash(ESFleetServersIndex), }; +const getIndexList = (returnAliases: boolean = false): string[] => { + const response = [...FLEET_SERVER_INDICES]; + + if (returnAliases) { + return response.sort(); + } + + return response.map((index) => `${index}_1`).sort(); +}; + describe('setupFleetServerIndexes ', () => { it('should create all the indices and aliases if nothings exists', async () => { const esMock = elasticsearchServiceMock.createInternalClient(); await setupFleetServerIndexes(esMock); const indexesCreated = esMock.indices.create.mock.calls.map((call) => call[0].index).sort(); - expect(indexesCreated).toEqual([ - '.fleet-actions_1', - '.fleet-agents_1', - '.fleet-enrollment-api-keys_1', - '.fleet-policies-leader_1', - '.fleet-policies_1', - '.fleet-servers_1', - ]); + expect(indexesCreated).toEqual(getIndexList()); const aliasesCreated = esMock.indices.updateAliases.mock.calls .map((call) => (call[0].body as any)?.actions[0].add.alias) .sort(); - expect(aliasesCreated).toEqual([ - '.fleet-actions', - '.fleet-agents', - '.fleet-enrollment-api-keys', - '.fleet-policies', - '.fleet-policies-leader', - '.fleet-servers', - ]); + expect(aliasesCreated).toEqual(getIndexList(true)); }); it('should not create any indices and create aliases if indices exists but not the aliases', async () => { @@ -63,7 +62,6 @@ describe('setupFleetServerIndexes ', () => { [params.index]: { mappings: { _meta: { - // @ts-expect-error migrationHash: FLEET_INDEXES_MIGRATION_HASH[params.index.replace(/_1$/, '')], }, }, @@ -79,14 +77,7 @@ describe('setupFleetServerIndexes ', () => { .map((call) => (call[0].body as any)?.actions[0].add.alias) .sort(); - expect(aliasesCreated).toEqual([ - '.fleet-actions', - '.fleet-agents', - '.fleet-enrollment-api-keys', - '.fleet-policies', - '.fleet-policies-leader', - '.fleet-servers', - ]); + expect(aliasesCreated).toEqual(getIndexList(true)); }); it('should put new indices mapping if the mapping has been updated ', async () => { @@ -115,14 +106,7 @@ describe('setupFleetServerIndexes ', () => { .map((call) => call[0].index) .sort(); - expect(indexesMappingUpdated).toEqual([ - '.fleet-actions_1', - '.fleet-agents_1', - '.fleet-enrollment-api-keys_1', - '.fleet-policies-leader_1', - '.fleet-policies_1', - '.fleet-servers_1', - ]); + expect(indexesMappingUpdated).toEqual(getIndexList()); }); it('should not create any indices or aliases if indices and aliases already exists', async () => { @@ -137,7 +121,6 @@ describe('setupFleetServerIndexes ', () => { [params.index]: { mappings: { _meta: { - // @ts-expect-error migrationHash: FLEET_INDEXES_MIGRATION_HASH[params.index.replace(/_1$/, '')], }, }, diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.ts b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.ts index 15672be756fe2d..4b85f753740e3f 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.ts @@ -16,10 +16,12 @@ import ESFleetPoliciesLeaderIndex from './elasticsearch/fleet_policies_leader.js import ESFleetServersIndex from './elasticsearch/fleet_servers.json'; import ESFleetEnrollmentApiKeysIndex from './elasticsearch/fleet_enrollment_api_keys.json'; import EsFleetActionsIndex from './elasticsearch/fleet_actions.json'; +import EsFleetArtifactsIndex from './elasticsearch/fleet_artifacts.json'; const FLEET_INDEXES: Array<[typeof FLEET_SERVER_INDICES[number], any]> = [ ['.fleet-actions', EsFleetActionsIndex], ['.fleet-agents', ESFleetAgentIndex], + ['.fleet-artifacts', EsFleetArtifactsIndex], ['.fleet-enrollment-api-keys', ESFleetEnrollmentApiKeysIndex], ['.fleet-policies', ESFleetPoliciesIndex], ['.fleet-policies-leader', ESFleetPoliciesLeaderIndex], diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_artifacts.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_artifacts.json new file mode 100644 index 00000000000000..01a2c82b718615 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_artifacts.json @@ -0,0 +1,47 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "identifier": { + "type": "keyword" + }, + "compressionAlgorithm": { + "type": "keyword", + "index": false + }, + "encryptionAlgorithm": { + "type": "keyword", + "index": false + }, + "encodedSha256": { + "type": "keyword" + }, + "encodedSize": { + "type": "long", + "index": false + }, + "decodedSha256": { + "type": "keyword", + "index": false + }, + "decodedSize": { + "type": "long", + "index": false + }, + "created": { + "type": "date", + "index": false + }, + "packageName": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "body": { + "type": "binary" + } + } + } +} From fd348d3f827e2b8f6831e89f3d65cf0665768607 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Thu, 25 Feb 2021 14:47:04 -0700 Subject: [PATCH 26/40] [Reporting/Discover] include the document's entire set of fields (#92730) * fix get_sharing_data to provide a list of fields if none are selected as columns in the saved search * add logging for download CSV that fields must be selected as columns * add more functional test coverage * fix ts in test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../application/helpers/get_sharing_data.ts | 5 +- .../csv_from_savedobject/execute_job.ts | 12 ++- .../lib/get_csv_job.test.ts | 18 +++-- .../csv_from_savedobject/lib/get_csv_job.ts | 12 ++- .../discover/__snapshots__/reporting.snap | 40 ++++++++++ .../functional/apps/discover/reporting.ts | 77 ++++++++++++++++++- .../functional/page_objects/reporting_page.ts | 4 +- 7 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 x-pack/test/functional/apps/discover/__snapshots__/reporting.snap diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.ts index 31de1f2f6ed66a..2455589cf69fc4 100644 --- a/src/plugins/discover/public/application/helpers/get_sharing_data.ts +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.ts @@ -19,7 +19,10 @@ const getSharingDataFields = async ( timeFieldName: string, hideTimeColumn: boolean ) => { - if (selectedFields.length === 1 && selectedFields[0] === '_source') { + if ( + selectedFields.length === 0 || + (selectedFields.length === 1 && selectedFields[0] === '_source') + ) { const fieldCounts = await getFieldCounts(); return { searchFields: undefined, diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts index d494a8b529d2c9..b037e72699dd63 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts @@ -35,13 +35,19 @@ export const runTaskFnFactory: RunTaskFnFactory = function e return async function runTask(jobId, jobPayload, context, req) { const generateCsv = createGenerateCsv(logger); - const { panel, visType } = jobPayload; + const { panel } = jobPayload; - logger.debug(`Execute job generating [${visType}] csv`); + logger.debug(`Execute job generating saved search CSV`); const savedObjectsClient = context.core.savedObjects.client; const uiSettingsClient = await reporting.getUiSettingsServiceFactory(savedObjectsClient); - const job = await getGenerateCsvParams(jobPayload, panel, savedObjectsClient, uiSettingsClient); + const job = await getGenerateCsvParams( + jobPayload, + panel, + savedObjectsClient, + uiSettingsClient, + logger + ); const elasticsearch = reporting.getElasticsearchService(); const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(req); diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts index 8c189537ad5ddc..fc6e092962d3b9 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts @@ -5,9 +5,12 @@ * 2.0. */ +import { createMockLevelLogger } from '../../../test_helpers'; import { JobParamsPanelCsv, SearchPanel } from '../types'; import { getGenerateCsvParams } from './get_csv_job'; +const logger = createMockLevelLogger(); + describe('Get CSV Job', () => { let mockJobParams: JobParamsPanelCsv; let mockSearchPanel: SearchPanel; @@ -42,7 +45,8 @@ describe('Get CSV Job', () => { mockJobParams, mockSearchPanel, mockSavedObjectsClient, - mockUiSettingsClient + mockUiSettingsClient, + logger ); expect(result).toMatchInlineSnapshot(` Object { @@ -94,7 +98,8 @@ describe('Get CSV Job', () => { mockJobParams, mockSearchPanel, mockSavedObjectsClient, - mockUiSettingsClient + mockUiSettingsClient, + logger ); expect(result).toMatchInlineSnapshot(` Object { @@ -149,7 +154,8 @@ describe('Get CSV Job', () => { mockJobParams, mockSearchPanel, mockSavedObjectsClient, - mockUiSettingsClient + mockUiSettingsClient, + logger ); expect(result).toMatchInlineSnapshot(` Object { @@ -203,7 +209,8 @@ describe('Get CSV Job', () => { mockJobParams, mockSearchPanel, mockSavedObjectsClient, - mockUiSettingsClient + mockUiSettingsClient, + logger ); expect(result).toMatchInlineSnapshot(` Object { @@ -275,7 +282,8 @@ describe('Get CSV Job', () => { mockJobParams, mockSearchPanel, mockSavedObjectsClient, - mockUiSettingsClient + mockUiSettingsClient, + logger ); expect(result).toMatchInlineSnapshot(` Object { diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts index b4846ee3b4236e..e4570816e26ff2 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts @@ -8,6 +8,7 @@ import { IUiSettingsClient, SavedObjectsClientContract } from 'kibana/server'; import { EsQueryConfig } from 'src/plugins/data/server'; import { esQuery, Filter, Query } from '../../../../../../../src/plugins/data/server'; +import { LevelLogger } from '../../../lib'; import { TimeRangeParams } from '../../common'; import { GenerateCsvParams } from '../../csv/generate_csv'; import { @@ -44,7 +45,8 @@ export const getGenerateCsvParams = async ( jobParams: JobParamsPanelCsv, panel: SearchPanel, savedObjectsClient: SavedObjectsClientContract, - uiConfig: IUiSettingsClient + uiConfig: IUiSettingsClient, + logger: LevelLogger ): Promise => { let timerange: TimeRangeParams | null; if (jobParams.post?.timerange) { @@ -75,6 +77,14 @@ export const getGenerateCsvParams = async ( fields: indexPatternFields, } = indexPatternSavedObject; + if (!indexPatternFields || indexPatternFields.length === 0) { + logger.error( + new Error( + `No fields are selected in the saved search! Please select fields as columns in the saved search and try again.` + ) + ); + } + let payloadQuery: QueryFilter | undefined; let payloadSort: any[] = []; let docValueFields: DocValueFields[] | undefined; diff --git a/x-pack/test/functional/apps/discover/__snapshots__/reporting.snap b/x-pack/test/functional/apps/discover/__snapshots__/reporting.snap new file mode 100644 index 00000000000000..43771b00525cc3 --- /dev/null +++ b/x-pack/test/functional/apps/discover/__snapshots__/reporting.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`discover Discover Generate CSV: archived search generates a report with data 1`] = ` +"\\"order_date\\",category,currency,\\"customer_id\\",\\"order_id\\",\\"day_of_week_i\\",\\"order_date\\",\\"products.created_on\\",sku +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Shoes\\"\\",\\"\\"Men's Clothing\\"\\",\\"\\"Women's Accessories\\"\\",\\"\\"Men's Accessories\\"\\"]\\",EUR,19,716724,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0687606876\\"\\",\\"\\"ZO0290502905\\"\\",\\"\\"ZO0126701267\\"\\",\\"\\"ZO0308503085\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Women's Shoes\\"\\",\\"\\"Women's Clothing\\"\\"]\\",EUR,45,591503,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0006400064\\"\\",\\"\\"ZO0150601506\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,12,591709,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0638206382\\"\\",\\"\\"ZO0038800388\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,52,590937,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0297602976\\"\\",\\"\\"ZO0565605656\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,29,590976,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0561405614\\"\\",\\"\\"ZO0281602816\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Shoes\\"\\",\\"\\"Men's Clothing\\"\\"]\\",EUR,41,591636,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0385003850\\"\\",\\"\\"ZO0408604086\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes\\",EUR,30,591539,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0505605056\\"\\",\\"\\"ZO0513605136\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,41,591598,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0276702767\\"\\",\\"\\"ZO0291702917\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,44,590927,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0046600466\\"\\",\\"\\"ZO0050800508\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Clothing\\"\\",\\"\\"Men's Shoes\\"\\"]\\",EUR,48,590970,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0455604556\\"\\",\\"\\"ZO0680806808\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Women's Clothing\\"\\",\\"\\"Women's Shoes\\"\\"]\\",EUR,46,591299,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0229002290\\"\\",\\"\\"ZO0674406744\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,36,591133,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0529905299\\"\\",\\"\\"ZO0617006170\\"\\"]\\" +" +`; + +exports[`discover Discover Generate CSV: archived search generates a report with filtered data 1`] = ` +"\\"order_date\\",category,currency,\\"customer_id\\",\\"order_id\\",\\"day_of_week_i\\",\\"order_date\\",\\"products.created_on\\",sku +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Shoes\\"\\",\\"\\"Men's Clothing\\"\\",\\"\\"Women's Accessories\\"\\",\\"\\"Men's Accessories\\"\\"]\\",EUR,19,716724,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0687606876\\"\\",\\"\\"ZO0290502905\\"\\",\\"\\"ZO0126701267\\"\\",\\"\\"ZO0308503085\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Women's Shoes\\"\\",\\"\\"Women's Clothing\\"\\"]\\",EUR,45,591503,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0006400064\\"\\",\\"\\"ZO0150601506\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,12,591709,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0638206382\\"\\",\\"\\"ZO0038800388\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,52,590937,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0297602976\\"\\",\\"\\"ZO0565605656\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,29,590976,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0561405614\\"\\",\\"\\"ZO0281602816\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Shoes\\"\\",\\"\\"Men's Clothing\\"\\"]\\",EUR,41,591636,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0385003850\\"\\",\\"\\"ZO0408604086\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Shoes\\",EUR,30,591539,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0505605056\\"\\",\\"\\"ZO0513605136\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,41,591598,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0276702767\\"\\",\\"\\"ZO0291702917\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Women's Clothing\\",EUR,44,590927,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0046600466\\"\\",\\"\\"ZO0050800508\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Men's Clothing\\"\\",\\"\\"Men's Shoes\\"\\"]\\",EUR,48,590970,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0455604556\\"\\",\\"\\"ZO0680806808\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Women's Clothing\\"\\",\\"\\"Women's Shoes\\"\\"]\\",EUR,46,591299,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0229002290\\"\\",\\"\\"ZO0674406744\\"\\"]\\" +\\"Jul 12, 2019 @ 00:00:00.000\\",\\"Men's Clothing\\",EUR,36,591133,5,\\"Jul 12, 2019 @ 00:00:00.000\\",\\"[\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\",\\"\\"Dec 31, 2016 @ 00:00:00.000\\"\\"]\\",\\"[\\"\\"ZO0529905299\\"\\",\\"\\"ZO0617006170\\"\\"]\\" +" +`; + +exports[`discover Discover Generate CSV: new search generates a report with data 1`] = ` +"\\"_id\\",\\"_index\\",\\"_score\\",\\"_type\\",category,\\"category.keyword\\",currency,\\"customer_first_name\\",\\"customer_first_name.keyword\\",\\"customer_full_name\\",\\"customer_full_name.keyword\\",\\"customer_gender\\",\\"customer_id\\",\\"customer_last_name\\",\\"customer_last_name.keyword\\",\\"customer_phone\\",\\"day_of_week\\",\\"day_of_week_i\\",email,\\"geoip.city_name\\",\\"geoip.continent_name\\",\\"geoip.country_iso_code\\",\\"geoip.location\\",\\"geoip.region_name\\",manufacturer,\\"manufacturer.keyword\\",\\"order_date\\",\\"order_id\\",\\"products._id\\",\\"products._id.keyword\\",\\"products.base_price\\",\\"products.base_unit_price\\",\\"products.category\\",\\"products.category.keyword\\",\\"products.created_on\\",\\"products.discount_amount\\",\\"products.discount_percentage\\",\\"products.manufacturer\\",\\"products.manufacturer.keyword\\",\\"products.min_price\\",\\"products.price\\",\\"products.product_id\\",\\"products.product_name\\",\\"products.product_name.keyword\\",\\"products.quantity\\",\\"products.sku\\",\\"products.tax_amount\\",\\"products.taxful_price\\",\\"products.taxless_price\\",\\"products.unit_discount_amount\\",sku,\\"taxful_total_price\\",\\"taxless_total_price\\",\\"total_quantity\\",\\"total_unique_products\\",type,user +" +`; diff --git a/x-pack/test/functional/apps/discover/reporting.ts b/x-pack/test/functional/apps/discover/reporting.ts index a0f10774e7fa64..dfc44a8e0e12d3 100644 --- a/x-pack/test/functional/apps/discover/reporting.ts +++ b/x-pack/test/functional/apps/discover/reporting.ts @@ -13,7 +13,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const es = getService('es'); const esArchiver = getService('esArchiver'); const browser = getService('browser'); - const PageObjects = getPageObjects(['reporting', 'common', 'discover']); + const PageObjects = getPageObjects(['reporting', 'common', 'discover', 'timePicker']); const filterBar = getService('filterBar'); describe('Discover', () => { @@ -31,7 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - describe('Generate CSV button', () => { + describe('Generate CSV: new search', () => { beforeEach(() => PageObjects.common.navigateToApp('discover')); it('is not available if new', async () => { @@ -69,14 +69,83 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.reporting.setTimepickerInDataRange(); await PageObjects.discover.saveSearch('my search - with data - expectReportCanBeCreated'); await PageObjects.reporting.openCsvReportingPanel(); - expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); + await PageObjects.reporting.clickGenerateReportButton(); + + const url = await PageObjects.reporting.getReportURL(60000); + const res = await PageObjects.reporting.getResponse(url); + + expect(res.status).to.equal(200); + expect(res.get('content-type')).to.equal('text/csv; charset=utf-8'); + expectSnapshot(res.text).toMatch(); }); it('generates a report with no data', async () => { await PageObjects.reporting.setTimepickerInNoDataRange(); await PageObjects.discover.saveSearch('my search - no data - expectReportCanBeCreated'); await PageObjects.reporting.openCsvReportingPanel(); - expect(await PageObjects.reporting.canReportBeCreated()).to.be(true); + await PageObjects.reporting.clickGenerateReportButton(); + + const url = await PageObjects.reporting.getReportURL(60000); + const res = await PageObjects.reporting.getResponse(url); + + expect(res.status).to.equal(200); + expect(res.get('content-type')).to.equal('text/csv; charset=utf-8'); + expectSnapshot(res.text).toMatchInline(` + " + " + `); + }); + }); + + describe('Generate CSV: archived search', () => { + before(async () => { + await esArchiver.load('reporting/ecommerce'); + await esArchiver.load('reporting/ecommerce_kibana'); + }); + + after(async () => { + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); + }); + + beforeEach(() => PageObjects.common.navigateToApp('discover')); + + it('generates a report with data', async () => { + await PageObjects.discover.loadSavedSearch('Ecommerce Data'); + const fromTime = 'Apr 27, 2019 @ 23:56:51.374'; + const toTime = 'Aug 23, 2019 @ 16:18:51.821'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + + await PageObjects.reporting.openCsvReportingPanel(); + await PageObjects.reporting.clickGenerateReportButton(); + + const url = await PageObjects.reporting.getReportURL(60000); + const res = await PageObjects.reporting.getResponse(url); + + expect(res.status).to.equal(200); + expect(res.get('content-type')).to.equal('text/csv; charset=utf-8'); + expectSnapshot(res.text).toMatch(); + }); + + it('generates a report with filtered data', async () => { + await PageObjects.discover.loadSavedSearch('Ecommerce Data'); + const fromTime = 'Apr 27, 2019 @ 23:56:51.374'; + const toTime = 'Aug 23, 2019 @ 16:18:51.821'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + + // filter and re-save + await filterBar.addFilter('currency', 'is', 'EUR'); + await PageObjects.discover.saveSearch(`Ecommerce Data: EUR Filtered`); + + await PageObjects.reporting.openCsvReportingPanel(); + await PageObjects.reporting.clickGenerateReportButton(); + + const url = await PageObjects.reporting.getReportURL(60000); + const res = await PageObjects.reporting.getResponse(url); + + expect(res.status).to.equal(200); + expect(res.get('content-type')).to.equal('text/csv; charset=utf-8'); + expectSnapshot(res.text).toMatch(); }); }); }); diff --git a/x-pack/test/functional/page_objects/reporting_page.ts b/x-pack/test/functional/page_objects/reporting_page.ts index 7d33680f1dc313..746df14d31ac4d 100644 --- a/x-pack/test/functional/page_objects/reporting_page.ts +++ b/x-pack/test/functional/page_objects/reporting_page.ts @@ -140,8 +140,8 @@ export function ReportingPageProvider({ getService, getPageObjects }: FtrProvide async setTimepickerInDataRange() { log.debug('Reporting:setTimepickerInDataRange'); - const fromTime = 'Sep 19, 2015 @ 06:31:44.000'; - const toTime = 'Sep 19, 2015 @ 18:01:44.000'; + const fromTime = 'Apr 27, 2019 @ 23:56:51.374'; + const toTime = 'Aug 23, 2019 @ 16:18:51.821'; await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); } From 0198607eb3a972801800d119cb36e8c0c6c72b9f Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 25 Feb 2021 14:07:05 -0800 Subject: [PATCH 27/40] [App Search] Create Curation view/functionality (#92560) * Add server route and logic listener * [Misc] Remove 'Set' from 'deleteCurationSet' - to match createCuration - IMO, this language isn't necessary if we're splitting up Curations and CurationLogic - the context is fairly evident within a smaller and more modular logic file * Add CurationQueries component + accompanying CurationQueriesLogic & CurationQuery row * Add CurationCreation view --- .../curation_queries/curation_queries.scss | 3 + .../curation_queries.test.tsx | 102 ++++++++++++++++++ .../curation_queries/curation_queries.tsx | 72 +++++++++++++ .../curation_queries_logic.test.ts | 98 +++++++++++++++++ .../curation_queries_logic.ts | 53 +++++++++ .../curation_queries/curation_query.test.tsx | 55 ++++++++++ .../curation_queries/curation_query.tsx | 51 +++++++++ .../components/curation_queries/index.ts | 8 ++ .../components/curation_queries/utils.test.ts | 15 +++ .../components/curation_queries/utils.ts | 10 ++ .../components/curations/components/index.ts | 8 ++ .../curations/curations_logic.test.ts | 43 +++++++- .../components/curations/curations_logic.ts | 27 ++++- .../components/curations/curations_router.tsx | 8 +- .../views/curation_creation.test.tsx | 40 +++++++ .../curations/views/curation_creation.tsx | 53 +++++++++ .../curations/views/curations.test.tsx | 8 +- .../components/curations/views/curations.tsx | 4 +- .../components/curations/views/index.ts | 1 + .../routes/app_search/curations.test.ts | 57 ++++++++++ .../server/routes/app_search/curations.ts | 17 +++ 21 files changed, 714 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.scss new file mode 100644 index 00000000000000..c242cf29fd37d6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.scss @@ -0,0 +1,3 @@ +.curationQueryRow { + margin-bottom: $euiSizeXS; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx new file mode 100644 index 00000000000000..e55b944f7bebce --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 { setMockActions, setMockValues } from '../../../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { CurationQuery } from './curation_query'; + +import { CurationQueries } from './'; + +describe('CurationQueries', () => { + const props = { + queries: ['a', 'b', 'c'], + onSubmit: jest.fn(), + }; + const values = { + queries: ['a', 'b', 'c'], + hasEmptyQueries: false, + hasOnlyOneQuery: false, + }; + const actions = { + addQuery: jest.fn(), + editQuery: jest.fn(), + deleteQuery: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders a CurationQuery row for each query', () => { + const wrapper = shallow(); + + expect(wrapper.find(CurationQuery)).toHaveLength(3); + expect(wrapper.find(CurationQuery).at(0).prop('queryValue')).toEqual('a'); + expect(wrapper.find(CurationQuery).at(1).prop('queryValue')).toEqual('b'); + expect(wrapper.find(CurationQuery).at(2).prop('queryValue')).toEqual('c'); + }); + + it('calls editQuery when the CurationQuery value changes', () => { + const wrapper = shallow(); + wrapper.find(CurationQuery).at(0).simulate('change', 'new query value'); + + expect(actions.editQuery).toHaveBeenCalledWith(0, 'new query value'); + }); + + it('calls deleteQuery when the CurationQuery calls onDelete', () => { + const wrapper = shallow(); + wrapper.find(CurationQuery).at(2).simulate('delete'); + + expect(actions.deleteQuery).toHaveBeenCalledWith(2); + }); + + it('calls addQuery when the Add Query button is clicked', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="addCurationQueryButton"]').simulate('click'); + + expect(actions.addQuery).toHaveBeenCalled(); + }); + + it('disables the add button if any query fields are empty', () => { + setMockValues({ + ...values, + queries: ['a', '', 'c'], + hasEmptyQueries: true, + }); + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="addCurationQueryButton"]'); + + expect(button.prop('isDisabled')).toEqual(true); + }); + + it('calls the passed onSubmit callback when the submit button is clicked', () => { + setMockValues({ ...values, queries: ['some query'] }); + const wrapper = shallow(); + wrapper.find('[data-test-subj="submitCurationQueriesButton"]').simulate('click'); + + expect(props.onSubmit).toHaveBeenCalledWith(['some query']); + }); + + it('disables the submit button if no query fields have been filled', () => { + setMockValues({ + ...values, + queries: [''], + hasOnlyOneQuery: true, + hasEmptyQueries: true, + }); + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="submitCurationQueriesButton"]'); + + expect(button.prop('isDisabled')).toEqual(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx new file mode 100644 index 00000000000000..ad7872b112408e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries.tsx @@ -0,0 +1,72 @@ +/* + * 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 { useValues, useActions } from 'kea'; + +import { EuiButton, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { Curation } from '../../types'; + +import { CurationQueriesLogic } from './curation_queries_logic'; +import { CurationQuery } from './curation_query'; +import { filterEmptyQueries } from './utils'; +import './curation_queries.scss'; + +interface Props { + queries: Curation['queries']; + onSubmit(queries: Curation['queries']): void; + submitButtonText?: string; +} + +export const CurationQueries: React.FC = ({ + queries: initialQueries, + onSubmit, + submitButtonText = i18n.translate('xpack.enterpriseSearch.actions.continue', { + defaultMessage: 'Continue', + }), +}) => { + const logic = CurationQueriesLogic({ queries: initialQueries }); + const { queries, hasEmptyQueries, hasOnlyOneQuery } = useValues(logic); + const { addQuery, editQuery, deleteQuery } = useActions(logic); + + return ( + <> + {queries.map((query: string, index) => ( + editQuery(index, newValue)} + onDelete={() => deleteQuery(index)} + disableDelete={hasOnlyOneQuery} + /> + ))} + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.addQueryButtonLabel', { + defaultMessage: 'Add query', + })} + + + onSubmit(filterEmptyQueries(queries))} + data-test-subj="submitCurationQueriesButton" + > + {submitButtonText} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts new file mode 100644 index 00000000000000..157e97433d2b64 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { resetContext } from 'kea'; + +import { CurationQueriesLogic } from './curation_queries_logic'; + +describe('CurationQueriesLogic', () => { + const MOCK_QUERIES = ['a', 'b', 'c']; + + const DEFAULT_PROPS = { queries: MOCK_QUERIES }; + const DEFAULT_VALUES = { + queries: MOCK_QUERIES, + hasEmptyQueries: false, + hasOnlyOneQuery: false, + }; + + const mount = (props = {}) => { + CurationQueriesLogic({ ...DEFAULT_PROPS, ...props }); + CurationQueriesLogic.mount(); + }; + + beforeEach(() => { + jest.clearAllMocks(); + resetContext({}); + }); + + it('has expected default values passed from props', () => { + mount(); + expect(CurationQueriesLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + afterEach(() => { + // Should not mutate the original array + expect(CurationQueriesLogic.values.queries).not.toBe(MOCK_QUERIES); // Would fail if we did not clone a new array + }); + + describe('addQuery', () => { + it('appends an empty string to the queries array', () => { + mount(); + CurationQueriesLogic.actions.addQuery(); + + expect(CurationQueriesLogic.values).toEqual({ + ...DEFAULT_VALUES, + hasEmptyQueries: true, + queries: ['a', 'b', 'c', ''], + }); + }); + }); + + describe('deleteQuery', () => { + it('deletes the query string at the specified array index', () => { + mount(); + CurationQueriesLogic.actions.deleteQuery(1); + + expect(CurationQueriesLogic.values).toEqual({ + ...DEFAULT_VALUES, + queries: ['a', 'c'], + }); + }); + }); + + describe('editQuery', () => { + it('edits the query string at the specified array index', () => { + mount(); + CurationQueriesLogic.actions.editQuery(2, 'z'); + + expect(CurationQueriesLogic.values).toEqual({ + ...DEFAULT_VALUES, + queries: ['a', 'b', 'z'], + }); + }); + }); + }); + + describe('selectors', () => { + describe('hasEmptyQueries', () => { + it('returns true if queries has any empty strings', () => { + mount({ queries: ['', '', ''] }); + + expect(CurationQueriesLogic.values.hasEmptyQueries).toEqual(true); + }); + }); + + describe('hasOnlyOneQuery', () => { + it('returns true if queries only has one item', () => { + mount({ queries: ['test'] }); + + expect(CurationQueriesLogic.values.hasOnlyOneQuery).toEqual(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts new file mode 100644 index 00000000000000..98109657d61a39 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_queries_logic.ts @@ -0,0 +1,53 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +interface CurationQueriesValues { + queries: string[]; + hasEmptyQueries: boolean; + hasOnlyOneQuery: boolean; +} + +interface CurationQueriesActions { + addQuery(): void; + deleteQuery(indexToDelete: number): { indexToDelete: number }; + editQuery(index: number, newQueryValue: string): { index: number; newQueryValue: string }; +} + +export const CurationQueriesLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'curation_queries_logic'], + actions: () => ({ + addQuery: true, + deleteQuery: (indexToDelete) => ({ indexToDelete }), + editQuery: (index, newQueryValue) => ({ index, newQueryValue }), + }), + reducers: ({ props }) => ({ + queries: [ + props.queries, + { + addQuery: (state) => [...state, ''], + deleteQuery: (state, { indexToDelete }) => { + const newState = [...state]; + newState.splice(indexToDelete, 1); + return newState; + }, + editQuery: (state, { index, newQueryValue }) => { + const newState = [...state]; + newState[index] = newQueryValue; + return newState; + }, + }, + ], + }), + selectors: { + hasEmptyQueries: [(selectors) => [selectors.queries], (queries) => queries.indexOf('') >= 0], + hasOnlyOneQuery: [(selectors) => [selectors.queries], (queries) => queries.length <= 1], + }, +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.test.tsx new file mode 100644 index 00000000000000..64fbec59382a01 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.test.tsx @@ -0,0 +1,55 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { EuiFieldText } from '@elastic/eui'; + +import { CurationQuery } from './curation_query'; + +describe('CurationQuery', () => { + const props = { + queryValue: 'some query', + onChange: jest.fn(), + onDelete: jest.fn(), + disableDelete: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFieldText)).toHaveLength(1); + expect(wrapper.find(EuiFieldText).prop('value')).toEqual('some query'); + }); + + it('calls onChange when the input value changes', () => { + const wrapper = shallow(); + wrapper.find(EuiFieldText).simulate('change', { target: { value: 'new query value' } }); + + expect(props.onChange).toHaveBeenCalledWith('new query value'); + }); + + it('calls onDelete when the delete button is clicked', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="deleteCurationQueryButton"]').simulate('click'); + + expect(props.onDelete).toHaveBeenCalled(); + }); + + it('disables the delete button if disableDelete is passed', () => { + const wrapper = shallow(); + const button = wrapper.find('[data-test-subj="deleteCurationQueryButton"]'); + + expect(button.prop('isDisabled')).toEqual(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.tsx new file mode 100644 index 00000000000000..78b32ef12e3611 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/curation_query.tsx @@ -0,0 +1,51 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiFieldText, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface Props { + queryValue: string; + onChange(newValue: string): void; + onDelete(): void; + disableDelete: boolean; +} + +export const CurationQuery: React.FC = ({ + queryValue, + onChange, + onDelete, + disableDelete, +}) => ( + + + onChange(e.target.value)} + /> + + + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/index.ts new file mode 100644 index 00000000000000..4f9136d15d6c37 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/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 { CurationQueries } from './curation_queries'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts new file mode 100644 index 00000000000000..d84649f0906913 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.test.ts @@ -0,0 +1,15 @@ +/* + * 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 { filterEmptyQueries } from './utils'; + +describe('filterEmptyQueries', () => { + it('filters out all empty strings from a queries array', () => { + const queries = ['', 'a', '', 'b', '', 'c', '']; + expect(filterEmptyQueries(queries)).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.ts new file mode 100644 index 00000000000000..505e9641d778eb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/curation_queries/utils.ts @@ -0,0 +1,10 @@ +/* + * 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 const filterEmptyQueries = (queries: string[]) => { + return queries.filter((query) => query.length); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/index.ts new file mode 100644 index 00000000000000..4f9136d15d6c37 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/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 { CurationQueries } from './curation_queries'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.test.ts index 1505fe5136bda3..c1031fc20bc15d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.test.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; +import { + LogicMounter, + mockHttpValues, + mockKibanaValues, + mockFlashMessageHelpers, +} from '../../../__mocks__'; import '../../__mocks__/engine_logic.mock'; import { nextTick } from '@kbn/test/jest'; @@ -17,6 +22,7 @@ import { CurationsLogic } from './'; describe('CurationsLogic', () => { const { mount } = new LogicMounter(CurationsLogic); const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; const { clearFlashMessages, setSuccessMessage, flashAPIErrors } = mockFlashMessageHelpers; const MOCK_CURATIONS_RESPONSE = { @@ -128,7 +134,7 @@ describe('CurationsLogic', () => { }); }); - describe('deleteCurationSet', () => { + describe('deleteCuration', () => { const confirmSpy = jest.spyOn(window, 'confirm'); beforeEach(() => { @@ -140,7 +146,7 @@ describe('CurationsLogic', () => { mount(); jest.spyOn(CurationsLogic.actions, 'loadCurations'); - CurationsLogic.actions.deleteCurationSet('some-curation-id'); + CurationsLogic.actions.deleteCuration('some-curation-id'); expect(clearFlashMessages).toHaveBeenCalled(); await nextTick(); @@ -155,7 +161,7 @@ describe('CurationsLogic', () => { http.delete.mockReturnValueOnce(Promise.reject('error')); mount(); - CurationsLogic.actions.deleteCurationSet('some-curation-id'); + CurationsLogic.actions.deleteCuration('some-curation-id'); expect(clearFlashMessages).toHaveBeenCalled(); await nextTick(); @@ -166,12 +172,39 @@ describe('CurationsLogic', () => { confirmSpy.mockImplementationOnce(() => false); mount(); - CurationsLogic.actions.deleteCurationSet('some-curation-id'); + CurationsLogic.actions.deleteCuration('some-curation-id'); expect(clearFlashMessages).toHaveBeenCalled(); await nextTick(); expect(http.delete).not.toHaveBeenCalled(); }); }); + + describe('createCuration', () => { + it('should make an API call and navigate to the new curation', async () => { + http.post.mockReturnValueOnce(Promise.resolve({ id: 'some-cur-id' })); + mount(); + + CurationsLogic.actions.createCuration(['some query']); + expect(clearFlashMessages).toHaveBeenCalled(); + await nextTick(); + + expect(http.post).toHaveBeenCalledWith('/api/app_search/engines/some-engine/curations', { + body: '{"queries":["some query"]}', + }); + expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/some-cur-id'); + }); + + it('handles errors', async () => { + http.post.mockReturnValueOnce(Promise.reject('error')); + mount(); + + CurationsLogic.actions.createCuration(['some query']); + expect(clearFlashMessages).toHaveBeenCalled(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.ts index 434aff9c3cc4bb..f4916f54fbc22e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_logic.ts @@ -15,8 +15,10 @@ import { flashAPIErrors, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; +import { KibanaLogic } from '../../../shared/kibana'; import { updateMetaPageIndex } from '../../../shared/table_pagination'; -import { EngineLogic } from '../engine'; +import { ENGINE_CURATION_PATH } from '../../routes'; +import { EngineLogic, generateEnginePath } from '../engine'; import { DELETE_MESSAGE, SUCCESS_MESSAGE } from './constants'; import { Curation, CurationsAPIResponse } from './types'; @@ -31,7 +33,8 @@ interface CurationsActions { onCurationsLoad(response: CurationsAPIResponse): CurationsAPIResponse; onPaginate(newPageIndex: number): { newPageIndex: number }; loadCurations(): void; - deleteCurationSet(id: string): string; + deleteCuration(id: string): string; + createCuration(queries: Curation['queries']): Curation['queries']; } export const CurationsLogic = kea>({ @@ -40,7 +43,8 @@ export const CurationsLogic = kea ({ results, meta }), onPaginate: (newPageIndex) => ({ newPageIndex }), loadCurations: true, - deleteCurationSet: (id) => id, + deleteCuration: (id) => id, + createCuration: (queries) => queries, }), reducers: () => ({ dataLoading: [ @@ -82,7 +86,7 @@ export const CurationsLogic = kea { + deleteCuration: async (id) => { const { http } = HttpLogic.values; const { engineName } = EngineLogic.values; clearFlashMessages(); @@ -97,5 +101,20 @@ export const CurationsLogic = kea { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + const { navigateToUrl } = KibanaLogic.values; + clearFlashMessages(); + + try { + const response = await http.post(`/api/app_search/engines/${engineName}/curations`, { + body: JSON.stringify({ queries }), + }); + navigateToUrl(generateEnginePath(ENGINE_CURATION_PATH, { curationId: response.id })); + } catch (e) { + flashAPIErrors(e); + } + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx index b4479fb145f813..634736bca4c656 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx @@ -19,8 +19,8 @@ import { ENGINE_CURATION_ADD_RESULT_PATH, } from '../../routes'; -import { CURATIONS_TITLE } from './constants'; -import { Curations } from './views'; +import { CURATIONS_TITLE, CREATE_NEW_CURATION_TITLE } from './constants'; +import { Curations, CurationCreation } from './views'; interface Props { engineBreadcrumb: BreadcrumbTrail; @@ -35,8 +35,8 @@ export const CurationsRouter: React.FC = ({ engineBreadcrumb }) => { - - TODO: Curation creation view + + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx new file mode 100644 index 00000000000000..e6ddbb9c1b7a9d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions } from '../../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { CurationQueries } from '../components'; + +import { CurationCreation } from './curation_creation'; + +describe('CurationCreation', () => { + const actions = { + createCuration: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(actions); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(CurationQueries)).toHaveLength(1); + }); + + it('calls createCuration on CurationQueries submit', () => { + const wrapper = shallow(); + wrapper.find(CurationQueries).simulate('submit', ['some query']); + + expect(actions.createCuration).toHaveBeenCalledWith(['some query']); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx new file mode 100644 index 00000000000000..b1bfc6c2ab7fae --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curation_creation.tsx @@ -0,0 +1,53 @@ +/* + * 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 { useActions } from 'kea'; + +import { EuiPageHeader, EuiPageContent, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { FlashMessages } from '../../../../shared/flash_messages'; + +import { CurationQueries } from '../components'; +import { CREATE_NEW_CURATION_TITLE } from '../constants'; +import { CurationsLogic } from '../index'; + +export const CurationCreation: React.FC = () => { + const { createCuration } = useActions(CurationsLogic); + + return ( + <> + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.create.curationQueriesTitle', + { defaultMessage: 'Curation queries' } + )} +

+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.create.curationQueriesDescription', + { + defaultMessage: + 'Add one or multiple queries to curate. You will be able add or remove more queries later.', + } + )} +

+
+ + createCuration(queries)} /> + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx index fd5d5b7ea64a97..d06144023e1702 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx @@ -51,7 +51,7 @@ describe('Curations', () => { const actions = { loadCurations: jest.fn(), - deleteCurationSet: jest.fn(), + deleteCuration: jest.fn(), onPaginate: jest.fn(), }; @@ -134,12 +134,12 @@ describe('Curations', () => { expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-id-2'); }); - it('delete action calls deleteCurationSet', () => { + it('delete action calls deleteCuration', () => { wrapper.find('[data-test-subj="CurationsTableDeleteButton"]').first().simulate('click'); - expect(actions.deleteCurationSet).toHaveBeenCalledWith('cur-id-1'); + expect(actions.deleteCuration).toHaveBeenCalledWith('cur-id-1'); wrapper.find('[data-test-subj="CurationsTableDeleteButton"]').last().simulate('click'); - expect(actions.deleteCurationSet).toHaveBeenCalledWith('cur-id-2'); + expect(actions.deleteCuration).toHaveBeenCalledWith('cur-id-2'); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx index 6affef53d71eea..fd0a36dfebec7c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx @@ -69,7 +69,7 @@ export const Curations: React.FC = () => { export const CurationsTable: React.FC = () => { const { dataLoading, curations, meta } = useValues(CurationsLogic); - const { onPaginate, deleteCurationSet } = useActions(CurationsLogic); + const { onPaginate, deleteCuration } = useActions(CurationsLogic); const columns: Array> = [ { @@ -141,7 +141,7 @@ export const CurationsTable: React.FC = () => { type: 'icon', icon: 'trash', color: 'danger', - onClick: (curation: Curation) => deleteCurationSet(curation.id), + onClick: (curation: Curation) => deleteCuration(curation.id), 'data-test-subj': 'CurationsTableDeleteButton', }, ], diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/index.ts index d454d24f6c8b5e..ca6924879324aa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/index.ts @@ -6,3 +6,4 @@ */ export { Curations } from './curations'; +export { CurationCreation } from './curation_creation'; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts index 4ac79068a88f5b..28896809bc81ad 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts @@ -50,6 +50,63 @@ describe('curations routes', () => { }); }); + describe('POST /api/app_search/engines/{engineName}/curations', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/curations', + }); + + registerCurationsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/curations/collection', + }); + }); + + describe('validates', () => { + it('with curation queries', () => { + const request = { + body: { + queries: ['a', 'b', 'c'], + }, + }; + mockRouter.shouldValidate(request); + }); + + it('empty queries array', () => { + const request = { + body: { + queries: [], + }, + }; + mockRouter.shouldThrow(request); + }); + + it('empty query strings', () => { + const request = { + body: { + queries: ['', '', ''], + }, + }; + mockRouter.shouldThrow(request); + }); + + it('missing queries', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); + describe('DELETE /api/app_search/engines/{engineName}/curations/{curationId}', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts index 48bb2fc5cb8236..2d7f09e1aeb8d6 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts @@ -31,6 +31,23 @@ export function registerCurationsRoutes({ }) ); + router.post( + { + path: '/api/app_search/engines/{engineName}/curations', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + body: schema.object({ + queries: schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/curations/collection', + }) + ); + router.delete( { path: '/api/app_search/engines/{engineName}/curations/{curationId}', From 9c527347efd45f56432f55a8589b62473b5d134a Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Thu, 25 Feb 2021 14:11:11 -0800 Subject: [PATCH 28/40] Test fix management scripted field filter functional test and unskip it (#92756) * fixes https://github.com/elastic/kibana/issues/74449 * unskipping the functional test, removing the unload and adding an empty kibana in the after method --- test/functional/apps/management/_scripted_fields_filter.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test/functional/apps/management/_scripted_fields_filter.js b/test/functional/apps/management/_scripted_fields_filter.js index c7d333bd681d14..7ed15a6cddbca3 100644 --- a/test/functional/apps/management/_scripted_fields_filter.js +++ b/test/functional/apps/management/_scripted_fields_filter.js @@ -16,9 +16,7 @@ export default function ({ getService, getPageObjects }) { const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['settings']); - // this functionality is no longer functional as of 7.0 but still needs cleanup - // https://github.com/elastic/kibana/issues/74118 - describe.skip('filter scripted fields', function describeIndexTests() { + describe('filter scripted fields', function describeIndexTests() { before(async function () { // delete .kibana index and then wait for Kibana to re-create it await browser.setWindowSize(1200, 800); @@ -29,8 +27,7 @@ export default function ({ getService, getPageObjects }) { }); after(async function () { - await esArchiver.unload('management'); - await kibanaServer.uiSettings.replace({}); + await esArchiver.load('empty_kibana'); }); const scriptedPainlessFieldName = 'ram_pain1'; From 0aabc317ec2a3d624c215c03ce472f236ae2b8bf Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 25 Feb 2021 16:13:27 -0700 Subject: [PATCH 29/40] [kbn/test] add import/export support to KbnClient (#92526) Co-authored-by: Tre' Seymour Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: spalger --- package.json | 1 + packages/kbn-dev-utils/src/index.ts | 1 - .../src/actions/empty_kibana_index.ts | 3 +- packages/kbn-es-archiver/src/actions/load.ts | 3 +- .../kbn-es-archiver/src/actions/unload.ts | 3 +- packages/kbn-es-archiver/src/cli.ts | 4 +- packages/kbn-es-archiver/src/es_archiver.ts | 3 +- .../src/lib/indices/kibana_index.ts | 3 +- .../lib/config/schema.ts | 7 + packages/kbn-test/src/index.ts | 4 + packages/kbn-test/src/kbn_archiver_cli.ts | 149 ++++++++++++++++ .../src/kbn_client/index.ts | 0 .../src/kbn_client/kbn_client.ts | 12 +- .../kbn_client/kbn_client_import_export.ts | 163 ++++++++++++++++++ .../src/kbn_client/kbn_client_plugins.ts | 0 .../src/kbn_client/kbn_client_requester.ts | 9 +- .../kbn_client/kbn_client_saved_objects.ts | 102 ++++++++++- .../src/kbn_client/kbn_client_status.ts | 0 .../src/kbn_client/kbn_client_ui_settings.ts | 2 +- .../src/kbn_client/kbn_client_version.ts | 0 scripts/kbn_archiver.js | 10 ++ .../services/kibana_server/kibana_server.ts | 3 +- test/common/services/security/role.ts | 3 +- .../common/services/security/role_mappings.ts | 3 +- test/common/services/security/user.ts | 3 +- test/functional/apps/discover/_discover.ts | 5 +- .../fixtures/kbn_archiver/discover.json | 51 ++++++ .../case/server/scripts/sub_cases/index.ts | 3 +- .../common/endpoint/index_data.ts | 2 +- .../kbn_client_with_api_key_support.ts | 2 +- .../endpoint/resolver_generator_script.ts | 3 +- .../scripts/endpoint/trusted_apps/index.ts | 3 +- .../detection_engine_api_integration/utils.ts | 2 +- yarn.lock | 9 + 34 files changed, 546 insertions(+), 25 deletions(-) create mode 100644 packages/kbn-test/src/kbn_archiver_cli.ts rename packages/{kbn-dev-utils => kbn-test}/src/kbn_client/index.ts (100%) rename packages/{kbn-dev-utils => kbn-test}/src/kbn_client/kbn_client.ts (87%) create mode 100644 packages/kbn-test/src/kbn_client/kbn_client_import_export.ts rename packages/{kbn-dev-utils => kbn-test}/src/kbn_client/kbn_client_plugins.ts (100%) rename packages/{kbn-dev-utils => kbn-test}/src/kbn_client/kbn_client_requester.ts (93%) rename packages/{kbn-dev-utils => kbn-test}/src/kbn_client/kbn_client_saved_objects.ts (60%) rename packages/{kbn-dev-utils => kbn-test}/src/kbn_client/kbn_client_status.ts (100%) rename packages/{kbn-dev-utils => kbn-test}/src/kbn_client/kbn_client_ui_settings.ts (98%) rename packages/{kbn-dev-utils => kbn-test}/src/kbn_client/kbn_client_version.ts (100%) create mode 100644 scripts/kbn_archiver.js create mode 100644 test/functional/fixtures/kbn_archiver/discover.json diff --git a/package.json b/package.json index 90096bfdf1b80f..3dde0c6a17fb51 100644 --- a/package.json +++ b/package.json @@ -655,6 +655,7 @@ "fetch-mock": "^7.3.9", "file-loader": "^4.2.0", "file-saver": "^1.3.8", + "form-data": "^4.0.0", "formsy-react": "^1.1.5", "geckodriver": "^1.21.0", "glob-watcher": "5.0.3", diff --git a/packages/kbn-dev-utils/src/index.ts b/packages/kbn-dev-utils/src/index.ts index 66ad4e7be589b8..3ac3927d25c056 100644 --- a/packages/kbn-dev-utils/src/index.ts +++ b/packages/kbn-dev-utils/src/index.ts @@ -23,7 +23,6 @@ export { KBN_P12_PATH, KBN_P12_PASSWORD, } from './certs'; -export * from './kbn_client'; export * from './run'; export * from './axios'; export * from './stdio'; diff --git a/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts b/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts index 300c9f4dd66b07..2c36e24453c626 100644 --- a/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts +++ b/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts @@ -7,7 +7,8 @@ */ import { Client } from '@elastic/elasticsearch'; -import { ToolingLog, KbnClient } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; import { migrateKibanaIndex, createStats, cleanKibanaIndices } from '../lib'; diff --git a/packages/kbn-es-archiver/src/actions/load.ts b/packages/kbn-es-archiver/src/actions/load.ts index 60af6b3aa747bf..68d54373360237 100644 --- a/packages/kbn-es-archiver/src/actions/load.ts +++ b/packages/kbn-es-archiver/src/actions/load.ts @@ -9,7 +9,8 @@ import { resolve } from 'path'; import { createReadStream } from 'fs'; import { Readable } from 'stream'; -import { ToolingLog, KbnClient } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; import { Client } from '@elastic/elasticsearch'; import { createPromiseFromStreams, concatStreamProviders } from '@kbn/utils'; import { ES_CLIENT_HEADERS } from '../client_headers'; diff --git a/packages/kbn-es-archiver/src/actions/unload.ts b/packages/kbn-es-archiver/src/actions/unload.ts index c12fa935f786ad..b5f259a1496bb8 100644 --- a/packages/kbn-es-archiver/src/actions/unload.ts +++ b/packages/kbn-es-archiver/src/actions/unload.ts @@ -10,7 +10,8 @@ import { resolve } from 'path'; import { createReadStream } from 'fs'; import { Readable, Writable } from 'stream'; import { Client } from '@elastic/elasticsearch'; -import { ToolingLog, KbnClient } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; import { createPromiseFromStreams } from '@kbn/utils'; import { diff --git a/packages/kbn-es-archiver/src/cli.ts b/packages/kbn-es-archiver/src/cli.ts index 919aff5c3851b4..9617457d4573ed 100644 --- a/packages/kbn-es-archiver/src/cli.ts +++ b/packages/kbn-es-archiver/src/cli.ts @@ -17,8 +17,8 @@ import Url from 'url'; import readline from 'readline'; import Fs from 'fs'; -import { RunWithCommands, createFlagError, KbnClient, CA_CERT_PATH } from '@kbn/dev-utils'; -import { readConfigFile } from '@kbn/test'; +import { RunWithCommands, createFlagError, CA_CERT_PATH } from '@kbn/dev-utils'; +import { readConfigFile, KbnClient } from '@kbn/test'; import { Client } from '@elastic/elasticsearch'; import { EsArchiver } from './es_archiver'; diff --git a/packages/kbn-es-archiver/src/es_archiver.ts b/packages/kbn-es-archiver/src/es_archiver.ts index b00b9fb8b3f25f..68eacb4f3caf22 100644 --- a/packages/kbn-es-archiver/src/es_archiver.ts +++ b/packages/kbn-es-archiver/src/es_archiver.ts @@ -7,7 +7,8 @@ */ import { Client } from '@elastic/elasticsearch'; -import { ToolingLog, KbnClient } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; import { saveAction, diff --git a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts index 7f0080783ee0ae..dc49085cbd4585 100644 --- a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts +++ b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts @@ -9,7 +9,8 @@ import { inspect } from 'util'; import { Client } from '@elastic/elasticsearch'; -import { ToolingLog, KbnClient } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; import { Stats } from '../stats'; import { deleteIndex } from './delete_index'; import { ES_CLIENT_HEADERS } from '../../client_headers'; diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index 4fd28678d2653f..0694bc4ffdb0fd 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -213,6 +213,13 @@ export const schema = Joi.object() }) .default(), + // settings for the saved objects svc + kbnArchiver: Joi.object() + .keys({ + directory: Joi.string().default(defaultRelativeToConfigPath('fixtures/kbn_archiver')), + }) + .default(), + // settings for the kibanaServer.uiSettings module uiSettings: Joi.object() .keys({ diff --git a/packages/kbn-test/src/index.ts b/packages/kbn-test/src/index.ts index 25a5c6541bf072..919dc8b4477f3a 100644 --- a/packages/kbn-test/src/index.ts +++ b/packages/kbn-test/src/index.ts @@ -48,3 +48,7 @@ export { getUrl } from './jest/utils/get_url'; export { runCheckJestConfigsCli } from './jest/run_check_jest_configs_cli'; export { runJest } from './jest/run'; + +export * from './kbn_archiver_cli'; + +export * from './kbn_client'; diff --git a/packages/kbn-test/src/kbn_archiver_cli.ts b/packages/kbn-test/src/kbn_archiver_cli.ts new file mode 100644 index 00000000000000..98bfa6eaa4046d --- /dev/null +++ b/packages/kbn-test/src/kbn_archiver_cli.ts @@ -0,0 +1,149 @@ +/* + * 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 Path from 'path'; +import Url from 'url'; + +import { RunWithCommands, createFlagError, Flags } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; + +import { readConfigFile } from './functional_test_runner'; + +function getSinglePositionalArg(flags: Flags) { + const positional = flags._; + if (positional.length < 1) { + throw createFlagError('missing name of export to import'); + } + + if (positional.length > 1) { + throw createFlagError(`extra positional arguments, expected 1, got [${positional}]`); + } + + return positional[0]; +} + +function parseTypesFlag(flags: Flags) { + if (!flags.type || (typeof flags.type !== 'string' && !Array.isArray(flags.type))) { + throw createFlagError('--type is a required flag'); + } + + const types = typeof flags.type === 'string' ? [flags.type] : flags.type; + return types.reduce( + (acc: string[], type) => [...acc, ...type.split(',').map((t) => t.trim())], + [] + ); +} + +export function runKbnArchiverCli() { + new RunWithCommands({ + description: 'Import/export saved objects from archives, for testing', + globalFlags: { + string: ['config', 'space', 'kibana-url', 'dir'], + help: ` + --space space id to operate on, defaults to the default space + --config optional path to an FTR config file that will be parsed and used for defaults + --kibana-url set the url that kibana can be reached at, uses the "servers.kibana" setting from --config by default + --dir directory that contains exports to be imported, or where exports will be saved, uses the "kbnArchiver.directory" + setting from --config by default + `, + }, + async extendContext({ log, flags }) { + let config; + if (flags.config) { + if (typeof flags.config !== 'string') { + throw createFlagError('expected --config to be a string'); + } + + config = await readConfigFile(log, Path.resolve(flags.config)); + } + + let kibanaUrl; + if (flags['kibana-url']) { + if (typeof flags['kibana-url'] !== 'string') { + throw createFlagError('expected --kibana-url to be a string'); + } + + kibanaUrl = flags['kibana-url']; + } else if (config) { + kibanaUrl = Url.format(config.get('servers.kibana')); + } + + if (!kibanaUrl) { + throw createFlagError( + 'Either a --config file with `servers.kibana` defined, or a --kibana-url must be passed' + ); + } + + let importExportDir; + if (flags.dir) { + if (typeof flags.dir !== 'string') { + throw createFlagError('expected --dir to be a string'); + } + + importExportDir = flags.dir; + } else if (config) { + importExportDir = config.get('kbnArchiver.directory'); + } + + if (!importExportDir) { + throw createFlagError( + '--config does not include a kbnArchiver.directory, specify it or include --dir flag' + ); + } + + const space = flags.space; + if (!(space === undefined || typeof space === 'string')) { + throw createFlagError('--space must be a string'); + } + + return { + space, + kbnClient: new KbnClient({ + log, + url: kibanaUrl, + importExportDir, + }), + }; + }, + }) + .command({ + name: 'save', + usage: 'save ', + description: 'export saved objects from Kibana to a file', + flags: { + string: ['type'], + help: ` + --type saved object type that should be fetched and stored in the archive, can + be specified multiple times or be a comma-separated list. + `, + }, + async run({ kbnClient, flags, space }) { + await kbnClient.importExport.save(getSinglePositionalArg(flags), { + types: parseTypesFlag(flags), + space, + }); + }, + }) + .command({ + name: 'load', + usage: 'load ', + description: 'import a saved export to Kibana', + async run({ kbnClient, flags, space }) { + await kbnClient.importExport.load(getSinglePositionalArg(flags), { space }); + }, + }) + .command({ + name: 'unload', + usage: 'unload ', + description: 'delete the saved objects saved in the archive from the Kibana index', + async run({ kbnClient, flags, space }) { + await kbnClient.importExport.unload(getSinglePositionalArg(flags), { space }); + }, + }) + .execute(); +} diff --git a/packages/kbn-dev-utils/src/kbn_client/index.ts b/packages/kbn-test/src/kbn_client/index.ts similarity index 100% rename from packages/kbn-dev-utils/src/kbn_client/index.ts rename to packages/kbn-test/src/kbn_client/index.ts diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client.ts b/packages/kbn-test/src/kbn_client/kbn_client.ts similarity index 87% rename from packages/kbn-dev-utils/src/kbn_client/kbn_client.ts rename to packages/kbn-test/src/kbn_client/kbn_client.ts index 963639d47045b4..3fa74412c1a8bf 100644 --- a/packages/kbn-dev-utils/src/kbn_client/kbn_client.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client.ts @@ -6,19 +6,22 @@ * Side Public License, v 1. */ -import { ToolingLog } from '../tooling_log'; +import { ToolingLog } from '@kbn/dev-utils'; + import { KbnClientRequester, ReqOptions } from './kbn_client_requester'; import { KbnClientStatus } from './kbn_client_status'; import { KbnClientPlugins } from './kbn_client_plugins'; import { KbnClientVersion } from './kbn_client_version'; import { KbnClientSavedObjects } from './kbn_client_saved_objects'; import { KbnClientUiSettings, UiSettingValues } from './kbn_client_ui_settings'; +import { KbnClientImportExport } from './kbn_client_import_export'; export interface KbnClientOptions { url: string; certificateAuthorities?: Buffer[]; log: ToolingLog; uiSettingDefaults?: UiSettingValues; + importExportDir?: string; } export class KbnClient { @@ -27,6 +30,7 @@ export class KbnClient { readonly version: KbnClientVersion; readonly savedObjects: KbnClientSavedObjects; readonly uiSettings: KbnClientUiSettings; + readonly importExport: KbnClientImportExport; private readonly requester: KbnClientRequester; private readonly log: ToolingLog; @@ -56,6 +60,12 @@ export class KbnClient { this.version = new KbnClientVersion(this.status); this.savedObjects = new KbnClientSavedObjects(this.log, this.requester); this.uiSettings = new KbnClientUiSettings(this.log, this.requester, this.uiSettingDefaults); + this.importExport = new KbnClientImportExport( + this.log, + this.requester, + this.savedObjects, + options.importExportDir + ); } /** diff --git a/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts b/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts new file mode 100644 index 00000000000000..bb5b99fdc44395 --- /dev/null +++ b/packages/kbn-test/src/kbn_client/kbn_client_import_export.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 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 { inspect } from 'util'; +import Fs from 'fs/promises'; +import Path from 'path'; + +import FormData from 'form-data'; +import { ToolingLog, isAxiosResponseError, createFailError } from '@kbn/dev-utils'; + +import { KbnClientRequester, uriencode, ReqOptions } from './kbn_client_requester'; +import { KbnClientSavedObjects } from './kbn_client_saved_objects'; + +interface ImportApiResponse { + success: boolean; + [key: string]: unknown; +} + +interface SavedObject { + id: string; + type: string; + [key: string]: unknown; +} + +async function parseArchive(path: string): Promise { + return (await Fs.readFile(path, 'utf-8')) + .split('\n\n') + .filter((line) => !!line) + .map((line) => JSON.parse(line)); +} + +export class KbnClientImportExport { + constructor( + public readonly log: ToolingLog, + public readonly requester: KbnClientRequester, + public readonly savedObjects: KbnClientSavedObjects, + public readonly dir?: string + ) {} + + private resolvePath(path: string) { + if (!Path.extname(path)) { + path = `${path}.json`; + } + + if (!this.dir && !Path.isAbsolute(path)) { + throw new Error( + 'unable to resolve relative path to import/export without a configured dir, either path absolute path or specify --dir' + ); + } + + return this.dir ? Path.resolve(this.dir, path) : path; + } + + async load(name: string, options?: { space?: string }) { + const src = this.resolvePath(name); + this.log.debug('resolved import for', name, 'to', src); + + const objects = await parseArchive(src); + this.log.info('importing', objects.length, 'saved objects', { space: options?.space }); + + const formData = new FormData(); + formData.append('file', objects.map((obj) => JSON.stringify(obj)).join('\n'), 'import.ndjson'); + + // TODO: should we clear out the existing saved objects? + const resp = await this.req(options?.space, { + method: 'POST', + path: '/api/saved_objects/_import', + query: { + overwrite: true, + }, + body: formData, + headers: formData.getHeaders(), + }); + + if (resp.data.success) { + this.log.success('import success'); + } else { + throw createFailError(`failed to import all saved objects: ${inspect(resp.data)}`); + } + } + + async unload(name: string, options?: { space?: string }) { + const src = this.resolvePath(name); + this.log.debug('unloading docs from archive at', src); + + const objects = await parseArchive(src); + this.log.info('deleting', objects.length, 'objects', { space: options?.space }); + + const { deleted, missing } = await this.savedObjects.bulkDelete({ + space: options?.space, + objects, + }); + + if (missing) { + this.log.info(missing, 'saved objects were already deleted'); + } + + this.log.success(deleted, 'saved objects deleted'); + } + + async save(name: string, options: { types: string[]; space?: string }) { + const dest = this.resolvePath(name); + this.log.debug('saving export to', dest); + + const resp = await this.req(options.space, { + method: 'POST', + path: '/api/saved_objects/_export', + body: { + type: options.types, + excludeExportDetails: true, + includeReferencesDeep: true, + }, + }); + + if (typeof resp.data !== 'string') { + throw createFailError(`unexpected response from export API: ${inspect(resp.data)}`); + } + + const objects = resp.data + .split('\n') + .filter((l) => !!l) + .map((line) => JSON.parse(line)); + + const fileContents = objects + .map((obj) => { + const { sort: _, ...nonSortFields } = obj; + return JSON.stringify(nonSortFields, null, 2); + }) + .join('\n\n'); + + await Fs.writeFile(dest, fileContents, 'utf-8'); + + this.log.success('Exported', objects.length, 'saved objects to', dest); + } + + private async req(space: string | undefined, options: ReqOptions) { + if (!options.path.startsWith('/')) { + throw new Error('options.path must start with a /'); + } + + try { + return await this.requester.request({ + ...options, + path: space ? uriencode`/s/${space}` + options.path : options.path, + }); + } catch (error) { + if (!isAxiosResponseError(error)) { + throw error; + } + + throw createFailError( + `${error.response.status} resp: ${inspect(error.response.data)}\nreq: ${inspect( + error.config + )}` + ); + } + } +} diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client_plugins.ts b/packages/kbn-test/src/kbn_client/kbn_client_plugins.ts similarity index 100% rename from packages/kbn-dev-utils/src/kbn_client/kbn_client_plugins.ts rename to packages/kbn-test/src/kbn_client/kbn_client_plugins.ts diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client_requester.ts b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts similarity index 93% rename from packages/kbn-dev-utils/src/kbn_client/kbn_client_requester.ts rename to packages/kbn-test/src/kbn_client/kbn_client_requester.ts index d940525f57e3c2..2e1575aee18974 100644 --- a/packages/kbn-dev-utils/src/kbn_client/kbn_client_requester.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts @@ -8,10 +8,10 @@ import Url from 'url'; import Https from 'https'; -import Axios, { AxiosResponse } from 'axios'; +import Qs from 'querystring'; -import { isAxiosRequestError, isAxiosResponseError } from '../axios'; -import { ToolingLog } from '../tooling_log'; +import Axios, { AxiosResponse } from 'axios'; +import { ToolingLog, isAxiosRequestError, isAxiosResponseError } from '@kbn/dev-utils'; const isConcliftOnGetError = (error: any) => { return ( @@ -52,6 +52,7 @@ export interface ReqOptions { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; body?: any; retries?: number; + headers?: Record; } const delay = (ms: number) => @@ -102,9 +103,11 @@ export class KbnClientRequester { data: options.body, params: options.query, headers: { + ...options.headers, 'kbn-xsrf': 'kbn-client', }, httpsAgent: this.httpsAgent, + paramsSerializer: (params) => Qs.stringify(params), }); return response; diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client_saved_objects.ts b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts similarity index 60% rename from packages/kbn-dev-utils/src/kbn_client/kbn_client_saved_objects.ts rename to packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts index 9d616d6f50a88b..904ccc385bd7df 100644 --- a/packages/kbn-dev-utils/src/kbn_client/kbn_client_saved_objects.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_saved_objects.ts @@ -6,7 +6,12 @@ * Side Public License, v 1. */ -import { ToolingLog } from '../tooling_log'; +import { inspect } from 'util'; + +import * as Rx from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; +import { lastValueFrom } from '@kbn/std'; +import { ToolingLog, isAxiosResponseError, createFailError } from '@kbn/dev-utils'; import { KbnClientRequester, uriencode } from './kbn_client_requester'; @@ -51,6 +56,38 @@ interface MigrateResponse { result: Array<{ status: string }>; } +interface FindApiResponse { + saved_objects: Array<{ + type: string; + id: string; + [key: string]: unknown; + }>; + total: number; + per_page: number; + page: number; +} + +interface CleanOptions { + space?: string; + types: string[]; +} + +interface DeleteObjectsOptions { + space?: string; + objects: Array<{ + type: string; + id: string; + }>; +} + +async function concurrently(maxConcurrency: number, arr: T[], fn: (item: T) => Promise) { + if (arr.length) { + await lastValueFrom( + Rx.from(arr).pipe(mergeMap(async (item) => await fn(item), maxConcurrency)) + ); + } +} + export class KbnClientSavedObjects { constructor(private readonly log: ToolingLog, private readonly requester: KbnClientRequester) {} @@ -143,4 +180,67 @@ export class KbnClientSavedObjects { return data; } + + public async clean(options: CleanOptions) { + this.log.debug('Cleaning all saved objects', { space: options.space }); + + let deleted = 0; + + while (true) { + const resp = await this.requester.request({ + method: 'GET', + path: options.space + ? uriencode`/s/${options.space}/api/saved_objects/_find` + : '/api/saved_objects/_find', + query: { + per_page: 1000, + type: options.types, + fields: 'none', + }, + }); + + this.log.info('deleting batch of', resp.data.saved_objects.length, 'objects'); + const deletion = await this.bulkDelete({ + space: options.space, + objects: resp.data.saved_objects, + }); + deleted += deletion.deleted; + + if (resp.data.total <= resp.data.per_page) { + break; + } + } + + this.log.success('deleted', deleted, 'objects'); + } + + public async bulkDelete(options: DeleteObjectsOptions) { + let deleted = 0; + let missing = 0; + + await concurrently(20, options.objects, async (obj) => { + try { + await this.requester.request({ + method: 'DELETE', + path: options.space + ? uriencode`/s/${options.space}/api/saved_objects/${obj.type}/${obj.id}` + : uriencode`/api/saved_objects/${obj.type}/${obj.id}`, + }); + deleted++; + } catch (error) { + if (isAxiosResponseError(error)) { + if (error.response.status === 404) { + missing++; + return; + } + + throw createFailError(`${error.response.status} resp: ${inspect(error.response.data)}`); + } + + throw error; + } + }); + + return { deleted, missing }; + } } diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client_status.ts b/packages/kbn-test/src/kbn_client/kbn_client_status.ts similarity index 100% rename from packages/kbn-dev-utils/src/kbn_client/kbn_client_status.ts rename to packages/kbn-test/src/kbn_client/kbn_client_status.ts diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client_ui_settings.ts b/packages/kbn-test/src/kbn_client/kbn_client_ui_settings.ts similarity index 98% rename from packages/kbn-dev-utils/src/kbn_client/kbn_client_ui_settings.ts rename to packages/kbn-test/src/kbn_client/kbn_client_ui_settings.ts index 75fd7a4c8391ec..78155098ef0388 100644 --- a/packages/kbn-dev-utils/src/kbn_client/kbn_client_ui_settings.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_ui_settings.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { ToolingLog } from '../tooling_log'; +import { ToolingLog } from '@kbn/dev-utils'; import { KbnClientRequester, uriencode } from './kbn_client_requester'; diff --git a/packages/kbn-dev-utils/src/kbn_client/kbn_client_version.ts b/packages/kbn-test/src/kbn_client/kbn_client_version.ts similarity index 100% rename from packages/kbn-dev-utils/src/kbn_client/kbn_client_version.ts rename to packages/kbn-test/src/kbn_client/kbn_client_version.ts diff --git a/scripts/kbn_archiver.js b/scripts/kbn_archiver.js new file mode 100644 index 00000000000000..b04b86a0d4eed1 --- /dev/null +++ b/scripts/kbn_archiver.js @@ -0,0 +1,10 @@ +/* + * 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. + */ + +require('../src/setup_node_env'); +require('@kbn/test').runKbnArchiverCli(); diff --git a/test/common/services/kibana_server/kibana_server.ts b/test/common/services/kibana_server/kibana_server.ts index 51f9dc9d00772a..f366a864db980d 100644 --- a/test/common/services/kibana_server/kibana_server.ts +++ b/test/common/services/kibana_server/kibana_server.ts @@ -7,7 +7,7 @@ */ import Url from 'url'; -import { KbnClient } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -22,6 +22,7 @@ export function KibanaServerProvider({ getService }: FtrProviderContext) { url, certificateAuthorities: config.get('servers.kibana.certificateAuthorities'), uiSettingDefaults: defaults, + importExportDir: config.get('kbnArchiver.directory'), }); if (defaults) { diff --git a/test/common/services/security/role.ts b/test/common/services/security/role.ts index 2aae5b22829403..420bed027f3170 100644 --- a/test/common/services/security/role.ts +++ b/test/common/services/security/role.ts @@ -7,7 +7,8 @@ */ import util from 'util'; -import { KbnClient, ToolingLog } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; export class Role { constructor(private log: ToolingLog, private kibanaServer: KbnClient) {} diff --git a/test/common/services/security/role_mappings.ts b/test/common/services/security/role_mappings.ts index c20ff7e327b64a..af9204866ad47d 100644 --- a/test/common/services/security/role_mappings.ts +++ b/test/common/services/security/role_mappings.ts @@ -7,7 +7,8 @@ */ import util from 'util'; -import { KbnClient, ToolingLog } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; export class RoleMappings { constructor(private log: ToolingLog, private kbnClient: KbnClient) {} diff --git a/test/common/services/security/user.ts b/test/common/services/security/user.ts index 0d12a0dae2e461..3bd31bb5ed1864 100644 --- a/test/common/services/security/user.ts +++ b/test/common/services/security/user.ts @@ -7,7 +7,8 @@ */ import util from 'util'; -import { KbnClient, ToolingLog } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; export class User { constructor(private log: ToolingLog, private kbnClient: KbnClient) {} diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index 9323c9e2fe70b0..aeb02e5c30eb88 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -20,6 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const inspector = getService('inspector'); const elasticChart = getService('elasticChart'); const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); + const defaultSettings = { defaultIndex: 'logstash-*', }; @@ -27,7 +28,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('discover test', function describeIndexTests() { before(async function () { log.debug('load kibana index with default index pattern'); - await esArchiver.load('discover'); + + await kibanaServer.savedObjects.clean({ types: ['search'] }); + await kibanaServer.importExport.load('discover'); // and load a set of makelogs data await esArchiver.loadIfNeeded('logstash_functional'); diff --git a/test/functional/fixtures/kbn_archiver/discover.json b/test/functional/fixtures/kbn_archiver/discover.json new file mode 100644 index 00000000000000..e861f875a2d9e1 --- /dev/null +++ b/test/functional/fixtures/kbn_archiver/discover.json @@ -0,0 +1,51 @@ +{ + "attributes": { + "fieldAttrs": "{\"referer\":{\"customLabel\":\"Referer custom\"}}", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"@message\"}}},{\"name\":\"@tags\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"@tags\"}}},{\"name\":\"@timestamp\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"agent\"}}},{\"name\":\"bytes\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"extension\"}}},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"headings\"}}},{\"name\":\"host\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"host\"}}},{\"name\":\"id\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"index\"}}},{\"name\":\"ip\",\"type\":\"ip\",\"esTypes\":[\"ip\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"links\"}}},{\"name\":\"machine.os\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"machine.os\"}}},{\"name\":\"machine.ram\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"esTypes\":[\"double\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"esTypes\":[\"integer\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"nestedField.child\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"nested\":{\"path\":\"nestedField\"}}},{\"name\":\"phpmemory\",\"type\":\"number\",\"esTypes\":[\"long\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.article:section\"}}},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.article:tag\"}}},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:description\"}}},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:image\"}}},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:image:height\"}}},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:image:width\"}}},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:site_name\"}}},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:title\"}}},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:type\"}}},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.og:url\"}}},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:card\"}}},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:description\"}}},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:image\"}}},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:site\"}}},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.twitter:title\"}}},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"relatedContent.url\"}}},{\"name\":\"request\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"request\"}}},{\"name\":\"response\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"response\"}}},{\"name\":\"spaces\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"spaces\"}}},{\"name\":\"type\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"url\"}}},{\"name\":\"utc_time\",\"type\":\"date\",\"esTypes\":[\"date\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"esTypes\":[\"text\"],\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"xss\"}}}]", + "timeFieldName": "@timestamp", + "title": "logstash-*" + }, + "coreMigrationVersion": "8.0.0", + "id": "logstash-*", + "migrationVersion": { + "index-pattern": "7.11.0" + }, + "references": [], + "type": "index-pattern", + "version": "WzQsMl0=" +} + +{ + "attributes": { + "columns": [ + "_source" + ], + "description": "A Saved Search Description", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"highlightAll\":true,\"filter\":[],\"query\":{\"query_string\":{\"query\":\"*\",\"analyze_wildcard\":true}},\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\"}" + }, + "sort": [ + [ + "@timestamp", + "desc" + ] + ], + "title": "A Saved Search", + "version": 1 + }, + "coreMigrationVersion": "8.0.0", + "id": "ab12e3c0-f231-11e6-9486-733b1ac9221a", + "migrationVersion": { + "search": "7.9.3" + }, + "references": [ + { + "id": "logstash-*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern" + } + ], + "type": "search", + "version": "WzUsMl0=" +} \ No newline at end of file diff --git a/x-pack/plugins/case/server/scripts/sub_cases/index.ts b/x-pack/plugins/case/server/scripts/sub_cases/index.ts index 9dd577c40c74e6..f6ec1f44076e4c 100644 --- a/x-pack/plugins/case/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -6,7 +6,8 @@ */ /* eslint-disable no-console */ import yargs from 'yargs'; -import { KbnClient, ToolingLog } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; import { CaseResponse, CaseType, diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts index 3ff719d267f400..96f83f1073fcc8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/index_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -7,7 +7,7 @@ import { Client } from '@elastic/elasticsearch'; import seedrandom from 'seedrandom'; -import { KbnClient } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; import { AxiosResponse } from 'axios'; import { EndpointDocGenerator, TreeOptions, Event } from './generate_data'; import { firstNonNullValue } from './models/ecs_safety_helpers'; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/kbn_client_with_api_key_support.ts b/x-pack/plugins/security_solution/scripts/endpoint/kbn_client_with_api_key_support.ts index 557b485682e150..a8ed7b77a5d9e0 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/kbn_client_with_api_key_support.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/kbn_client_with_api_key_support.ts @@ -7,7 +7,7 @@ import { URL } from 'url'; -import { KbnClient, KbnClientOptions } from '@kbn/dev-utils'; +import { KbnClient, KbnClientOptions } from '@kbn/test'; import fetch, { RequestInit } from 'node-fetch'; export class KbnClientWithApiKeySupport extends KbnClient { diff --git a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts index a4664e4a51ca23..72f56f13eaddf9 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts @@ -10,7 +10,8 @@ import yargs from 'yargs'; import fs from 'fs'; import { Client, ClientOptions } from '@elastic/elasticsearch'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; -import { KbnClient, ToolingLog, CA_CERT_PATH } from '@kbn/dev-utils'; +import { ToolingLog, CA_CERT_PATH } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; import { AxiosResponse } from 'axios'; import { indexHostsAndAlerts } from '../../common/endpoint/index_data'; import { ANCESTRY_LIMIT, EndpointDocGenerator } from '../../common/endpoint/generate_data'; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts index 3b914f987456ac..582969f1dcd450 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts @@ -7,7 +7,8 @@ // @ts-ignore import minimist from 'minimist'; -import { KbnClient, ToolingLog } from '@kbn/dev-utils'; +import { ToolingLog } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; import bluebird from 'bluebird'; import { basename } from 'path'; import { TRUSTED_APPS_CREATE_API, TRUSTED_APPS_LIST_API } from '../../../common/endpoint/constants'; diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 7711338b446977..683d57081a2674 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { KbnClient } from '@kbn/dev-utils'; +import { KbnClient } from '@kbn/test'; import { ApiResponse, Client } from '@elastic/elasticsearch'; import { SuperTest } from 'supertest'; import supertestAsPromised from 'supertest-as-promised'; diff --git a/yarn.lock b/yarn.lock index 92c67cda974c3c..6c706cffebaaf3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14954,6 +14954,15 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" From 3df61f760232fed506db282846908e9b5c640974 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Thu, 25 Feb 2021 18:36:47 -0500 Subject: [PATCH 30/40] API docs (#92827) --- .../build_api_declaration.ts | 1 - .../build_arrow_fn_dec.ts | 3 - .../build_function_dec.ts | 1 - .../build_variable_dec.ts | 1 - .../extract_import_refs.test.ts | 39 +- .../extract_import_refs.ts | 14 +- .../api_docs/build_api_declarations/utils.ts | 2 +- .../src/api_docs/mdx/write_plugin_mdx_docs.ts | 2 +- .../api_docs/tests/snapshots/plugin_a.json | 1567 ++++++++++++++++- .../tests/snapshots/plugin_a_foo.json | 78 +- packages/kbn-docs-utils/src/api_docs/types.ts | 2 +- packages/kbn-docs-utils/src/api_docs/utils.ts | 3 - src/dev/ci_setup/setup.sh | 16 + 13 files changed, 1710 insertions(+), 19 deletions(-) diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_api_declaration.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_api_declaration.ts index 3ee6676cf5e32c..2d1cd7b9f97ca1 100644 --- a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_api_declaration.ts +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_api_declaration.ts @@ -50,7 +50,6 @@ export function buildApiDeclaration( name?: string ): ApiDeclaration { const apiName = name ? name : isNamedNode(node) ? node.getName() : 'Unnamed'; - log.debug(`Building API Declaration for ${apiName} of kind ${node.getKindName()}`); const apiId = parentApiId ? parentApiId + '.' + apiName : apiName; const anchorLink: AnchorLink = { scope, pluginName, apiName: apiId }; diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_arrow_fn_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_arrow_fn_dec.ts index 146fcf4fa4d0a9..2f041c8d42b4b5 100644 --- a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_arrow_fn_dec.ts +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_arrow_fn_dec.ts @@ -47,9 +47,6 @@ export function getArrowFunctionDec( anchorLink: AnchorLink, log: ToolingLog ) { - log.debug( - `Getting Arrow Function doc def for node ${node.getName()} of kind ${node.getKindName()}` - ); return { id: getApiSectionId(anchorLink), type: TypeKind.FunctionKind, diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_dec.ts index 2936699152a83c..89050430085fd5 100644 --- a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_dec.ts +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_function_dec.ts @@ -39,7 +39,6 @@ export function buildFunctionDec( const label = Node.isConstructorDeclaration(node) ? 'Constructor' : node.getName() || '(WARN: Missing name)'; - log.debug(`Getting function doc def for node ${label} of kind ${node.getKindName()}`); return { id: getApiSectionId(anchorLink), type: TypeKind.FunctionKind, diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_variable_dec.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_variable_dec.ts index 3e0b48de1e18b1..86e8e5078b6fb2 100644 --- a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_variable_dec.ts +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/build_variable_dec.ts @@ -45,7 +45,6 @@ export function buildVariableDec( anchorLink: AnchorLink, log: ToolingLog ): ApiDeclaration { - log.debug('buildVariableDec for ' + node.getName()); const initializer = node.getInitializer(); // Recusively list object properties as children. if (initializer && Node.isObjectLiteralExpression(initializer)) { diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.test.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.test.ts index a757df2ece366b..f3fafca4c1e827 100644 --- a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.test.ts +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { REPO_ROOT } from '@kbn/utils'; import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; import { getPluginApiDocId } from '../utils'; import { extractImportReferences } from './extract_import_refs'; @@ -82,7 +83,43 @@ it('test extractImportReference with unknown imports', () => { expect(results.length).toBe(3); expect(results[0]).toBe(''); +}); + +it('test full file imports with no matching plugin', () => { + const refs = extractImportReferences( + `typeof import("${REPO_ROOT}/src/plugins/data/common/es_query/kuery/node_types/function")`, + plugins, + log + ); + expect(refs).toMatchInlineSnapshot(` + Array [ + "typeof ", + "src/plugins/data/common/es_query/kuery/node_types/function", + ] + `); + expect(refs.length).toBe(2); +}); + +it('test full file imports with a matching plugin', () => { + const refs = extractImportReferences( + `typeof import("${plugin.directory}/public/foo/index") something`, + plugins, + log + ); + expect(refs).toMatchInlineSnapshot(` + Array [ + "typeof ", + Object { + "docId": "kibPluginAPluginApi", + "pluginId": "pluginA", + "scope": "public", + "section": undefined, + "text": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index", + }, + " something", + ] + `); + expect(refs.length).toBe(3); }); it('test single link', () => { diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.ts index 1147e15a1acb60..92f191197472d3 100644 --- a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.ts +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/extract_import_refs.ts @@ -9,6 +9,7 @@ import { KibanaPlatformPlugin, ToolingLog } from '@kbn/dev-utils'; import { getApiSectionId, getPluginApiDocId, getPluginForPath } from '../utils'; import { ApiScope, TextWithLinks } from '../types'; +import { getRelativePath } from './utils'; /** * @@ -54,6 +55,9 @@ export function extractImportReferences( const str = textSegment.substr(index + length - name.length, name.length); if (str && str !== '') { texts.push(str); + } else { + // If there is no ".Name" then use the full path. You can see things like "typeof import("file")" + texts.push(getRelativePath(path)); } } else { const section = getApiSectionId({ @@ -69,10 +73,12 @@ export function extractImportReferences( apiPath: path, directory: plugin.directory, }), - section, - text: name, + section: name && name !== '' ? section : undefined, + text: name && name !== '' ? name : getRelativePath(path), }); } + + // Prep textSegment to skip past the `import`, then check for more. textSegment = textSegment.substr(index + length); } else { if (textSegment && textSegment !== '') { @@ -87,10 +93,10 @@ export function extractImportReferences( function extractImportRef( str: string ): { path: string; name: string; index: number; length: number } | undefined { - const groups = str.match(/import\("(.*?)"\)\.(\w*)/); + const groups = str.match(/import\("(.*?)"\)\.?(\w*)/); if (groups) { const path = groups[1]; - const name = groups[2]; + const name = groups.length > 2 ? groups[2] : ''; const index = groups.index!; const length = groups[0].length; return { path, name, index, length }; diff --git a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/utils.ts b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/utils.ts index 9efa96b6e96769..76683d968b9c22 100644 --- a/packages/kbn-docs-utils/src/api_docs/build_api_declarations/utils.ts +++ b/packages/kbn-docs-utils/src/api_docs/build_api_declarations/utils.ts @@ -17,7 +17,7 @@ export function isPrivate(node: ParameterDeclaration | ClassMemberTypes): boolea /** * Change the absolute path into a relative one. */ -function getRelativePath(fullPath: string): string { +export function getRelativePath(fullPath: string): string { return Path.relative(REPO_ROOT, fullPath); } diff --git a/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_mdx_docs.ts b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_mdx_docs.ts index b35515eb9d209e..608c30f0683576 100644 --- a/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_mdx_docs.ts +++ b/packages/kbn-docs-utils/src/api_docs/mdx/write_plugin_mdx_docs.ts @@ -84,7 +84,7 @@ import ${json} from './${fileName}.json'; common: groupPluginApi(doc.common), server: groupPluginApi(doc.server), }; - fs.writeFileSync(Path.resolve(folder, fileName + '.json'), JSON.stringify(scopedDoc)); + fs.writeFileSync(Path.resolve(folder, fileName + '.json'), JSON.stringify(scopedDoc, null, 2)); mdx += scopApiToMdx(scopedDoc.client, 'Client', json, 'client'); mdx += scopApiToMdx(scopedDoc.server, 'Server', json, 'server'); diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json index db25b8c4f021ee..ab605006d7e3de 100644 --- a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.json @@ -1 +1,1566 @@ -{"id":"pluginA","client":{"classes":[{"id":"def-public.ExampleClass","type":"Class","label":"ExampleClass","description":[],"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ExampleClass","text":"ExampleClass"}," implements ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.AnotherInterface","text":"AnotherInterface"},""],"children":[{"id":"def-public.ExampleClass.component","type":"CompoundType","label":"component","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":30,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L30"},"signature":["React.ComponentClass<{}, any> | React.FunctionComponent<{}> | undefined"]},{"id":"def-public.ExampleClass.Unnamed","type":"Function","label":"Constructor","signature":["any"],"description":[],"children":[{"type":"Uncategorized","label":"t","isRequired":true,"signature":["T"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":32,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L32"}}],"tags":[],"returnComment":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":32,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L32"}},{"id":"def-public.ExampleClass.arrowFn","type":"Function","children":[{"type":"CompoundType","label":"a","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":40,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L40"}}],"signature":["(a: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},") => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["\nan arrow fn on a class."],"label":"arrowFn","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":40,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L40"},"returnComment":[]},{"id":"def-public.ExampleClass.getVar","type":"Function","label":"getVar","signature":["(a: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},") => string"],"description":["\nA function on a class."],"children":[{"type":"CompoundType","label":"a","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["a param"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":46,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L46"}}],"tags":[],"returnComment":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":46,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L46"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":24,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L24"},"initialIsOpen":false},{"id":"def-public.CrazyClass","type":"Class","label":"CrazyClass","description":[],"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.CrazyClass","text":"CrazyClass"},"

extends ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ExampleClass","text":"ExampleClass"},"<",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.WithGen","text":"WithGen"},"

>"],"children":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":51,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L51"},"initialIsOpen":false}],"functions":[{"id":"def-public.notAnArrowFn","type":"Function","label":"notAnArrowFn","signature":["(a: string, b: number | undefined, c: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},", d: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},", e: string | undefined) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":["\nThis is a non arrow function.\n"],"children":[{"type":"string","label":"a","isRequired":true,"signature":["string"],"description":["The letter A"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":22,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L22"}},{"type":"number","label":"b","isRequired":false,"signature":["number | undefined"],"description":["Feed me to the function"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":23,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L23"}},{"type":"Array","label":"c","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":["So many params"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":24,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L24"}},{"type":"CompoundType","label":"d","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["a great param"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":25,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L25"}},{"type":"string","label":"e","isRequired":false,"signature":["string | undefined"],"description":["Another comment"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L26"}}],"tags":[],"returnComment":["something!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":21,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L21"},"initialIsOpen":false},{"id":"def-public.arrowFn","type":"Function","children":[{"type":"string","label":"a","isRequired":true,"signature":["string"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":42,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L42"}},{"type":"number","label":"b","isRequired":false,"signature":["number | undefined"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":43,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L43"}},{"type":"Array","label":"c","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":44,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L44"}},{"type":"CompoundType","label":"d","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":45,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L45"}},{"type":"string","label":"e","isRequired":false,"signature":["string | undefined"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":46,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L46"}}],"signature":["(a: string, b: number | undefined, c: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},", d: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},", e?: string | undefined) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"description":["\nThis is an arrow function.\n"],"label":"arrowFn","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":41,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L41"},"returnComment":["something!"],"initialIsOpen":false},{"id":"def-public.crazyFunction","type":"Function","children":[{"id":"def-public.crazyFunction.obj","type":"Object","label":"obj","description":[],"children":[{"id":"def-public.crazyFunction.obj.hi","type":"string","label":"hi","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":67,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L67"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":67,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L67"}},{"id":"def-public.crazyFunction.{-fn }","type":"Object","label":"{ fn }","description":[],"children":[{"id":"def-public.crazyFunction.{-fn }.fn","type":"Function","label":"fn","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":68,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L68"},"signature":["(foo: { param: string; }) => number"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":68,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L68"}},{"id":"def-public.crazyFunction.{-str }","type":"Object","label":"{ str }","description":[],"children":[{"id":"def-public.crazyFunction.{-str }.str","type":"string","label":"str","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":69,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L69"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":69,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L69"}}],"signature":["(obj: { hi: string; }, { fn }: { fn: (foo: { param: string; }) => number; }, { str }: { str: string; }) => () => () => number"],"description":["\nWho would write such a complicated function?? Ewwww.\n\nAccording to https://jsdoc.app/tags-param.html#parameters-with-properties,\nthis is how destructured arguements should be commented.\n"],"label":"crazyFunction","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":66,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L66"},"returnComment":["I have no idea."],"initialIsOpen":false},{"id":"def-public.fnWithNonExportedRef","type":"Function","children":[{"type":"Object","label":"a","isRequired":true,"signature":["ImNotExported"],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":76,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L76"}}],"signature":["(a: ImNotExported) => string"],"description":[],"label":"fnWithNonExportedRef","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":76,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L76"},"returnComment":[],"initialIsOpen":false}],"interfaces":[{"id":"def-public.SearchSpec","type":"Interface","label":"SearchSpec","description":["\nThe SearchSpec interface contains settings for creating a new SearchService, like\nusername and password."],"children":[{"id":"def-public.SearchSpec.username","type":"string","label":"username","description":["\nStores the username. Duh,"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L26"}},{"id":"def-public.SearchSpec.password","type":"string","label":"password","description":["\nStores the password. I hope it's encrypted!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":30,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L30"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":22,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L22"},"initialIsOpen":false},{"id":"def-public.WithGen","type":"Interface","label":"WithGen","signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.WithGen","text":"WithGen"},""],"description":["\nAn interface with a generic."],"children":[{"id":"def-public.WithGen.t","type":"Uncategorized","label":"t","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":17,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L17"},"signature":["T"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":16,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L16"},"initialIsOpen":false},{"id":"def-public.AnotherInterface","type":"Interface","label":"AnotherInterface","signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.AnotherInterface","text":"AnotherInterface"},""],"description":[],"children":[{"id":"def-public.AnotherInterface.t","type":"Uncategorized","label":"t","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":21,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L21"},"signature":["T"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":20,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L20"},"initialIsOpen":false},{"id":"def-public.ExampleInterface","type":"Interface","label":"ExampleInterface","signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ExampleInterface","text":"ExampleInterface"}," extends ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.AnotherInterface","text":"AnotherInterface"},""],"description":["\nThis is an example interface so we can see how it appears inside the API\ndocumentation system."],"children":[{"id":"def-public.ExampleInterface.getAPromiseThatResolvesToString","type":"Function","label":"getAPromiseThatResolvesToString","description":["\nThis gets a promise that resolves to a string."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":61,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L61"},"signature":["() => Promise"]},{"id":"def-public.ExampleInterface.aFnWithGen","type":"Function","label":"aFnWithGen","description":["\nThis function takes a generic. It was sometimes being tripped on\nand returned as an unknown type with no signature."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":67,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L67"},"signature":["(t: T) => void"]},{"id":"def-public.ExampleInterface.aFn","type":"Function","label":"aFn","signature":["() => void"],"description":["\nThese are not coming back properly."],"children":[],"tags":[],"returnComment":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":72,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L72"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":57,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L57"},"initialIsOpen":false},{"id":"def-public.IReturnAReactComponent","type":"Interface","label":"IReturnAReactComponent","description":["\nAn interface that has a react component."],"children":[{"id":"def-public.IReturnAReactComponent.component","type":"CompoundType","label":"component","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":79,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L79"},"signature":["React.ComponentType<{}>"]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts","lineNumber":78,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L78"},"initialIsOpen":false},{"id":"def-public.ImAnObject","type":"Interface","label":"ImAnObject","description":[],"children":[{"id":"def-public.ImAnObject.foo","type":"Function","label":"foo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":44,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L44"},"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.FnWithGeneric","text":"FnWithGeneric"}]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":43,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L43"},"initialIsOpen":false}],"enums":[{"id":"def-public.DayOfWeek","type":"Enum","label":"DayOfWeek","description":["\nComments on enums."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":31,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L31"},"initialIsOpen":false}],"misc":[{"id":"def-public.imAnAny","type":"Any","label":"imAnAny","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts","lineNumber":19,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts#L19"},"signature":["any"],"initialIsOpen":false},{"id":"def-public.imAnUnknown","type":"Unknown","label":"imAnUnknown","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts","lineNumber":20,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts#L20"},"signature":["unknown"],"initialIsOpen":false},{"id":"def-public.NotAnArrowFnType","type":"Type","label":"NotAnArrowFnType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts","lineNumber":78,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L78"},"signature":["(a: string, b: number | undefined, c: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},", d: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},", e: string | undefined) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"initialIsOpen":false},{"id":"def-public.aUnionProperty","type":"CompoundType","label":"aUnionProperty","description":["\nThis is a complicated union type"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":51,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L51"},"signature":["string | number | (() => string) | ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.CrazyClass","text":"CrazyClass"},""],"initialIsOpen":false},{"id":"def-public.aStrArray","type":"Array","label":"aStrArray","description":["\nThis is an array of strings. The type is explicit."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":56,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L56"},"signature":["string[]"],"initialIsOpen":false},{"id":"def-public.aNumArray","type":"Array","label":"aNumArray","description":["\nThis is an array of numbers. The type is implied."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":61,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L61"},"signature":["number[]"],"initialIsOpen":false},{"id":"def-public.aStr","type":"string","label":"aStr","description":["\nA string that says hi to you!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":66,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L66"},"initialIsOpen":false},{"id":"def-public.aNum","type":"number","label":"aNum","description":["\nIt's a number. A special number."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":71,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L71"},"signature":["10"],"initialIsOpen":false},{"id":"def-public.literalString","type":"string","label":"literalString","description":["\nI'm a type of string, but more specifically, a literal string type."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":76,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L76"},"signature":["\"HI\""],"initialIsOpen":false},{"id":"def-public.StringOrUndefinedType","type":"Type","label":"StringOrUndefinedType","description":["\nHow should a potentially undefined type show up."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":15,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L15"},"signature":["undefined | string"],"initialIsOpen":false},{"id":"def-public.TypeWithGeneric","type":"Type","label":"TypeWithGeneric","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":17,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L17"},"signature":["T[]"],"initialIsOpen":false},{"id":"def-public.ImAType","type":"Type","label":"ImAType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":19,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L19"},"signature":["string | number | ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAFooPluginApi","section":"def-public.FooType","text":"FooType"}," | ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"}," | ",{"pluginId":"pluginA","scope":"common","docId":"kibPluginAPluginApi","section":"def-common.ImACommonType","text":"ImACommonType"}],"initialIsOpen":false},{"id":"def-public.FnWithGeneric","type":"Type","label":"FnWithGeneric","description":["\nThis is a type that defines a function.\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L26"},"signature":["(t: T) => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.TypeWithGeneric","text":"TypeWithGeneric"},""],"initialIsOpen":false},{"id":"def-public.MultipleDeclarationsType","type":"Type","label":"MultipleDeclarationsType","description":["\nCalling node.getSymbol().getDeclarations() will return > 1 declaration."],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":40,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L40"},"signature":["(typeof DayOfWeek)[]"],"initialIsOpen":false},{"id":"def-public.IRefANotExportedType","type":"Type","label":"IRefANotExportedType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts","lineNumber":42,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L42"},"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAFooPluginApi","section":"def-public.ImNotExportedFromIndex","text":"ImNotExportedFromIndex"}," | { zed: \"hi\"; }"],"initialIsOpen":false}],"objects":[{"id":"def-public.aPretendNamespaceObj","type":"Object","children":[{"id":"def-public.aPretendNamespaceObj.notAnArrowFn","type":"Function","label":"notAnArrowFn","description":["/**\n * The docs should show this inline comment.\n */"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":21,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L21"},"signature":["typeof ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.notAnArrowFn","text":"notAnArrowFn"}]},{"id":"def-public.aPretendNamespaceObj.aPropertyMisdirection","type":"Function","label":"aPropertyMisdirection","description":["/**\n * Should this comment show up?\n */"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":26,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L26"},"signature":["typeof ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.notAnArrowFn","text":"notAnArrowFn"}]},{"id":"def-public.aPretendNamespaceObj.aPropertyInlineFn","type":"Function","children":[{"type":"CompoundType","label":"a","isRequired":true,"signature":[{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":31,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L31"}}],"signature":["(a: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"},") => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.ImAType","text":"ImAType"}],"description":["/**\n * I'm a property inline fun.\n */"],"label":"aPropertyInlineFn","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":31,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L31"},"returnComment":[]},{"id":"def-public.aPretendNamespaceObj.aPropertyStr","type":"string","label":"aPropertyStr","description":["/**\n * The only way for this to have a comment is to grab this.\n */"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":38,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L38"}},{"id":"def-public.aPretendNamespaceObj.nestedObj","type":"Object","children":[{"id":"def-public.aPretendNamespaceObj.nestedObj.foo","type":"string","label":"foo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":44,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L44"}}],"description":["/**\n * Will this nested object have it's children extracted appropriately?\n */"],"label":"nestedObj","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":43,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L43"}}],"description":["\nSome of the plugins wrap static exports in an object to create\na namespace like this."],"label":"aPretendNamespaceObj","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts","lineNumber":17,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L17"},"initialIsOpen":false}],"setup":{"id":"def-public.Setup","type":"Interface","label":"Setup","description":["\nAccess setup functionality from your plugin's setup function by adding the example\nplugin as a dependency.\n\n```ts\nClass MyPlugin {\n setup(core: CoreDependencies, { example }: PluginDependencies) {\n // Here you can access this functionality.\n example.getSearchService();\n }\n}\n```"],"children":[{"id":"def-public.Setup.getSearchService","type":"Function","label":"getSearchService","description":["\nA factory function that returns a new instance of Foo based\non the spec. We aren't sure if this is a good function so it's marked\nbeta. That should be clear in the docs because of the js doc tag.\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":96,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L96"},"signature":["(searchSpec: ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.SearchSpec","text":"SearchSpec"},") => string"]},{"id":"def-public.Setup.getSearchService2","type":"Function","label":"getSearchService2","description":["\nThis uses an inlined object type rather than referencing an exported type, which is discouraged.\nprefer the way {@link getSearchService} is typed.\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":104,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L104"},"signature":["(searchSpec: { username: string; password: string; }) => string"]},{"id":"def-public.Setup.doTheThing","type":"Function","label":"doTheThing","description":["\nThis function does the thing and it's so good at it! But we decided to deprecate it\nanyway. I hope that's clear to developers in the docs!\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":117,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L117"},"signature":["(thingOne: number, thingTwo: string, thingThree: { nestedVar: number; }) => void"]},{"id":"def-public.Setup.fnWithInlineParams","type":"Function","label":"fnWithInlineParams","description":["\nWho would write such a complicated function?? Ew, how will the obj parameter appear in docs?\n"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":128,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L128"},"signature":["(obj: { fn: (foo: { param: string; }) => number; }) => () => { retFoo: () => string; }"]},{"id":"def-public.Setup.id","type":"string","label":"id","description":["\nHi, I'm a comment for an id string!"],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":135,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L135"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":84,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L84"},"lifecycle":"setup","initialIsOpen":true},"start":{"id":"def-public.Start","type":"Interface","label":"Start","description":["\nAccess start functionality from your plugin's start function by adding the example\nplugin as a dependency.\n\n```ts\nClass MyPlugin {\n start(core: CoreDependencies, { example }: PluginDependencies) {\n // Here you can access this functionality.\n example.getSearchLanguage();\n }\n}\n```"],"children":[{"id":"def-public.Start.getSearchLanguage","type":"Function","label":"getSearchLanguage","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":68,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L68"},"signature":["() => ",{"pluginId":"pluginA","scope":"public","docId":"kibPluginAPluginApi","section":"def-public.SearchLanguage","text":"SearchLanguage"}]}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts","lineNumber":64,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L64"},"lifecycle":"start","initialIsOpen":true}},"server":{"classes":[],"functions":[],"interfaces":[],"enums":[],"misc":[],"objects":[]},"common":{"classes":[],"functions":[],"interfaces":[{"id":"def-common.ImACommonType","type":"Interface","label":"ImACommonType","description":[],"children":[{"id":"def-common.ImACommonType.goo","type":"number","label":"goo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts","lineNumber":12,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts#L12"}}],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts","lineNumber":11,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts#L11"},"initialIsOpen":false}],"enums":[],"misc":[],"objects":[]}} \ No newline at end of file +{ + "id": "pluginA", + "client": { + "classes": [ + { + "id": "def-public.ExampleClass", + "type": "Class", + "label": "ExampleClass", + "description": [], + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ExampleClass", + "text": "ExampleClass" + }, + " implements ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.AnotherInterface", + "text": "AnotherInterface" + }, + "" + ], + "children": [ + { + "id": "def-public.ExampleClass.component", + "type": "CompoundType", + "label": "component", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 30, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L30" + }, + "signature": [ + "React.ComponentClass<{}, any> | React.FunctionComponent<{}> | undefined" + ] + }, + { + "id": "def-public.ExampleClass.Unnamed", + "type": "Function", + "label": "Constructor", + "signature": [ + "any" + ], + "description": [], + "children": [ + { + "type": "Uncategorized", + "label": "t", + "isRequired": true, + "signature": [ + "T" + ], + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 32, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L32" + } + } + ], + "tags": [], + "returnComment": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 32, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L32" + } + }, + { + "id": "def-public.ExampleClass.arrowFn", + "type": "Function", + "children": [ + { + "type": "CompoundType", + "label": "a", + "isRequired": true, + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + } + ], + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 40, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L40" + } + } + ], + "signature": [ + "(a: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + }, + ") => ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + } + ], + "description": [ + "\nan arrow fn on a class." + ], + "label": "arrowFn", + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 40, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L40" + }, + "returnComment": [] + }, + { + "id": "def-public.ExampleClass.getVar", + "type": "Function", + "label": "getVar", + "signature": [ + "(a: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + }, + ") => string" + ], + "description": [ + "\nA function on a class." + ], + "children": [ + { + "type": "CompoundType", + "label": "a", + "isRequired": true, + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + } + ], + "description": [ + "a param" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 46, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L46" + } + } + ], + "tags": [], + "returnComment": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 46, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L46" + } + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 24, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L24" + }, + "initialIsOpen": false + }, + { + "id": "def-public.CrazyClass", + "type": "Class", + "label": "CrazyClass", + "description": [], + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.CrazyClass", + "text": "CrazyClass" + }, + "

extends ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ExampleClass", + "text": "ExampleClass" + }, + "<", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.WithGen", + "text": "WithGen" + }, + "

>" + ], + "children": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 51, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L51" + }, + "initialIsOpen": false + } + ], + "functions": [ + { + "id": "def-public.notAnArrowFn", + "type": "Function", + "label": "notAnArrowFn", + "signature": [ + "(a: string, b: number | undefined, c: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + ", d: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + }, + ", e: string | undefined) => ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + "" + ], + "description": [ + "\nThis is a non arrow function.\n" + ], + "children": [ + { + "type": "string", + "label": "a", + "isRequired": true, + "signature": [ + "string" + ], + "description": [ + "The letter A" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 22, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L22" + } + }, + { + "type": "number", + "label": "b", + "isRequired": false, + "signature": [ + "number | undefined" + ], + "description": [ + "Feed me to the function" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 23, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L23" + } + }, + { + "type": "Array", + "label": "c", + "isRequired": true, + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + "" + ], + "description": [ + "So many params" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 24, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L24" + } + }, + { + "type": "CompoundType", + "label": "d", + "isRequired": true, + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + } + ], + "description": [ + "a great param" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 25, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L25" + } + }, + { + "type": "string", + "label": "e", + "isRequired": false, + "signature": [ + "string | undefined" + ], + "description": [ + "Another comment" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 26, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L26" + } + } + ], + "tags": [], + "returnComment": [ + "something!" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 21, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L21" + }, + "initialIsOpen": false + }, + { + "id": "def-public.arrowFn", + "type": "Function", + "children": [ + { + "type": "string", + "label": "a", + "isRequired": true, + "signature": [ + "string" + ], + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 42, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L42" + } + }, + { + "type": "number", + "label": "b", + "isRequired": false, + "signature": [ + "number | undefined" + ], + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 43, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L43" + } + }, + { + "type": "Array", + "label": "c", + "isRequired": true, + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + "" + ], + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 44, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L44" + } + }, + { + "type": "CompoundType", + "label": "d", + "isRequired": true, + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + } + ], + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 45, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L45" + } + }, + { + "type": "string", + "label": "e", + "isRequired": false, + "signature": [ + "string | undefined" + ], + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 46, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L46" + } + } + ], + "signature": [ + "(a: string, b: number | undefined, c: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + ", d: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + }, + ", e?: string | undefined) => ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + "" + ], + "description": [ + "\nThis is an arrow function.\n" + ], + "label": "arrowFn", + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 41, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L41" + }, + "returnComment": [ + "something!" + ], + "initialIsOpen": false + }, + { + "id": "def-public.crazyFunction", + "type": "Function", + "children": [ + { + "id": "def-public.crazyFunction.obj", + "type": "Object", + "label": "obj", + "description": [], + "children": [ + { + "id": "def-public.crazyFunction.obj.hi", + "type": "string", + "label": "hi", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 67, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L67" + } + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 67, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L67" + } + }, + { + "id": "def-public.crazyFunction.{-fn }", + "type": "Object", + "label": "{ fn }", + "description": [], + "children": [ + { + "id": "def-public.crazyFunction.{-fn }.fn", + "type": "Function", + "label": "fn", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 68, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L68" + }, + "signature": [ + "(foo: { param: string; }) => number" + ] + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 68, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L68" + } + }, + { + "id": "def-public.crazyFunction.{-str }", + "type": "Object", + "label": "{ str }", + "description": [], + "children": [ + { + "id": "def-public.crazyFunction.{-str }.str", + "type": "string", + "label": "str", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 69, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L69" + } + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 69, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L69" + } + } + ], + "signature": [ + "(obj: { hi: string; }, { fn }: { fn: (foo: { param: string; }) => number; }, { str }: { str: string; }) => () => () => number" + ], + "description": [ + "\nWho would write such a complicated function?? Ewwww.\n\nAccording to https://jsdoc.app/tags-param.html#parameters-with-properties,\nthis is how destructured arguements should be commented.\n" + ], + "label": "crazyFunction", + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 66, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L66" + }, + "returnComment": [ + "I have no idea." + ], + "initialIsOpen": false + }, + { + "id": "def-public.fnWithNonExportedRef", + "type": "Function", + "children": [ + { + "type": "Object", + "label": "a", + "isRequired": true, + "signature": [ + "ImNotExported" + ], + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 76, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L76" + } + } + ], + "signature": [ + "(a: ImNotExported) => string" + ], + "description": [], + "label": "fnWithNonExportedRef", + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 76, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L76" + }, + "returnComment": [], + "initialIsOpen": false + } + ], + "interfaces": [ + { + "id": "def-public.SearchSpec", + "type": "Interface", + "label": "SearchSpec", + "description": [ + "\nThe SearchSpec interface contains settings for creating a new SearchService, like\nusername and password." + ], + "children": [ + { + "id": "def-public.SearchSpec.username", + "type": "string", + "label": "username", + "description": [ + "\nStores the username. Duh," + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 26, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L26" + } + }, + { + "id": "def-public.SearchSpec.password", + "type": "string", + "label": "password", + "description": [ + "\nStores the password. I hope it's encrypted!" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 30, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L30" + } + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 22, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L22" + }, + "initialIsOpen": false + }, + { + "id": "def-public.WithGen", + "type": "Interface", + "label": "WithGen", + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.WithGen", + "text": "WithGen" + }, + "" + ], + "description": [ + "\nAn interface with a generic." + ], + "children": [ + { + "id": "def-public.WithGen.t", + "type": "Uncategorized", + "label": "t", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 17, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L17" + }, + "signature": [ + "T" + ] + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 16, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L16" + }, + "initialIsOpen": false + }, + { + "id": "def-public.AnotherInterface", + "type": "Interface", + "label": "AnotherInterface", + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.AnotherInterface", + "text": "AnotherInterface" + }, + "" + ], + "description": [], + "children": [ + { + "id": "def-public.AnotherInterface.t", + "type": "Uncategorized", + "label": "t", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 21, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L21" + }, + "signature": [ + "T" + ] + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 20, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L20" + }, + "initialIsOpen": false + }, + { + "id": "def-public.ExampleInterface", + "type": "Interface", + "label": "ExampleInterface", + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ExampleInterface", + "text": "ExampleInterface" + }, + " extends ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.AnotherInterface", + "text": "AnotherInterface" + }, + "" + ], + "description": [ + "\nThis is an example interface so we can see how it appears inside the API\ndocumentation system." + ], + "children": [ + { + "id": "def-public.ExampleInterface.getAPromiseThatResolvesToString", + "type": "Function", + "label": "getAPromiseThatResolvesToString", + "description": [ + "\nThis gets a promise that resolves to a string." + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 61, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L61" + }, + "signature": [ + "() => Promise" + ] + }, + { + "id": "def-public.ExampleInterface.aFnWithGen", + "type": "Function", + "label": "aFnWithGen", + "description": [ + "\nThis function takes a generic. It was sometimes being tripped on\nand returned as an unknown type with no signature." + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 67, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L67" + }, + "signature": [ + "(t: T) => void" + ] + }, + { + "id": "def-public.ExampleInterface.aFn", + "type": "Function", + "label": "aFn", + "signature": [ + "() => void" + ], + "description": [ + "\nThese are not coming back properly." + ], + "children": [], + "tags": [], + "returnComment": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 72, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L72" + } + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 57, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L57" + }, + "initialIsOpen": false + }, + { + "id": "def-public.IReturnAReactComponent", + "type": "Interface", + "label": "IReturnAReactComponent", + "description": [ + "\nAn interface that has a react component." + ], + "children": [ + { + "id": "def-public.IReturnAReactComponent.component", + "type": "CompoundType", + "label": "component", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 79, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L79" + }, + "signature": [ + "React.ComponentType<{}>" + ] + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts", + "lineNumber": 78, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/classes.ts#L78" + }, + "initialIsOpen": false + }, + { + "id": "def-public.ImAnObject", + "type": "Interface", + "label": "ImAnObject", + "description": [], + "children": [ + { + "id": "def-public.ImAnObject.foo", + "type": "Function", + "label": "foo", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 44, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L44" + }, + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.FnWithGeneric", + "text": "FnWithGeneric" + } + ] + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 43, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L43" + }, + "initialIsOpen": false + } + ], + "enums": [ + { + "id": "def-public.DayOfWeek", + "type": "Enum", + "label": "DayOfWeek", + "description": [ + "\nComments on enums." + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 31, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L31" + }, + "initialIsOpen": false + } + ], + "misc": [ + { + "id": "def-public.imAnAny", + "type": "Any", + "label": "imAnAny", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts", + "lineNumber": 19, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts#L19" + }, + "signature": [ + "any" + ], + "initialIsOpen": false + }, + { + "id": "def-public.imAnUnknown", + "type": "Unknown", + "label": "imAnUnknown", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts", + "lineNumber": 20, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/index.ts#L20" + }, + "signature": [ + "unknown" + ], + "initialIsOpen": false + }, + { + "id": "def-public.NotAnArrowFnType", + "type": "Type", + "label": "NotAnArrowFnType", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts", + "lineNumber": 78, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/fns.ts#L78" + }, + "signature": [ + "(a: string, b: number | undefined, c: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + ", d: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + }, + ", e: string | undefined) => ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + "" + ], + "initialIsOpen": false + }, + { + "id": "def-public.aUnionProperty", + "type": "CompoundType", + "label": "aUnionProperty", + "description": [ + "\nThis is a complicated union type" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 51, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L51" + }, + "signature": [ + "string | number | (() => string) | ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.CrazyClass", + "text": "CrazyClass" + }, + "" + ], + "initialIsOpen": false + }, + { + "id": "def-public.aStrArray", + "type": "Array", + "label": "aStrArray", + "description": [ + "\nThis is an array of strings. The type is explicit." + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 56, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L56" + }, + "signature": [ + "string[]" + ], + "initialIsOpen": false + }, + { + "id": "def-public.aNumArray", + "type": "Array", + "label": "aNumArray", + "description": [ + "\nThis is an array of numbers. The type is implied." + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 61, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L61" + }, + "signature": [ + "number[]" + ], + "initialIsOpen": false + }, + { + "id": "def-public.aStr", + "type": "string", + "label": "aStr", + "description": [ + "\nA string that says hi to you!" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 66, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L66" + }, + "initialIsOpen": false + }, + { + "id": "def-public.aNum", + "type": "number", + "label": "aNum", + "description": [ + "\nIt's a number. A special number." + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 71, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L71" + }, + "signature": [ + "10" + ], + "initialIsOpen": false + }, + { + "id": "def-public.literalString", + "type": "string", + "label": "literalString", + "description": [ + "\nI'm a type of string, but more specifically, a literal string type." + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 76, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L76" + }, + "signature": [ + "\"HI\"" + ], + "initialIsOpen": false + }, + { + "id": "def-public.StringOrUndefinedType", + "type": "Type", + "label": "StringOrUndefinedType", + "description": [ + "\nHow should a potentially undefined type show up." + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 15, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L15" + }, + "signature": [ + "undefined | string" + ], + "initialIsOpen": false + }, + { + "id": "def-public.TypeWithGeneric", + "type": "Type", + "label": "TypeWithGeneric", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 17, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L17" + }, + "signature": [ + "T[]" + ], + "initialIsOpen": false + }, + { + "id": "def-public.ImAType", + "type": "Type", + "label": "ImAType", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 19, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L19" + }, + "signature": [ + "string | number | ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAFooPluginApi", + "section": "def-public.FooType", + "text": "FooType" + }, + " | ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + " | ", + { + "pluginId": "pluginA", + "scope": "common", + "docId": "kibPluginAPluginApi", + "section": "def-common.ImACommonType", + "text": "ImACommonType" + } + ], + "initialIsOpen": false + }, + { + "id": "def-public.FnWithGeneric", + "type": "Type", + "label": "FnWithGeneric", + "description": [ + "\nThis is a type that defines a function.\n" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 26, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L26" + }, + "signature": [ + "(t: T) => ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.TypeWithGeneric", + "text": "TypeWithGeneric" + }, + "" + ], + "initialIsOpen": false + }, + { + "id": "def-public.MultipleDeclarationsType", + "type": "Type", + "label": "MultipleDeclarationsType", + "description": [ + "\nCalling node.getSymbol().getDeclarations() will return > 1 declaration." + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 40, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L40" + }, + "signature": [ + "(typeof DayOfWeek)[]" + ], + "initialIsOpen": false + }, + { + "id": "def-public.IRefANotExportedType", + "type": "Type", + "label": "IRefANotExportedType", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts", + "lineNumber": 42, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/types.ts#L42" + }, + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAFooPluginApi", + "section": "def-public.ImNotExportedFromIndex", + "text": "ImNotExportedFromIndex" + }, + " | { zed: \"hi\"; }" + ], + "initialIsOpen": false + } + ], + "objects": [ + { + "id": "def-public.aPretendNamespaceObj", + "type": "Object", + "children": [ + { + "id": "def-public.aPretendNamespaceObj.notAnArrowFn", + "type": "Function", + "label": "notAnArrowFn", + "description": [ + "/**\n * The docs should show this inline comment.\n */" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 21, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L21" + }, + "signature": [ + "typeof ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.notAnArrowFn", + "text": "notAnArrowFn" + } + ] + }, + { + "id": "def-public.aPretendNamespaceObj.aPropertyMisdirection", + "type": "Function", + "label": "aPropertyMisdirection", + "description": [ + "/**\n * Should this comment show up?\n */" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 26, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L26" + }, + "signature": [ + "typeof ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.notAnArrowFn", + "text": "notAnArrowFn" + } + ] + }, + { + "id": "def-public.aPretendNamespaceObj.aPropertyInlineFn", + "type": "Function", + "children": [ + { + "type": "CompoundType", + "label": "a", + "isRequired": true, + "signature": [ + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + } + ], + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 31, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L31" + } + } + ], + "signature": [ + "(a: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + }, + ") => ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.ImAType", + "text": "ImAType" + } + ], + "description": [ + "/**\n * I'm a property inline fun.\n */" + ], + "label": "aPropertyInlineFn", + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 31, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L31" + }, + "returnComment": [] + }, + { + "id": "def-public.aPretendNamespaceObj.aPropertyStr", + "type": "string", + "label": "aPropertyStr", + "description": [ + "/**\n * The only way for this to have a comment is to grab this.\n */" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 38, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L38" + } + }, + { + "id": "def-public.aPretendNamespaceObj.nestedObj", + "type": "Object", + "children": [ + { + "id": "def-public.aPretendNamespaceObj.nestedObj.foo", + "type": "string", + "label": "foo", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 44, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L44" + } + } + ], + "description": [ + "/**\n * Will this nested object have it's children extracted appropriately?\n */" + ], + "label": "nestedObj", + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 43, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L43" + } + } + ], + "description": [ + "\nSome of the plugins wrap static exports in an object to create\na namespace like this." + ], + "label": "aPretendNamespaceObj", + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts", + "lineNumber": 17, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/const_vars.ts#L17" + }, + "initialIsOpen": false + } + ], + "setup": { + "id": "def-public.Setup", + "type": "Interface", + "label": "Setup", + "description": [ + "\nAccess setup functionality from your plugin's setup function by adding the example\nplugin as a dependency.\n\n```ts\nClass MyPlugin {\n setup(core: CoreDependencies, { example }: PluginDependencies) {\n // Here you can access this functionality.\n example.getSearchService();\n }\n}\n```" + ], + "children": [ + { + "id": "def-public.Setup.getSearchService", + "type": "Function", + "label": "getSearchService", + "description": [ + "\nA factory function that returns a new instance of Foo based\non the spec. We aren't sure if this is a good function so it's marked\nbeta. That should be clear in the docs because of the js doc tag.\n" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 96, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L96" + }, + "signature": [ + "(searchSpec: ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.SearchSpec", + "text": "SearchSpec" + }, + ") => string" + ] + }, + { + "id": "def-public.Setup.getSearchService2", + "type": "Function", + "label": "getSearchService2", + "description": [ + "\nThis uses an inlined object type rather than referencing an exported type, which is discouraged.\nprefer the way {@link getSearchService} is typed.\n" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 104, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L104" + }, + "signature": [ + "(searchSpec: { username: string; password: string; }) => string" + ] + }, + { + "id": "def-public.Setup.doTheThing", + "type": "Function", + "label": "doTheThing", + "description": [ + "\nThis function does the thing and it's so good at it! But we decided to deprecate it\nanyway. I hope that's clear to developers in the docs!\n" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 117, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L117" + }, + "signature": [ + "(thingOne: number, thingTwo: string, thingThree: { nestedVar: number; }) => void" + ] + }, + { + "id": "def-public.Setup.fnWithInlineParams", + "type": "Function", + "label": "fnWithInlineParams", + "description": [ + "\nWho would write such a complicated function?? Ew, how will the obj parameter appear in docs?\n" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 128, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L128" + }, + "signature": [ + "(obj: { fn: (foo: { param: string; }) => number; }) => () => { retFoo: () => string; }" + ] + }, + { + "id": "def-public.Setup.id", + "type": "string", + "label": "id", + "description": [ + "\nHi, I'm a comment for an id string!" + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 135, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L135" + } + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 84, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L84" + }, + "lifecycle": "setup", + "initialIsOpen": true + }, + "start": { + "id": "def-public.Start", + "type": "Interface", + "label": "Start", + "description": [ + "\nAccess start functionality from your plugin's start function by adding the example\nplugin as a dependency.\n\n```ts\nClass MyPlugin {\n start(core: CoreDependencies, { example }: PluginDependencies) {\n // Here you can access this functionality.\n example.getSearchLanguage();\n }\n}\n```" + ], + "children": [ + { + "id": "def-public.Start.getSearchLanguage", + "type": "Function", + "label": "getSearchLanguage", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 68, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L68" + }, + "signature": [ + "() => ", + { + "pluginId": "pluginA", + "scope": "public", + "docId": "kibPluginAPluginApi", + "section": "def-public.SearchLanguage", + "text": "SearchLanguage" + } + ] + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts", + "lineNumber": 64, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/plugin.ts#L64" + }, + "lifecycle": "start", + "initialIsOpen": true + } + }, + "server": { + "classes": [], + "functions": [], + "interfaces": [], + "enums": [], + "misc": [], + "objects": [] + }, + "common": { + "classes": [], + "functions": [], + "interfaces": [ + { + "id": "def-common.ImACommonType", + "type": "Interface", + "label": "ImACommonType", + "description": [], + "children": [ + { + "id": "def-common.ImACommonType.goo", + "type": "number", + "label": "goo", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts", + "lineNumber": 12, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts#L12" + } + } + ], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts", + "lineNumber": 11, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/index.ts#L11" + }, + "initialIsOpen": false + } + ], + "enums": [], + "misc": [], + "objects": [] + } +} \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json index 8b5ec5f3da960b..2589948b54ff0c 100644 --- a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.json @@ -1 +1,77 @@ -{"id":"pluginA.foo","client":{"classes":[],"functions":[{"id":"def-public.doTheFooFnThing","type":"Function","children":[],"signature":["() => void"],"description":[],"label":"doTheFooFnThing","source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts","lineNumber":9,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts#L9"},"returnComment":[],"initialIsOpen":false}],"interfaces":[],"enums":[],"misc":[{"id":"def-public.FooType","type":"Type","label":"FooType","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts","lineNumber":11,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts#L11"},"signature":["() => \"foo\""],"initialIsOpen":false}],"objects":[]},"server":{"classes":[],"functions":[],"interfaces":[],"enums":[],"misc":[],"objects":[]},"common":{"classes":[],"functions":[],"interfaces":[],"enums":[],"misc":[{"id":"def-common.commonFoo","type":"string","label":"commonFoo","description":[],"source":{"path":"/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts","lineNumber":9,"link":"https://github.com/elastic/kibana/tree/master/packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts#L9"},"signature":["\"COMMON VAR!\""],"initialIsOpen":false}],"objects":[]}} \ No newline at end of file +{ + "id": "pluginA.foo", + "client": { + "classes": [], + "functions": [ + { + "id": "def-public.doTheFooFnThing", + "type": "Function", + "children": [], + "signature": [ + "() => void" + ], + "description": [], + "label": "doTheFooFnThing", + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts", + "lineNumber": 9, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts#L9" + }, + "returnComment": [], + "initialIsOpen": false + } + ], + "interfaces": [], + "enums": [], + "misc": [ + { + "id": "def-public.FooType", + "type": "Type", + "label": "FooType", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts", + "lineNumber": 11, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/public/foo/index.ts#L11" + }, + "signature": [ + "() => \"foo\"" + ], + "initialIsOpen": false + } + ], + "objects": [] + }, + "server": { + "classes": [], + "functions": [], + "interfaces": [], + "enums": [], + "misc": [], + "objects": [] + }, + "common": { + "classes": [], + "functions": [], + "interfaces": [], + "enums": [], + "misc": [ + { + "id": "def-common.commonFoo", + "type": "string", + "label": "commonFoo", + "description": [], + "source": { + "path": "packages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts", + "lineNumber": 9, + "link": "https://github.com/elastic/kibana/tree/masterpackages/kbn-docs-utils/src/api_docs/tests/__fixtures__/src/plugin_a/common/foo/index.ts#L9" + }, + "signature": [ + "\"COMMON VAR!\"" + ], + "initialIsOpen": false + } + ], + "objects": [] + } +} \ No newline at end of file diff --git a/packages/kbn-docs-utils/src/api_docs/types.ts b/packages/kbn-docs-utils/src/api_docs/types.ts index c41cd42e6b4244..5468709206eecf 100644 --- a/packages/kbn-docs-utils/src/api_docs/types.ts +++ b/packages/kbn-docs-utils/src/api_docs/types.ts @@ -97,7 +97,7 @@ export interface Reference { pluginId: string; scope: ApiScope; docId: string; - section: string; + section?: string; text: string; } diff --git a/packages/kbn-docs-utils/src/api_docs/utils.ts b/packages/kbn-docs-utils/src/api_docs/utils.ts index 34162aa330911f..66cdfee8f233bb 100644 --- a/packages/kbn-docs-utils/src/api_docs/utils.ts +++ b/packages/kbn-docs-utils/src/api_docs/utils.ts @@ -91,9 +91,6 @@ export function getPluginApiDocId( const cleanName = id.replace('.', '_'); if (serviceInfo) { const serviceName = getServiceForPath(serviceInfo.apiPath, serviceInfo.directory); - log.debug( - `Service for path ${serviceInfo.apiPath} and ${serviceInfo.directory} is ${serviceName}` - ); const serviceFolder = serviceInfo.serviceFolders?.find((f) => f === serviceName); if (serviceFolder) { diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index f9c1e67c0540d9..b685b32038f8e3 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -87,3 +87,19 @@ if [ "$GIT_CHANGES" ]; then echo -e "$GIT_CHANGES\n" exit 1 fi + +### +### rebuild plugin api docs to ensure it's not out of date +### +echo " -- building api docs" +node scripts/build_api_docs + +### +### verify no api changes +### +GIT_CHANGES="$(git ls-files --modified)" +if [ "$GIT_CHANGES" ]; then + echo -e "\n${RED}ERROR: 'node scripts/build_api_docs' caused changes to the following files:${C_RESET}\n" + echo -e "$GIT_CHANGES\n" + exit 1 +fi \ No newline at end of file From e409405ccbcf22ebc33523d83be25d5ea8c1f259 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 25 Feb 2021 17:09:52 -0700 Subject: [PATCH 31/40] [Maps] fix selecting EMS basemap does not populate input (#92711) * [Maps] fix selecting EMS basemap does not populate input * tslint --- .../ems_tms_source/create_source_editor.tsx | 39 +++++++++++++++++++ .../ems_base_map_layer_wizard.tsx | 13 ++----- ...vice_select.js => tile_service_select.tsx} | 33 ++++++++++++---- 3 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/sources/ems_tms_source/create_source_editor.tsx rename x-pack/plugins/maps/public/classes/sources/ems_tms_source/{tile_service_select.js => tile_service_select.tsx} (78%) diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/create_source_editor.tsx b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/create_source_editor.tsx new file mode 100644 index 00000000000000..16e4986f4d8a65 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/create_source_editor.tsx @@ -0,0 +1,39 @@ +/* + * 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, { Component } from 'react'; +import { EuiPanel } from '@elastic/eui'; +import { EmsTmsSourceConfig, TileServiceSelect } from './tile_service_select'; + +interface Props { + onTileSelect: (sourceConfig: EmsTmsSourceConfig) => void; +} + +interface State { + config?: EmsTmsSourceConfig; +} + +export class CreateSourceEditor extends Component { + state: State = {}; + + componentDidMount() { + this._onTileSelect({ id: null, isAutoSelect: true }); + } + + _onTileSelect = (config: EmsTmsSourceConfig) => { + this.setState({ config }); + this.props.onTileSelect(config); + }; + + render() { + return ( + + + + ); + } +} diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx index 3cb707f377b708..859d8b95cef3f2 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx @@ -7,14 +7,13 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiPanel } from '@elastic/eui'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; // @ts-ignore import { EMSTMSSource, getSourceTitle } from './ems_tms_source'; // @ts-ignore import { VectorTileLayer } from '../../layers/vector_tile_layer/vector_tile_layer'; -// @ts-ignore -import { TileServiceSelect } from './tile_service_select'; +import { EmsTmsSourceConfig } from './tile_service_select'; +import { CreateSourceEditor } from './create_source_editor'; import { getEMSSettings } from '../../../kibana_services'; import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; import { WorldMapLayerIcon } from '../../layers/icons/world_map_layer_icon'; @@ -45,18 +44,14 @@ export const emsBaseMapLayerWizardConfig: LayerWizard = { }, icon: WorldMapLayerIcon, renderWizard: ({ previewLayers }: RenderWizardArguments) => { - const onSourceConfigChange = (sourceConfig: unknown) => { + const onSourceConfigChange = (sourceConfig: EmsTmsSourceConfig) => { const layerDescriptor = VectorTileLayer.createDescriptor({ sourceDescriptor: EMSTMSSource.createDescriptor(sourceConfig), }); previewLayers([layerDescriptor]); }; - return ( - - - - ); + return ; }, title: getSourceTitle(), }; diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.js b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.tsx similarity index 78% rename from x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.js rename to x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.tsx index 42ff1789df8f44..5f0f406d53e86c 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.js +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.tsx @@ -5,17 +5,34 @@ * 2.0. */ -import React from 'react'; -import { EuiSelect, EuiFormRow } from '@elastic/eui'; +import React, { ChangeEvent, Component } from 'react'; +import { EuiSelect, EuiSelectOption, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { getEmsTmsServices } from '../../../meta'; import { getEmsUnavailableMessage } from '../../../components/ems_unavailable_message'; -import { i18n } from '@kbn/i18n'; export const AUTO_SELECT = 'auto_select'; -export class TileServiceSelect extends React.Component { - state = { +export interface EmsTmsSourceConfig { + id: string | null; + isAutoSelect: boolean; +} + +interface Props { + config?: EmsTmsSourceConfig; + onTileSelect: (sourceConfig: EmsTmsSourceConfig) => void; +} + +interface State { + emsTmsOptions: EuiSelectOption[]; + hasLoaded: boolean; +} + +export class TileServiceSelect extends Component { + private _isMounted = false; + + state: State = { emsTmsOptions: [], hasLoaded: false, }; @@ -51,7 +68,7 @@ export class TileServiceSelect extends React.Component { this.setState({ emsTmsOptions, hasLoaded: true }); }; - _onChange = (e) => { + _onChange = (e: ChangeEvent) => { const value = e.target.value; const isAutoSelect = value === AUTO_SELECT; this.props.onTileSelect({ @@ -63,9 +80,9 @@ export class TileServiceSelect extends React.Component { render() { const helpText = this.state.emsTmsOptions.length === 0 ? getEmsUnavailableMessage() : null; - let selectedId; + let selectedId: string | undefined; if (this.props.config) { - selectedId = this.props.config.isAutoSelect ? AUTO_SELECT : this.props.config.id; + selectedId = this.props.config.isAutoSelect ? AUTO_SELECT : this.props.config.id!; } return ( From d0e9b5133c26a16aa90a3b50c6bce19e772e64c2 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 26 Feb 2021 00:48:47 +0000 Subject: [PATCH 32/40] chore(NA): bump bazelisk to v1.7.5 (#92905) --- .bazeliskversion | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bazeliskversion b/.bazeliskversion index 661e7aeadf36f8..6a126f402d53da 100644 --- a/.bazeliskversion +++ b/.bazeliskversion @@ -1 +1 @@ -1.7.3 +1.7.5 From 7aae6c5f50ea74a085e4321dfdea389c6d79d815 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Thu, 25 Feb 2021 21:56:06 -0500 Subject: [PATCH 33/40] [APM] Fix for default fields in correlations view (#91868) (#92090) * [APM] Fix for default fields in correlations view (#91868) * removes useCallback hook Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apm/public/hooks/useLocalStorage.ts | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/apm/public/hooks/useLocalStorage.ts b/x-pack/plugins/apm/public/hooks/useLocalStorage.ts index dc07b89ec78076..502824135db2a4 100644 --- a/x-pack/plugins/apm/public/hooks/useLocalStorage.ts +++ b/x-pack/plugins/apm/public/hooks/useLocalStorage.ts @@ -8,28 +8,10 @@ import { useState, useEffect } from 'react'; export function useLocalStorage(key: string, defaultValue: T) { - const [item, setItem] = useState(getFromStorage()); - - function getFromStorage() { - const storedItem = window.localStorage.getItem(key); - - let toStore: T = defaultValue; - - if (storedItem !== null) { - try { - toStore = JSON.parse(storedItem) as T; - } catch (err) { - window.localStorage.removeItem(key); - // eslint-disable-next-line no-console - console.log(`Unable to decode: ${key}`); - } - } - - return toStore; - } + const [item, setItem] = useState(getFromStorage(key, defaultValue)); const updateFromStorage = () => { - const storedItem = getFromStorage(); + const storedItem = getFromStorage(key, defaultValue); setItem(storedItem); }; @@ -51,5 +33,25 @@ export function useLocalStorage(key: string, defaultValue: T) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // item state must be updated with a new key or default value + useEffect(() => { + setItem(getFromStorage(key, defaultValue)); + }, [key, defaultValue]); + return [item, saveToStorage] as const; } + +function getFromStorage(keyName: string, defaultValue: T) { + const storedItem = window.localStorage.getItem(keyName); + + if (storedItem !== null) { + try { + return JSON.parse(storedItem) as T; + } catch (err) { + window.localStorage.removeItem(keyName); + // eslint-disable-next-line no-console + console.log(`Unable to decode: ${keyName}`); + } + } + return defaultValue; +} From 3d9139089f5652af6894f7cbf409068c2bc4a8bb Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 26 Feb 2021 02:58:55 +0000 Subject: [PATCH 34/40] skip flaky suite (#92114) --- x-pack/test/accessibility/apps/dashboard_edit_panel.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/dashboard_edit_panel.ts b/x-pack/test/accessibility/apps/dashboard_edit_panel.ts index c318c2d1c26a0e..466eab6b6b3361 100644 --- a/x-pack/test/accessibility/apps/dashboard_edit_panel.ts +++ b/x-pack/test/accessibility/apps/dashboard_edit_panel.ts @@ -20,7 +20,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PANEL_TITLE = 'Visualization PieChart'; - describe('Dashboard Edit Panel', () => { + // FLAKY: https://github.com/elastic/kibana/issues/92114 + describe.skip('Dashboard Edit Panel', () => { before(async () => { await esArchiver.load('dashboard/drilldowns'); await esArchiver.loadIfNeeded('logstash_functional'); From 4192ea71c33bdcb25e18e39f942b1e34edf582fc Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Fri, 26 Feb 2021 10:04:44 +0300 Subject: [PATCH 35/40] [Vega] Allow image loading without CORS policy by changing the default to crossOrigin=null (#91991) * changing the default to crossOrigin=null in Vega * Fix eslint Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../vis_type_vega/public/vega_view/vega_base_view.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index 14e4d6034c1c22..353273d1372e63 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -172,7 +172,7 @@ export class VegaBaseView { // Override URL sanitizer to prevent external data loading (if disabled) const vegaLoader = loader(); const originalSanitize = vegaLoader.sanitize.bind(vegaLoader); - vegaLoader.sanitize = (uri, options) => { + vegaLoader.sanitize = async (uri, options) => { if (uri.bypassToken === bypassToken) { // If uri has a bypass token, the uri was encoded by bypassExternalUrlCheck() above. // because user can only supply pure JSON data structure. @@ -189,7 +189,11 @@ export class VegaBaseView { }) ); } - return originalSanitize(uri, options); + const result = await originalSanitize(uri, options); + // This will allow Vega users to load images from any domain. + result.crossOrigin = null; + + return result; }; config.loader = vegaLoader; From a1d2d870d3afab10abc9404823495fea0263fb68 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Fri, 26 Feb 2021 10:37:24 +0100 Subject: [PATCH 36/40] [APM] Always allow access to Profiling via URL (#92889) --- .../service_details/service_detail_tabs.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx index 5c9d79f37cc573..b86f0d40de1372 100644 --- a/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx +++ b/x-pack/plugins/apm/public/components/app/service_details/service_detail_tabs.tsx @@ -37,6 +37,7 @@ interface Tab { key: string; href: string; text: ReactNode; + hidden?: boolean; render: () => ReactNode; } @@ -126,6 +127,7 @@ export function ServiceDetailTabs({ serviceName, tab }: Props) { const profilingTab = { key: 'profiling', href: useServiceProfilingHref({ serviceName }), + hidden: !config.profilingEnabled, text: ( @@ -167,22 +169,20 @@ export function ServiceDetailTabs({ serviceName, tab }: Props) { tabs.push(metricsTab); } - tabs.push(serviceMapTab); - - if (config.profilingEnabled) { - tabs.push(profilingTab); - } + tabs.push(serviceMapTab, profilingTab); const selectedTab = tabs.find((serviceTab) => serviceTab.key === tab); return ( <> - {tabs.map(({ href, key, text }) => ( - - {text} - - ))} + {tabs + .filter((t) => !t.hidden) + .map(({ href, key, text }) => ( + + {text} + + ))}

From c2877a6d96791a9dd5498de80a75945b9e1c70fc Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 26 Feb 2021 15:35:43 +0200 Subject: [PATCH 37/40] [Security Solution][Case] Fix subcases bugs on detections and case view (#91836) Co-authored-by: Jonathan Buttner --- .../case/common/api/cases/commentable_case.ts | 35 -------- x-pack/plugins/case/common/api/cases/index.ts | 1 - x-pack/plugins/case/common/api/helpers.ts | 8 +- .../plugins/case/common/api/runtime_types.ts | 27 +++++- .../plugins/case/server/client/cases/types.ts | 2 +- .../case/server/client/cases/utils.test.ts | 2 + .../plugins/case/server/client/cases/utils.ts | 1 + .../case/server/client/comments/add.ts | 6 +- x-pack/plugins/case/server/client/types.ts | 3 +- .../server/common/models/commentable_case.ts | 63 +++++++++----- .../case/server/connectors/case/index.test.ts | 4 +- .../case/server/connectors/case/types.ts | 4 +- .../api/cases/comments/delete_all_comments.ts | 8 +- .../api/cases/comments/delete_comment.ts | 8 +- .../api/cases/comments/find_comments.ts | 6 +- .../api/cases/comments/get_all_comment.ts | 6 +- .../api/cases/comments/patch_comment.ts | 16 ++-- .../routes/api/cases/comments/post_comment.ts | 4 +- .../api/cases/sub_case/patch_sub_cases.ts | 4 +- .../case/server/scripts/sub_cases/index.ts | 11 +-- .../components/all_cases/expanded_row.tsx | 14 ++- .../cases/components/all_cases/index.tsx | 49 ++++++++--- .../all_cases/status_filter.test.tsx | 20 +++++ .../components/all_cases/status_filter.tsx | 9 +- .../components/all_cases/table_filters.tsx | 3 + .../components/case_action_bar/index.tsx | 24 +++--- .../cases/components/case_view/index.test.tsx | 80 ++++++++++++++++- .../cases/components/case_view/index.tsx | 69 ++++++++------- .../connectors/case/alert_fields.tsx | 4 +- .../connectors/case/existing_case.tsx | 8 +- .../cases/components/create/flyout.test.tsx | 14 +-- .../public/cases/components/create/flyout.tsx | 8 +- .../components/create/form_context.test.tsx | 86 +++++++++++++++++++ .../cases/components/create/form_context.tsx | 12 ++- .../public/cases/components/create/index.tsx | 2 +- .../cases/components/status/button.test.tsx | 2 +- .../public/cases/components/status/config.ts | 2 +- .../add_to_case_action.test.tsx | 69 ++++++++++++--- .../timeline_actions/add_to_case_action.tsx | 34 +++++--- .../use_all_cases_modal/all_cases_modal.tsx | 26 ++++-- .../use_all_cases_modal/index.test.tsx | 2 +- .../components/use_all_cases_modal/index.tsx | 12 ++- .../create_case_modal.test.tsx | 6 +- .../create_case_modal.tsx | 2 +- .../use_create_case_modal/index.test.tsx | 6 +- .../use_create_case_modal/index.tsx | 2 +- .../user_action_tree/index.test.tsx | 6 +- .../components/user_action_tree/index.tsx | 15 ++-- .../cases/containers/use_get_case.test.tsx | 8 +- .../public/cases/containers/use_get_case.tsx | 34 +------- .../cases/containers/use_post_comment.tsx | 2 +- .../public/cases/translations.ts | 7 ++ .../alerts/use_query.test.tsx | 2 +- .../detection_engine/alerts/use_query.tsx | 2 +- .../flyout/add_to_case_button/index.tsx | 4 +- .../tests/cases/comments/delete_comment.ts | 14 +-- .../tests/cases/comments/find_comments.ts | 4 +- .../tests/cases/comments/get_all_comments.ts | 4 +- .../basic/tests/cases/comments/get_comment.ts | 2 +- .../tests/cases/comments/patch_comment.ts | 39 ++++----- .../tests/cases/comments/post_comment.ts | 4 +- .../basic/tests/cases/delete_cases.ts | 16 ++-- .../basic/tests/cases/find_cases.ts | 6 +- .../tests/cases/sub_cases/delete_sub_cases.ts | 20 ++--- .../tests/cases/sub_cases/find_sub_cases.ts | 12 +-- .../tests/cases/sub_cases/get_sub_case.ts | 16 ++-- .../tests/cases/sub_cases/patch_sub_cases.ts | 26 +++--- .../case_api_integration/common/lib/mock.ts | 18 ++-- .../case_api_integration/common/lib/utils.ts | 4 +- 69 files changed, 683 insertions(+), 366 deletions(-) delete mode 100644 x-pack/plugins/case/common/api/cases/commentable_case.ts diff --git a/x-pack/plugins/case/common/api/cases/commentable_case.ts b/x-pack/plugins/case/common/api/cases/commentable_case.ts deleted file mode 100644 index 023229a90d352d..00000000000000 --- a/x-pack/plugins/case/common/api/cases/commentable_case.ts +++ /dev/null @@ -1,35 +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 * as rt from 'io-ts'; -import { CaseAttributesRt } from './case'; -import { CommentResponseRt } from './comment'; -import { SubCaseAttributesRt, SubCaseResponseRt } from './sub_case'; - -export const CollectionSubCaseAttributesRt = rt.intersection([ - rt.partial({ subCase: SubCaseAttributesRt }), - rt.type({ - case: CaseAttributesRt, - }), -]); - -export const CollectWithSubCaseResponseRt = rt.intersection([ - CaseAttributesRt, - rt.type({ - id: rt.string, - totalComment: rt.number, - version: rt.string, - }), - rt.partial({ - subCase: SubCaseResponseRt, - totalAlerts: rt.number, - comments: rt.array(CommentResponseRt), - }), -]); - -export type CollectionWithSubCaseResponse = rt.TypeOf; -export type CollectionWithSubCaseAttributes = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts index 4d1fc68109ddb7..6e7fb818cb2b58 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -11,4 +11,3 @@ export * from './comment'; export * from './status'; export * from './user_actions'; export * from './sub_case'; -export * from './commentable_case'; diff --git a/x-pack/plugins/case/common/api/helpers.ts b/x-pack/plugins/case/common/api/helpers.ts index 00c8ff402c802e..43e292b91db4b7 100644 --- a/x-pack/plugins/case/common/api/helpers.ts +++ b/x-pack/plugins/case/common/api/helpers.ts @@ -24,8 +24,8 @@ export const getSubCasesUrl = (caseID: string): string => { return SUB_CASES_URL.replace('{case_id}', caseID); }; -export const getSubCaseDetailsUrl = (caseID: string, subCaseID: string): string => { - return SUB_CASE_DETAILS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID); +export const getSubCaseDetailsUrl = (caseID: string, subCaseId: string): string => { + return SUB_CASE_DETAILS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseId); }; export const getCaseCommentsUrl = (id: string): string => { @@ -40,8 +40,8 @@ export const getCaseUserActionUrl = (id: string): string => { return CASE_USER_ACTIONS_URL.replace('{case_id}', id); }; -export const getSubCaseUserActionUrl = (caseID: string, subCaseID: string): string => { - return SUB_CASE_USER_ACTIONS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID); +export const getSubCaseUserActionUrl = (caseID: string, subCaseId: string): string => { + return SUB_CASE_USER_ACTIONS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseId); }; export const getCasePushUrl = (caseId: string, connectorId: string): string => { diff --git a/x-pack/plugins/case/common/api/runtime_types.ts b/x-pack/plugins/case/common/api/runtime_types.ts index 43e3be04d10e5e..b2ff763838287e 100644 --- a/x-pack/plugins/case/common/api/runtime_types.ts +++ b/x-pack/plugins/case/common/api/runtime_types.ts @@ -9,14 +9,37 @@ import { either, fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import * as rt from 'io-ts'; -import { failure } from 'io-ts/lib/PathReporter'; +import { isObject } from 'lodash/fp'; type ErrorFactory = (message: string) => Error; +export const formatErrors = (errors: rt.Errors): string[] => { + const err = errors.map((error) => { + if (error.message != null) { + return error.message; + } else { + const keyContext = error.context + .filter( + (entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== '' + ) + .map((entry) => entry.key) + .join(','); + + const nameContext = error.context.find((entry) => entry.type?.name?.length > 0); + const suppliedValue = + keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : ''; + const value = isObject(error.value) ? JSON.stringify(error.value) : error.value; + return `Invalid value "${value}" supplied to "${suppliedValue}"`; + } + }); + + return [...new Set(err)]; +}; + export const createPlainError = (message: string) => new Error(message); export const throwErrors = (createError: ErrorFactory) => (errors: rt.Errors) => { - throw createError(failure(errors).join('\n')); + throw createError(formatErrors(errors).join()); }; export const decodeOrThrow = ( diff --git a/x-pack/plugins/case/server/client/cases/types.ts b/x-pack/plugins/case/server/client/cases/types.ts index 2dd2caf9fe73a0..f1d56e7132bd11 100644 --- a/x-pack/plugins/case/server/client/cases/types.ts +++ b/x-pack/plugins/case/server/client/cases/types.ts @@ -72,7 +72,7 @@ export interface TransformFieldsArgs { export interface ExternalServiceComment { comment: string; - commentId?: string; + commentId: string; } export interface MapIncident { diff --git a/x-pack/plugins/case/server/client/cases/utils.test.ts b/x-pack/plugins/case/server/client/cases/utils.test.ts index 44e7a682aa7edf..859114a5e8fb07 100644 --- a/x-pack/plugins/case/server/client/cases/utils.test.ts +++ b/x-pack/plugins/case/server/client/cases/utils.test.ts @@ -540,6 +540,7 @@ describe('utils', () => { }, { comment: 'Elastic Security Alerts attached to the case: 3', + commentId: 'mock-id-1-total-alerts', }, ]); }); @@ -569,6 +570,7 @@ describe('utils', () => { }, { comment: 'Elastic Security Alerts attached to the case: 4', + commentId: 'mock-id-1-total-alerts', }, ]); }); diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index a5013d9b93982f..67d5ef55f83c3d 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -185,6 +185,7 @@ export const createIncident = async ({ if (totalAlerts > 0) { comments.push({ comment: `Elastic Security Alerts attached to the case: ${totalAlerts}`, + commentId: `${theCase.id}-total-alerts`, }); } diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 0a86c1825fedce..4c1cc59a957509 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -25,7 +25,7 @@ import { CaseType, SubCaseAttributes, CommentRequest, - CollectionWithSubCaseResponse, + CaseResponse, User, CommentRequestAlertType, AlertCommentRequestRt, @@ -113,7 +113,7 @@ const addGeneratedAlerts = async ({ caseClient, caseId, comment, -}: AddCommentFromRuleArgs): Promise => { +}: AddCommentFromRuleArgs): Promise => { const query = pipe( AlertCommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) @@ -260,7 +260,7 @@ export const addComment = async ({ caseId, comment, user, -}: AddCommentArgs): Promise => { +}: AddCommentArgs): Promise => { const query = pipe( CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index ba5677426c2228..d6a8f6b5d706cd 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -13,7 +13,6 @@ import { CasesPatchRequest, CasesResponse, CaseStatuses, - CollectionWithSubCaseResponse, CommentRequest, ConnectorMappingsAttributes, GetFieldsResponse, @@ -89,7 +88,7 @@ export interface ConfigureFields { * This represents the interface that other plugins can access. */ export interface CaseClient { - addComment(args: CaseClientAddComment): Promise; + addComment(args: CaseClientAddComment): Promise; create(theCase: CasePostRequest): Promise; get(args: CaseClientGet): Promise; getAlerts(args: CaseClientGetAlerts): Promise; diff --git a/x-pack/plugins/case/server/common/models/commentable_case.ts b/x-pack/plugins/case/server/common/models/commentable_case.ts index 9827118ee8e298..3ae225999db4ee 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -17,8 +17,8 @@ import { CaseSettings, CaseStatuses, CaseType, - CollectionWithSubCaseResponse, - CollectWithSubCaseResponseRt, + CaseResponse, + CaseResponseRt, CommentAttributes, CommentPatchRequest, CommentRequest, @@ -254,7 +254,7 @@ export class CommentableCase { }; } - public async encode(): Promise { + public async encode(): Promise { const collectionCommentStats = await this.service.getAllCaseComments({ client: this.soClient, id: this.collection.id, @@ -265,22 +265,6 @@ export class CommentableCase { }, }); - if (this.subCase) { - const subCaseComments = await this.service.getAllSubCaseComments({ - client: this.soClient, - id: this.subCase.id, - }); - - return CollectWithSubCaseResponseRt.encode({ - subCase: flattenSubCaseSavedObject({ - savedObject: this.subCase, - comments: subCaseComments.saved_objects, - totalAlerts: countAlertsForID({ comments: subCaseComments, id: this.subCase.id }), - }), - ...this.formatCollectionForEncoding(collectionCommentStats.total), - }); - } - const collectionComments = await this.service.getAllCaseComments({ client: this.soClient, id: this.collection.id, @@ -291,10 +275,45 @@ export class CommentableCase { }, }); - return CollectWithSubCaseResponseRt.encode({ + const collectionTotalAlerts = + countAlertsForID({ comments: collectionComments, id: this.collection.id }) ?? 0; + + const caseResponse = { comments: flattenCommentSavedObjects(collectionComments.saved_objects), - totalAlerts: countAlertsForID({ comments: collectionComments, id: this.collection.id }), + totalAlerts: collectionTotalAlerts, ...this.formatCollectionForEncoding(collectionCommentStats.total), - }); + }; + + if (this.subCase) { + const subCaseComments = await this.service.getAllSubCaseComments({ + client: this.soClient, + id: this.subCase.id, + }); + const totalAlerts = countAlertsForID({ comments: subCaseComments, id: this.subCase.id }) ?? 0; + + return CaseResponseRt.encode({ + ...caseResponse, + /** + * For now we need the sub case comments and totals to be exposed on the top level of the response so that the UI + * functionality can stay the same. Ideally in the future we can refactor this so that the UI will look for the + * comments either in the top level for a case or a collection or in the subCases field if it is a sub case. + * + * If we ever need to return both the collection's comments and the sub case comments we'll need to refactor it then + * as well. + */ + comments: flattenCommentSavedObjects(subCaseComments.saved_objects), + totalComment: subCaseComments.saved_objects.length, + totalAlerts, + subCases: [ + flattenSubCaseSavedObject({ + savedObject: this.subCase, + totalComment: subCaseComments.saved_objects.length, + totalAlerts, + }), + ], + }); + } + + return CaseResponseRt.encode(caseResponse); } } diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index 4be519858db18c..e4c29bb099f0e4 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -18,7 +18,6 @@ import { AssociationType, CaseResponse, CasesResponse, - CollectionWithSubCaseResponse, } from '../../../common/api'; import { connectorMappingsServiceMock, @@ -1018,9 +1017,10 @@ describe('case connector', () => { describe('addComment', () => { it('executes correctly', async () => { - const commentReturn: CollectionWithSubCaseResponse = { + const commentReturn: CaseResponse = { id: 'mock-it', totalComment: 0, + totalAlerts: 0, version: 'WzksMV0=', closed_at: null, diff --git a/x-pack/plugins/case/server/connectors/case/types.ts b/x-pack/plugins/case/server/connectors/case/types.ts index 50ff104d7bad02..6a7dfd9c2e6876 100644 --- a/x-pack/plugins/case/server/connectors/case/types.ts +++ b/x-pack/plugins/case/server/connectors/case/types.ts @@ -16,7 +16,7 @@ import { ConnectorSchema, CommentSchema, } from './schema'; -import { CaseResponse, CasesResponse, CollectionWithSubCaseResponse } from '../../../common/api'; +import { CaseResponse, CasesResponse } from '../../../common/api'; export type CaseConfiguration = TypeOf; export type Connector = TypeOf; @@ -29,7 +29,7 @@ export type ExecutorSubActionAddCommentParams = TypeOf< >; export type CaseExecutorParams = TypeOf; -export type CaseExecutorResponse = CaseResponse | CasesResponse | CollectionWithSubCaseResponse; +export type CaseExecutorResponse = CaseResponse | CasesResponse; export type CaseActionType = ActionType< CaseConfiguration, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index bcbf1828e1fde4..e0b3a4420f4b5d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -23,7 +23,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic }), query: schema.maybe( schema.object({ - subCaseID: schema.maybe(schema.string()), + subCaseId: schema.maybe(schema.string()), }) ), }, @@ -35,11 +35,11 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); - const id = request.query?.subCaseID ?? request.params.case_id; + const id = request.query?.subCaseId ?? request.params.case_id; const comments = await caseService.getCommentsByAssociation({ client, id, - associationType: request.query?.subCaseID + associationType: request.query?.subCaseId ? AssociationType.subCase : AssociationType.case, }); @@ -61,7 +61,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: request.params.case_id, - subCaseId: request.query?.subCaseID, + subCaseId: request.query?.subCaseId, commentId: comment.id, fields: ['comment'], }) diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index 73307753a550de..cae0809ea5f0bf 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -25,7 +25,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: }), query: schema.maybe( schema.object({ - subCaseID: schema.maybe(schema.string()), + subCaseId: schema.maybe(schema.string()), }) ), }, @@ -46,8 +46,8 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); } - const type = request.query?.subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; - const id = request.query?.subCaseID ?? request.params.case_id; + const type = request.query?.subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + const id = request.query?.subCaseId ?? request.params.case_id; const caseRef = myComment.references.find((c) => c.type === type); if (caseRef == null || (caseRef != null && caseRef.id !== id)) { @@ -69,7 +69,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: id, - subCaseId: request.query?.subCaseID, + subCaseId: request.query?.subCaseId, commentId: request.params.comment_id, fields: ['comment'], }), diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts index 3431c340c791e3..0ec0f1871c7adf 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -27,7 +27,7 @@ import { defaultPage, defaultPerPage } from '../..'; const FindQueryParamsRt = rt.partial({ ...SavedObjectFindOptionsRt.props, - subCaseID: rt.string, + subCaseId: rt.string, }); export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { @@ -49,8 +49,8 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { fold(throwErrors(Boom.badRequest), identity) ); - const id = query.subCaseID ?? request.params.case_id; - const associationType = query.subCaseID ? AssociationType.subCase : AssociationType.case; + const id = query.subCaseId ?? request.params.case_id; + const associationType = query.subCaseId ? AssociationType.subCase : AssociationType.case; const args = query ? { caseService, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index 730b1b92a8a076..8bf49ec3e27a12 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -25,7 +25,7 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { query: schema.maybe( schema.object({ includeSubCaseComments: schema.maybe(schema.boolean()), - subCaseID: schema.maybe(schema.string()), + subCaseId: schema.maybe(schema.string()), }) ), }, @@ -35,10 +35,10 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { const client = context.core.savedObjects.client; let comments: SavedObjectsFindResponse; - if (request.query?.subCaseID) { + if (request.query?.subCaseId) { comments = await caseService.getAllSubCaseComments({ client, - id: request.query.subCaseID, + id: request.query.subCaseId, options: { sortField: defaultSortField, }, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index e8b6f7bc957eb3..01b0e174640537 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -26,11 +26,11 @@ interface CombinedCaseParams { service: CaseServiceSetup; client: SavedObjectsClientContract; caseID: string; - subCaseID?: string; + subCaseId?: string; } -async function getCommentableCase({ service, client, caseID, subCaseID }: CombinedCaseParams) { - if (subCaseID) { +async function getCommentableCase({ service, client, caseID, subCaseId }: CombinedCaseParams) { + if (subCaseId) { const [caseInfo, subCase] = await Promise.all([ service.getCase({ client, @@ -38,7 +38,7 @@ async function getCommentableCase({ service, client, caseID, subCaseID }: Combin }), service.getSubCase({ client, - id: subCaseID, + id: subCaseId, }), ]); return new CommentableCase({ collection: caseInfo, service, subCase, soClient: client }); @@ -66,7 +66,7 @@ export function initPatchCommentApi({ }), query: schema.maybe( schema.object({ - subCaseID: schema.maybe(schema.string()), + subCaseId: schema.maybe(schema.string()), }) ), body: escapeHatch, @@ -87,7 +87,7 @@ export function initPatchCommentApi({ service: caseService, client, caseID: request.params.case_id, - subCaseID: request.query?.subCaseID, + subCaseId: request.query?.subCaseId, }); const myComment = await caseService.getComment({ @@ -103,7 +103,7 @@ export function initPatchCommentApi({ throw Boom.badRequest(`You cannot change the type of the comment.`); } - const saveObjType = request.query?.subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + const saveObjType = request.query?.subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; const caseRef = myComment.references.find((c) => c.type === saveObjType); if (caseRef == null || (caseRef != null && caseRef.id !== commentableCase.id)) { @@ -144,7 +144,7 @@ export function initPatchCommentApi({ actionAt: updatedDate, actionBy: { username, full_name, email }, caseId: request.params.case_id, - subCaseId: request.query?.subCaseID, + subCaseId: request.query?.subCaseId, commentId: updatedComment.id, fields: ['comment'], newValue: JSON.stringify(queryRestAttributes), diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 95b611950bd411..607f3f381f0675 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -21,7 +21,7 @@ export function initPostCommentApi({ router }: RouteDeps) { }), query: schema.maybe( schema.object({ - subCaseID: schema.maybe(schema.string()), + subCaseId: schema.maybe(schema.string()), }) ), body: escapeHatch, @@ -33,7 +33,7 @@ export function initPostCommentApi({ router }: RouteDeps) { } const caseClient = context.case.getCaseClient(); - const caseId = request.query?.subCaseID ?? request.params.case_id; + const caseId = request.query?.subCaseId ?? request.params.case_id; const comment = request.body as CommentRequest; try { diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts index ca5cd657a39f32..4b8e4920852c27 100644 --- a/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts @@ -153,8 +153,8 @@ async function getParentCases({ return parentCases.saved_objects.reduce((acc, so) => { const subCaseIDsWithParent = parentIDInfo.parentIDToSubID.get(so.id); - subCaseIDsWithParent?.forEach((subCaseID) => { - acc.set(subCaseID, so); + subCaseIDsWithParent?.forEach((subCaseId) => { + acc.set(subCaseId, so); }); return acc; }, new Map>()); diff --git a/x-pack/plugins/case/server/scripts/sub_cases/index.ts b/x-pack/plugins/case/server/scripts/sub_cases/index.ts index f6ec1f44076e4c..ba3bcaa65091c3 100644 --- a/x-pack/plugins/case/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -8,12 +8,7 @@ import yargs from 'yargs'; import { ToolingLog } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; -import { - CaseResponse, - CaseType, - CollectionWithSubCaseResponse, - ConnectorTypes, -} from '../../../common/api'; +import { CaseResponse, CaseType, ConnectorTypes } from '../../../common/api'; import { CommentType } from '../../../common/api/cases/comment'; import { CASES_URL } from '../../../common/constants'; import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common'; @@ -119,9 +114,7 @@ async function handleGenGroupAlerts(argv: any) { ), }; - const executeResp = await client.request< - ActionTypeExecutorResult - >({ + const executeResp = await client.request>({ path: `/api/actions/action/${createdAction.data.id}/_execute`, method: 'POST', body: { diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx index bb4bd0f98949da..1e1e925a20adac 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/expanded_row.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { EuiBasicTable as _EuiBasicTable } from '@elastic/eui'; import styled from 'styled-components'; -import { Case } from '../../containers/types'; +import { Case, SubCase } from '../../containers/types'; import { CasesColumns } from './columns'; import { AssociationType } from '../../../../../case/common/api'; @@ -34,14 +34,25 @@ BasicTable.displayName = 'BasicTable'; export const getExpandedRowMap = ({ data, columns, + isModal, + onSubCaseClick, }: { data: Case[] | null; columns: CasesColumns[]; + isModal: boolean; + onSubCaseClick?: (theSubCase: SubCase) => void; }): ExpandedRowMap => { if (data == null) { return {}; } + const rowProps = (theSubCase: SubCase) => { + return { + ...(isModal && onSubCaseClick ? { onClick: () => onSubCaseClick(theSubCase) } : {}), + className: 'subCase', + }; + }; + return data.reduce((acc, curr) => { if (curr.subCases != null) { const subCases = curr.subCases.map((subCase, index) => ({ @@ -58,6 +69,7 @@ export const getExpandedRowMap = ({ data-test-subj={`sub-cases-table-${curr.id}`} itemId="id" items={subCases} + rowProps={rowProps} /> ), }; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index ce0fea07bf473c..56dcf3bc28757e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -19,11 +19,12 @@ import { import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; import { isEmpty, memoize } from 'lodash/fp'; import styled, { css } from 'styled-components'; -import * as i18n from './translations'; +import classnames from 'classnames'; -import { CaseStatuses } from '../../../../../case/common/api'; +import * as i18n from './translations'; +import { CaseStatuses, CaseType } from '../../../../../case/common/api'; import { getCasesColumns } from './columns'; -import { Case, DeleteCase, FilterOptions, SortFieldCase } from '../../containers/types'; +import { Case, DeleteCase, FilterOptions, SortFieldCase, SubCase } from '../../containers/types'; import { useGetCases, UpdateCase } from '../../containers/use_get_cases'; import { useGetCasesStatus } from '../../containers/use_get_cases_status'; import { useDeleteCases } from '../../containers/use_delete_cases'; @@ -58,6 +59,7 @@ import { getExpandedRowMap } from './expanded_row'; const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; `; + const FlexItemDivider = styled(EuiFlexItem)` ${({ theme }) => css` .euiFlexGroup--gutterMedium > &.euiFlexItem { @@ -75,6 +77,7 @@ const ProgressLoader = styled(EuiProgress)` z-index: ${theme.eui.euiZHeader}; `} `; + const getSortField = (field: string): SortFieldCase => { if (field === SortFieldCase.createdAt) { return SortFieldCase.createdAt; @@ -86,19 +89,39 @@ const getSortField = (field: string): SortFieldCase => { const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any const BasicTable = styled(EuiBasicTable)` - .euiTableRow-isExpandedRow.euiTableRow-isSelectable .euiTableCellContent { - padding: 8px 0 8px 32px; - } + ${({ theme }) => ` + .euiTableRow-isExpandedRow.euiTableRow-isSelectable .euiTableCellContent { + padding: 8px 0 8px 32px; + } + + &.isModal .euiTableRow.isDisabled { + cursor: not-allowed; + background-color: ${theme.eui.euiTableHoverClickableColor}; + } + + &.isModal .euiTableRow.euiTableRow-isExpandedRow .euiTableRowCell, + &.isModal .euiTableRow.euiTableRow-isExpandedRow:hover { + background-color: transparent; + } + + &.isModal .euiTableRow.euiTableRow-isExpandedRow { + .subCase:hover { + background-color: ${theme.eui.euiTableHoverClickableColor}; + } + } + `} `; BasicTable.displayName = 'BasicTable'; interface AllCasesProps { - onRowClick?: (theCase?: Case) => void; + onRowClick?: (theCase?: Case | SubCase) => void; isModal?: boolean; userCanCrud: boolean; + disabledStatuses?: CaseStatuses[]; + disabledCases?: CaseType[]; } export const AllCases = React.memo( - ({ onRowClick, isModal = false, userCanCrud }) => { + ({ onRowClick, isModal = false, userCanCrud, disabledStatuses, disabledCases = [] }) => { const { navigateToApp } = useKibana().services.application; const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case); const { actionLicense } = useGetActionLicense(); @@ -334,8 +357,10 @@ export const AllCases = React.memo( getExpandedRowMap({ columns: memoizedGetCasesColumns, data: data.cases, + isModal, + onSubCaseClick: onRowClick, }), - [data.cases, memoizedGetCasesColumns] + [data.cases, isModal, memoizedGetCasesColumns, onRowClick] ); const memoizedPagination = useMemo( @@ -356,6 +381,7 @@ export const AllCases = React.memo( () => ({ selectable: (theCase) => isEmpty(theCase.subCases), onSelectionChange: setSelectedCases, + selectableMessage: (selectable) => (!selectable ? i18n.SELECTABLE_MESSAGE_COLLECTIONS : ''), }), [setSelectedCases] ); @@ -377,7 +403,8 @@ export const AllCases = React.memo( return { 'data-test-subj': `cases-table-row-${theCase.id}`, - ...(isModal ? { onClick: onTableRowClick } : {}), + className: classnames({ isDisabled: theCase.type === CaseType.collection }), + ...(isModal && theCase.type !== CaseType.collection ? { onClick: onTableRowClick } : {}), }; }, [isModal, onRowClick] @@ -462,6 +489,7 @@ export const AllCases = React.memo( status: filterOptions.status, }} setFilterRefetch={setFilterRefetch} + disabledStatuses={disabledStatuses} /> {isCasesLoading && isDataEmpty ? (
@@ -530,6 +558,7 @@ export const AllCases = React.memo( rowProps={tableRowProps} selection={userCanCrud && !isModal ? euiBasicTableSelectionProps : undefined} sorting={sorting} + className={classnames({ isModal })} />
)} diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx index 785d4447c0acf4..11d53b6609e74c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.test.tsx @@ -61,4 +61,24 @@ describe('StatusFilter', () => { expect(onStatusChanged).toBeCalledWith('closed'); }); }); + + it('should disabled selected statuses', () => { + const wrapper = mount( + + ); + + wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); + + expect( + wrapper.find('button[data-test-subj="case-status-filter-open"]').prop('disabled') + ).toBeFalsy(); + + expect( + wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').prop('disabled') + ).toBeFalsy(); + + expect( + wrapper.find('button[data-test-subj="case-status-filter-closed"]').prop('disabled') + ).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx index 7fa0625229b480..41997d6f384214 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/status_filter.tsx @@ -14,9 +14,15 @@ interface Props { stats: Record; selectedStatus: CaseStatuses; onStatusChanged: (status: CaseStatuses) => void; + disabledStatuses?: CaseStatuses[]; } -const StatusFilterComponent: React.FC = ({ stats, selectedStatus, onStatusChanged }) => { +const StatusFilterComponent: React.FC = ({ + stats, + selectedStatus, + onStatusChanged, + disabledStatuses = [], +}) => { const caseStatuses = Object.keys(statuses) as CaseStatuses[]; const options: Array> = caseStatuses.map((status) => ({ value: status, @@ -28,6 +34,7 @@ const StatusFilterComponent: React.FC = ({ stats, selectedStatus, onStatu {` (${stats[status]})`} ), + disabled: disabledStatuses.includes(status), 'data-test-subj': `case-status-filter-${status}`, })); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx index 1f7f1d1e0d4876..61bbbac5a1e847 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/table_filters.tsx @@ -25,6 +25,7 @@ interface CasesTableFiltersProps { onFilterChanged: (filterOptions: Partial) => void; initial: FilterOptions; setFilterRefetch: (val: () => void) => void; + disabledStatuses?: CaseStatuses[]; } // Fix the width of the status dropdown to prevent hiding long text items @@ -50,6 +51,7 @@ const CasesTableFiltersComponent = ({ onFilterChanged, initial = defaultInitial, setFilterRefetch, + disabledStatuses, }: CasesTableFiltersProps) => { const [selectedReporters, setSelectedReporters] = useState( initial.reporters.map((r) => r.full_name ?? r.username ?? '') @@ -158,6 +160,7 @@ const CasesTableFiltersComponent = ({ selectedStatus={initial.status} onStatusChanged={onStatusChanged} stats={stats} + disabledStatuses={disabledStatuses} /> diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx index 5e33736ce9c3ac..95c534f7c1edef 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx @@ -16,7 +16,7 @@ import { EuiFlexItem, EuiIconTip, } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../case/common/api'; +import { CaseStatuses, CaseType } from '../../../../../case/common/api'; import * as i18n from '../case_view/translations'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; import { Actions } from './actions'; @@ -73,19 +73,21 @@ const CaseActionBarComponent: React.FC = ({ ); return ( - + - - {i18n.STATUS} - - - - + {caseData.type !== CaseType.collection && ( + + {i18n.STATUS} + + + + + )} {title} diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index 7a5f6647a8dcf6..5f9fb5b63d6ebc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -29,6 +29,7 @@ import { connectorsMock } from '../../containers/configure/mock'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; import { ConnectorTypes } from '../../../../../case/common/api/connectors'; +import { CaseType } from '../../../../../case/common/api'; const mockDispatch = jest.fn(); jest.mock('react-redux', () => { @@ -201,6 +202,10 @@ describe('CaseView ', () => { .first() .text() ).toBe(data.description); + + expect( + wrapper.find('button[data-test-subj="case-view-status-action-button"]').first().text() + ).toBe('Mark in progress'); }); }); @@ -464,7 +469,7 @@ describe('CaseView ', () => { ); await waitFor(() => { wrapper.find('[data-test-subj="case-refresh"]').first().simulate('click'); - expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id); + expect(fetchCaseUserActions).toBeCalledWith('1234', undefined); expect(fetchCase).toBeCalled(); }); }); @@ -547,8 +552,7 @@ describe('CaseView ', () => { }); }); - // TO DO fix when the useEffects in edit_connector are cleaned up - it.skip('should update connector', async () => { + it('should update connector', async () => { const wrapper = mount( @@ -752,4 +756,74 @@ describe('CaseView ', () => { }); }); }); + + describe('Collections', () => { + it('it does not allow the user to update the status', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + expect(wrapper.find('[data-test-subj="case-action-bar-wrapper"]').exists()).toBe(true); + expect(wrapper.find('button[data-test-subj="case-view-status"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="user-actions"]').exists()).toBe(true); + expect( + wrapper.find('button[data-test-subj="case-view-status-action-button"]').exists() + ).toBe(false); + }); + }); + + it('it shows the push button when has data to push', async () => { + useGetCaseUserActionsMock.mockImplementation(() => ({ + ...defaultUseGetCaseUserActions, + hasDataToPush: true, + })); + + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + expect(wrapper.find('[data-test-subj="has-data-to-push-button"]').exists()).toBe(true); + }); + }); + + it('it does not show the horizontal rule when does NOT has data to push', async () => { + useGetCaseUserActionsMock.mockImplementation(() => ({ + ...defaultUseGetCaseUserActions, + hasDataToPush: false, + })); + + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="case-view-bottom-actions-horizontal-rule"]').exists() + ).toBe(false); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index e42431e55ee290..d0b7c34ab84fd0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -17,7 +17,7 @@ import { EuiHorizontalRule, } from '@elastic/eui'; -import { CaseStatuses, CaseAttributes } from '../../../../../case/common/api'; +import { CaseStatuses, CaseAttributes, CaseType } from '../../../../../case/common/api'; import { Case, CaseConnector } from '../../containers/types'; import { getCaseDetailsUrl, getCaseUrl, useFormatUrl } from '../../../common/components/link_to'; import { gutterTimeline } from '../../../common/lib/helpers'; @@ -213,9 +213,9 @@ export const CaseComponent = React.memo( const handleUpdateCase = useCallback( (newCase: Case) => { updateCase(newCase); - fetchCaseUserActions(newCase.id); + fetchCaseUserActions(caseId, subCaseId); }, - [updateCase, fetchCaseUserActions] + [updateCase, fetchCaseUserActions, caseId, subCaseId] ); const { loading: isLoadingConnectors, connectors } = useConnectors(); @@ -283,9 +283,9 @@ export const CaseComponent = React.memo( ); const handleRefresh = useCallback(() => { - fetchCaseUserActions(caseData.id); + fetchCaseUserActions(caseId, subCaseId); fetchCase(); - }, [caseData.id, fetchCase, fetchCaseUserActions]); + }, [caseId, fetchCase, fetchCaseUserActions, subCaseId]); const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); @@ -345,6 +345,7 @@ export const CaseComponent = React.memo( ); } }, [dispatch]); + return ( <> @@ -387,7 +388,7 @@ export const CaseComponent = React.memo( caseUserActions={caseUserActions} connectors={connectors} data={caseData} - fetchUserActions={fetchCaseUserActions.bind(null, caseData.id)} + fetchUserActions={fetchCaseUserActions.bind(null, caseId, subCaseId)} isLoadingDescription={isLoading && updateKey === 'description'} isLoadingUserActions={isLoadingUserActions} onShowAlertDetails={showAlert} @@ -395,22 +396,29 @@ export const CaseComponent = React.memo( updateCase={updateCase} userCanCrud={userCanCrud} /> - - - - + - - {hasDataToPush && ( - - {pushButton} - - )} - + {caseData.type !== CaseType.collection && ( + + + + )} + {hasDataToPush && ( + + {pushButton} + + )} + + )} )} @@ -465,6 +473,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = if (isError) { return null; } + if (isLoading) { return ( @@ -476,14 +485,16 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = } return ( - + data && ( + + ) ); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx index d5c90bd09a6db2..b7fbaff288a2a3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx @@ -47,7 +47,9 @@ const CaseParamsFields: React.FunctionComponent = ({ onCaseChanged, sel onCaseChanged(''); dispatchResetIsDeleted(); } - }, [isDeleted, dispatchResetIsDeleted, onCaseChanged]); + // onCaseChanged and/or dispatchResetIsDeleted causes re-renders + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDeleted]); useEffect(() => { if (!isLoading && !isError && data != null) { setCreatedCase(data); onCaseChanged(data.id); } - }, [data, isLoading, isError, onCaseChanged]); + // onCaseChanged causes re-renders + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, isLoading, isError]); return ( <> diff --git a/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx index 842fe9e00ab390..d5883b7b88cd0d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx @@ -20,14 +20,16 @@ jest.mock('../create/form_context', () => { onSuccess, }: { children: ReactNode; - onSuccess: ({ id }: { id: string }) => void; + onSuccess: ({ id }: { id: string }) => Promise; }) => { return ( <> @@ -55,10 +57,10 @@ jest.mock('../create/submit_button', () => { }); const onCloseFlyout = jest.fn(); -const onCaseCreated = jest.fn(); +const onSuccess = jest.fn(); const defaultProps = { onCloseFlyout, - onCaseCreated, + onSuccess, }; describe('CreateCaseFlyout', () => { @@ -97,7 +99,7 @@ describe('CreateCaseFlyout', () => { const props = wrapper.find('FormContext').props(); expect(props).toEqual( expect.objectContaining({ - onSuccess: onCaseCreated, + onSuccess, }) ); }); @@ -110,6 +112,6 @@ describe('CreateCaseFlyout', () => { ); wrapper.find(`[data-test-subj='form-context-on-success']`).first().simulate('click'); - expect(onCaseCreated).toHaveBeenCalledWith({ id: 'case-id' }); + expect(onSuccess).toHaveBeenCalledWith({ id: 'case-id' }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx index cb3436f6ba3bcb..e7bb0b25f391fd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx @@ -17,7 +17,8 @@ import * as i18n from '../../translations'; export interface CreateCaseModalProps { onCloseFlyout: () => void; - onCaseCreated: (theCase: Case) => void; + onSuccess: (theCase: Case) => Promise; + afterCaseCreated?: (theCase: Case) => Promise; } const Container = styled.div` @@ -40,7 +41,8 @@ const FormWrapper = styled.div` `; const CreateCaseFlyoutComponent: React.FC = ({ - onCaseCreated, + onSuccess, + afterCaseCreated, onCloseFlyout, }) => { return ( @@ -52,7 +54,7 @@ const CreateCaseFlyoutComponent: React.FC = ({ - + diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx index 8236ab7b19d27d..1e512ef5ffabd4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx @@ -98,6 +98,7 @@ const fillForm = (wrapper: ReactWrapper) => { describe('Create case', () => { const fetchTags = jest.fn(); const onFormSubmitSuccess = jest.fn(); + const afterCaseCreated = jest.fn(); beforeEach(() => { jest.resetAllMocks(); @@ -593,4 +594,89 @@ describe('Create case', () => { }); }); }); + + it(`it should call afterCaseCreated`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy(); + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => { + expect(afterCaseCreated).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); + }); + + it(`it should call callbacks in correct order`, async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy(); + wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); + wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy(); + }); + + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + await waitFor(() => { + expect(postCase).toHaveBeenCalled(); + expect(afterCaseCreated).toHaveBeenCalled(); + expect(pushCaseToExternalService).toHaveBeenCalled(); + expect(onFormSubmitSuccess).toHaveBeenCalled(); + }); + + const postCaseOrder = postCase.mock.invocationCallOrder[0]; + const afterCaseOrder = afterCaseCreated.mock.invocationCallOrder[0]; + const pushCaseToExternalServiceOrder = pushCaseToExternalService.mock.invocationCallOrder[0]; + const onFormSubmitSuccessOrder = onFormSubmitSuccess.mock.invocationCallOrder[0]; + + expect( + postCaseOrder < afterCaseOrder && + postCaseOrder < pushCaseToExternalServiceOrder && + postCaseOrder < onFormSubmitSuccessOrder + ).toBe(true); + + expect( + afterCaseOrder < pushCaseToExternalServiceOrder && afterCaseOrder < onFormSubmitSuccessOrder + ).toBe(true); + + expect(pushCaseToExternalServiceOrder < onFormSubmitSuccessOrder).toBe(true); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx index 83b8870ab597d3..26203d7268fd38 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx @@ -32,13 +32,15 @@ const initialCaseValue: FormProps = { interface Props { caseType?: CaseType; - onSuccess?: (theCase: Case) => void; + onSuccess?: (theCase: Case) => Promise; + afterCaseCreated?: (theCase: Case) => Promise; } export const FormContext: React.FC = ({ caseType = CaseType.individual, children, onSuccess, + afterCaseCreated, }) => { const { connectors } = useConnectors(); const { connector: configurationConnector } = useCaseConfigure(); @@ -72,6 +74,10 @@ export const FormContext: React.FC = ({ settings: { syncAlerts }, }); + if (afterCaseCreated && updatedCase) { + await afterCaseCreated(updatedCase); + } + if (updatedCase?.id && dataConnectorId !== 'none') { await pushCaseToExternalService({ caseId: updatedCase.id, @@ -80,11 +86,11 @@ export const FormContext: React.FC = ({ } if (onSuccess && updatedCase) { - onSuccess(updatedCase); + await onSuccess(updatedCase); } } }, - [caseType, connectors, postCase, onSuccess, pushCaseToExternalService] + [caseType, connectors, postCase, onSuccess, pushCaseToExternalService, afterCaseCreated] ); const { form } = useForm({ diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index b7d162bd92761a..9f904350b772ef 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -41,7 +41,7 @@ const InsertTimeline = () => { export const Create = React.memo(() => { const history = useHistory(); const onSuccess = useCallback( - ({ id }) => { + async ({ id }) => { history.push(getCaseDetailsUrl({ id })); }, [history] diff --git a/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx b/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx index ab30fe2979b9eb..22d72429836de8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/status/button.test.tsx @@ -42,7 +42,7 @@ describe('StatusActionButton', () => { expect( wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType') - ).toBe('folderClosed'); + ).toBe('folderCheck'); }); it('it renders the correct button icon: status closed', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/status/config.ts b/x-pack/plugins/security_solution/public/cases/components/status/config.ts index d811db43df814a..0eebef39859c73 100644 --- a/x-pack/plugins/security_solution/public/cases/components/status/config.ts +++ b/x-pack/plugins/security_solution/public/cases/components/status/config.ts @@ -83,7 +83,7 @@ export const statuses: Statuses = { [CaseStatuses.closed]: { color: 'default', label: i18n.CLOSED, - icon: 'folderClosed' as const, + icon: 'folderCheck' as const, actions: { bulk: { title: i18n.BULK_ACTION_CLOSE_SELECTED, diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx index aa1305f1f655cb..b3302a05cfcb21 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -30,12 +30,18 @@ jest.mock('../../../common/components/toasters', () => { jest.mock('../all_cases', () => { return { - AllCases: ({ onRowClick }: { onRowClick: ({ id }: { id: string }) => void }) => { + AllCases: ({ onRowClick }: { onRowClick: (theCase: Partial) => void }) => { return ( @@ -49,18 +55,25 @@ jest.mock('../create/form_context', () => { FormContext: ({ children, onSuccess, + afterCaseCreated, }: { children: ReactNode; - onSuccess: (theCase: Partial) => void; + onSuccess: (theCase: Partial) => Promise; + afterCaseCreated: (theCase: Partial) => Promise; }) => { return ( <> @@ -212,11 +225,43 @@ describe('AddToCaseAction', () => { }); }); - it('navigates to case view', async () => { + it('navigates to case view when attach to a new case', async () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click'); + + expect(mockDispatchToaster).toHaveBeenCalled(); + const toast = mockDispatchToaster.mock.calls[0][0].toast; + + const toastWrapper = mount( + {}} /> + ); + + toastWrapper + .find('[data-test-subj="toaster-content-case-view-link"]') + .first() + .simulate('click'); + + expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/new-case' }); + }); + + it('navigates to case view when attach to an existing case', async () => { usePostCommentMock.mockImplementation(() => { return { ...defaultPostComment, - postComment: jest.fn().mockImplementation(({ caseId, data, updateCase }) => updateCase()), + postComment: jest.fn().mockImplementation(({ caseId, data, updateCase }) => { + updateCase({ + id: 'selected-case', + title: 'the selected case', + settings: { syncAlerts: true }, + }); + }), }; }); @@ -227,8 +272,8 @@ describe('AddToCaseAction', () => { ); wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="all-cases-modal-button"]`).first().simulate('click'); expect(mockDispatchToaster).toHaveBeenCalled(); const toast = mockDispatchToaster.mock.calls[0][0].toast; @@ -242,6 +287,8 @@ describe('AddToCaseAction', () => { .first() .simulate('click'); - expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/new-case' }); + expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', { + path: '/selected-case', + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index aa9cec2d6b5b14..3000551dd3c07f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -15,7 +15,7 @@ import { EuiToolTip, } from '@elastic/eui'; -import { CommentType } from '../../../../../case/common/api'; +import { CommentType, CaseStatuses } from '../../../../../case/common/api'; import { Ecs } from '../../../../common/ecs'; import { ActionIconItem } from '../../../timelines/components/timeline/body/actions/action_icon_item'; import { usePostComment } from '../../containers/use_post_comment'; @@ -70,9 +70,9 @@ const AddToCaseActionComponent: React.FC = ({ } = useControl(); const attachAlertToCase = useCallback( - (theCase: Case) => { + async (theCase: Case, updateCase?: (newCase: Case) => void) => { closeCaseFlyoutOpen(); - postComment({ + await postComment({ caseId: theCase.id, data: { type: CommentType.alert, @@ -83,14 +83,19 @@ const AddToCaseActionComponent: React.FC = ({ name: rule?.name != null ? rule.name[0] : null, }, }, - updateCase: () => - dispatchToaster({ - type: 'addToaster', - toast: createUpdateSuccessToaster(theCase, onViewCaseClick), - }), + updateCase, }); }, - [closeCaseFlyoutOpen, postComment, eventId, eventIndex, rule, dispatchToaster, onViewCaseClick] + [closeCaseFlyoutOpen, postComment, eventId, eventIndex, rule] + ); + + const onCaseSuccess = useCallback( + async (theCase: Case) => + dispatchToaster({ + type: 'addToaster', + toast: createUpdateSuccessToaster(theCase, onViewCaseClick), + }), + [dispatchToaster, onViewCaseClick] ); const onCaseClicked = useCallback( @@ -105,12 +110,13 @@ const AddToCaseActionComponent: React.FC = ({ return; } - attachAlertToCase(theCase); + attachAlertToCase(theCase, onCaseSuccess); }, - [attachAlertToCase, openCaseFlyoutOpen] + [attachAlertToCase, onCaseSuccess, openCaseFlyoutOpen] ); const { modal: allCasesModal, openModal: openAllCaseModal } = useAllCasesModal({ + disabledStatuses: [CaseStatuses.closed], onRowClick: onCaseClicked, }); @@ -183,7 +189,11 @@ const AddToCaseActionComponent: React.FC = ({ {isCreateCaseFlyoutOpen && ( - + )} {allCasesModal} diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx index eda8ed8cdfbcd5..e1d6baa6e630a3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx @@ -6,36 +6,52 @@ */ import React, { memo } from 'react'; +import styled from 'styled-components'; import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; -import { Case } from '../../containers/types'; +import { CaseStatuses } from '../../../../../case/common/api'; +import { Case, SubCase } from '../../containers/types'; import { AllCases } from '../all_cases'; import * as i18n from './translations'; export interface AllCasesModalProps { isModalOpen: boolean; onCloseCaseModal: () => void; - onRowClick: (theCase?: Case) => void; + onRowClick: (theCase?: Case | SubCase) => void; + disabledStatuses?: CaseStatuses[]; } +const Modal = styled(EuiModal)` + ${({ theme }) => ` + width: ${theme.eui.euiBreakpoints.l}; + max-width: ${theme.eui.euiBreakpoints.l}; + `} +`; + const AllCasesModalComponent: React.FC = ({ isModalOpen, onCloseCaseModal, onRowClick, + disabledStatuses, }) => { const userPermissions = useGetUserSavedObjectPermissions(); const userCanCrud = userPermissions?.crud ?? false; return isModalOpen ? ( - + {i18n.SELECT_CASE_TITLE} - + - + ) : null; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx index 576f942a36a8fb..57bb39a1ab50f2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx @@ -123,7 +123,7 @@ describe('useAllCasesModal', () => { }); const modal = result.current.modal; - render(<>{modal}); + render({modal}); act(() => { userEvent.click(screen.getByText('case-row')); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx index 79b490c1962da4..52b8ebe0210c0e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx @@ -6,11 +6,13 @@ */ import React, { useState, useCallback, useMemo } from 'react'; -import { Case } from '../../containers/types'; +import { CaseStatuses } from '../../../../../case/common/api'; +import { Case, SubCase } from '../../containers/types'; import { AllCasesModal } from './all_cases_modal'; export interface UseAllCasesModalProps { - onRowClick: (theCase?: Case) => void; + onRowClick: (theCase?: Case | SubCase) => void; + disabledStatuses?: CaseStatuses[]; } export interface UseAllCasesModalReturnedValues { @@ -22,12 +24,13 @@ export interface UseAllCasesModalReturnedValues { export const useAllCasesModal = ({ onRowClick, + disabledStatuses, }: UseAllCasesModalProps): UseAllCasesModalReturnedValues => { const [isModalOpen, setIsModalOpen] = useState(false); const closeModal = useCallback(() => setIsModalOpen(false), []); const openModal = useCallback(() => setIsModalOpen(true), []); const onClick = useCallback( - (theCase?: Case) => { + (theCase?: Case | SubCase) => { closeModal(); onRowClick(theCase); }, @@ -41,6 +44,7 @@ export const useAllCasesModal = ({ isModalOpen={isModalOpen} onCloseCaseModal={closeModal} onRowClick={onClick} + disabledStatuses={disabledStatuses} /> ), isModalOpen, @@ -48,7 +52,7 @@ export const useAllCasesModal = ({ openModal, onRowClick, }), - [isModalOpen, closeModal, onClick, openModal, onRowClick] + [isModalOpen, closeModal, onClick, disabledStatuses, openModal, onRowClick] ); return state; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx index 0e04acb013b2d5..08fca0cc6e0097 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.test.tsx @@ -20,14 +20,16 @@ jest.mock('../create/form_context', () => { onSuccess, }: { children: ReactNode; - onSuccess: ({ id }: { id: string }) => void; + onSuccess: ({ id }: { id: string }) => Promise; }) => { return ( <> diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx index 2806e358fceee9..3e11ee526839cd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx @@ -19,7 +19,7 @@ import { CaseType } from '../../../../../case/common/api'; export interface CreateCaseModalProps { isModalOpen: boolean; onCloseCaseModal: () => void; - onSuccess: (theCase: Case) => void; + onSuccess: (theCase: Case) => Promise; caseType?: CaseType; } diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx index 9966cf75351dd4..5174c03e56e0b3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.test.tsx @@ -25,14 +25,16 @@ jest.mock('../create/form_context', () => { onSuccess, }: { children: ReactNode; - onSuccess: ({ id }: { id: string }) => void; + onSuccess: ({ id }: { id: string }) => Promise; }) => { return ( <> diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx index 3dc852a19e73fc..1cef63ae9cfbf8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx @@ -29,7 +29,7 @@ export const useCreateCaseModal = ({ const closeModal = useCallback(() => setIsModalOpen(false), []); const openModal = useCallback(() => setIsModalOpen(true), []); const onSuccess = useCallback( - (theCase) => { + async (theCase) => { onCaseCreated(theCase); closeModal(); }, diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx index c5d3ef1893ad7b..056add32add82d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx @@ -55,6 +55,9 @@ describe('UserActionTree ', () => { useFormMock.mockImplementation(() => ({ form: formHookMock })); useFormDataMock.mockImplementation(() => [{ content: sampleData.content, comment: '' }]); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); + jest + .spyOn(routeData, 'useParams') + .mockReturnValue({ detailName: 'case-id', subCaseId: 'sub-case-id' }); }); it('Loading spinner when user actions loading and displays fullName/username', () => { @@ -289,7 +292,8 @@ describe('UserActionTree ', () => { ).toEqual(false); expect(patchComment).toBeCalledWith({ commentUpdate: sampleData.content, - caseId: props.data.id, + caseId: 'case-id', + subCaseId: 'sub-case-id', commentId: props.data.comments[0].id, fetchUserActions, updateCase, diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index 2a9f99465251b9..cf68d07859ced5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -122,7 +122,11 @@ export const UserActionTree = React.memo( userCanCrud, onShowAlertDetails, }: UserActionTreeProps) => { - const { commentId, subCaseId } = useParams<{ commentId?: string; subCaseId?: string }>(); + const { detailName: caseId, commentId, subCaseId } = useParams<{ + detailName: string; + commentId?: string; + subCaseId?: string; + }>(); const handlerTimeoutId = useRef(0); const addCommentRef = useRef(null); const [initLoading, setInitLoading] = useState(true); @@ -149,15 +153,16 @@ export const UserActionTree = React.memo( const handleSaveComment = useCallback( ({ id, version }: { id: string; version: string }, content: string) => { patchComment({ - caseId: caseData.id, + caseId, commentId: id, commentUpdate: content, fetchUserActions, version, updateCase, + subCaseId, }); }, - [caseData.id, fetchUserActions, patchComment, updateCase] + [caseId, fetchUserActions, patchComment, subCaseId, updateCase] ); const handleOutlineComment = useCallback( @@ -223,7 +228,7 @@ export const UserActionTree = React.memo( const MarkdownNewComment = useMemo( () => ( ), - [caseData.id, handleUpdate, userCanCrud, handleManageMarkdownEditId, subCaseId] + [caseId, userCanCrud, handleUpdate, handleManageMarkdownEditId, subCaseId] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.test.tsx index a157be2dc13537..a3d64a17727e50 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.test.tsx @@ -6,7 +6,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { initialData, useGetCase, UseGetCase } from './use_get_case'; +import { useGetCase, UseGetCase } from './use_get_case'; import { basicCase } from './mock'; import * as api from './api'; @@ -26,8 +26,8 @@ describe('useGetCase', () => { ); await waitForNextUpdate(); expect(result.current).toEqual({ - data: initialData, - isLoading: true, + data: null, + isLoading: false, isError: false, fetchCase: result.current.fetchCase, updateCase: result.current.updateCase, @@ -102,7 +102,7 @@ describe('useGetCase', () => { await waitForNextUpdate(); expect(result.current).toEqual({ - data: initialData, + data: null, isLoading: false, isError: true, fetchCase: result.current.fetchCase, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index fb8da8d0663ee1..70e202b5d6bdf6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -6,16 +6,14 @@ */ import { useEffect, useReducer, useCallback, useRef } from 'react'; -import { CaseStatuses, CaseType } from '../../../../case/common/api'; import { Case } from './types'; import * as i18n from './translations'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { getCase, getSubCase } from './api'; -import { getNoneConnector } from '../components/configure_cases/utils'; interface CaseState { - data: Case; + data: Case | null; isLoading: boolean; isError: boolean; } @@ -56,32 +54,6 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { return state; } }; -export const initialData: Case = { - id: '', - closedAt: null, - closedBy: null, - createdAt: '', - comments: [], - connector: { ...getNoneConnector(), fields: null }, - createdBy: { - username: '', - }, - description: '', - externalService: null, - status: CaseStatuses.open, - tags: [], - title: '', - totalAlerts: 0, - totalComment: 0, - type: CaseType.individual, - updatedAt: null, - updatedBy: null, - version: '', - subCaseIds: [], - settings: { - syncAlerts: true, - }, -}; export interface UseGetCase extends CaseState { fetchCase: () => void; @@ -90,9 +62,9 @@ export interface UseGetCase extends CaseState { export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => { const [state, dispatch] = useReducer(dataFetchReducer, { - isLoading: true, + isLoading: false, isError: false, - data: initialData, + data: null, }); const [, dispatchToaster] = useStateToaster(); const isCancelledRef = useRef(false); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx index 5eb875287ba888..75d3047bc828ea 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx @@ -48,7 +48,7 @@ interface PostComment { subCaseId?: string; } export interface UsePostComment extends NewCommentState { - postComment: (args: PostComment) => void; + postComment: (args: PostComment) => Promise; } export const usePostComment = (): UsePostComment => { diff --git a/x-pack/plugins/security_solution/public/cases/translations.ts b/x-pack/plugins/security_solution/public/cases/translations.ts index caaa1f6e248ea6..b7cfe11aafda07 100644 --- a/x-pack/plugins/security_solution/public/cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/translations.ts @@ -294,3 +294,10 @@ export const ALERT_ADDED_TO_CASE = i18n.translate( defaultMessage: 'added to case', } ); + +export const SELECTABLE_MESSAGE_COLLECTIONS = i18n.translate( + 'xpack.securitySolution.common.allCases.table.selectableMessageCollections', + { + defaultMessage: 'Cases with sub-cases cannot be selected', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx index ff358b6ab0e1d6..f7466b183e18a5 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx @@ -25,7 +25,7 @@ describe('useQueryAlerts', () => { >(() => useQueryAlerts(mockAlertsQuery, indexName)); await waitForNextUpdate(); expect(result.current).toEqual({ - loading: true, + loading: false, data: null, response: '', request: '', diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx index 8557e1082c1cb9..3736c8593daa93 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx @@ -42,7 +42,7 @@ export const useQueryAlerts = ( setQuery, refetch: null, }); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); useEffect(() => { let isSubscribed = true; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx index 487d42aac48409..5cba64299ee9d5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx @@ -20,7 +20,7 @@ import { TimelineStatus, TimelineId, TimelineType } from '../../../../../common/ import { getCreateCaseUrl, getCaseDetailsUrl } from '../../../../common/components/link_to'; import { SecurityPageName } from '../../../../app/types'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; -import { Case } from '../../../../cases/containers/types'; +import { Case, SubCase } from '../../../../cases/containers/types'; import * as i18n from '../../timeline/properties/translations'; interface Props { @@ -46,7 +46,7 @@ const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { const [isPopoverOpen, setPopover] = useState(false); const onRowClick = useCallback( - async (theCase?: Case) => { + async (theCase?: Case | SubCase) => { await navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { path: theCase != null ? getCaseDetailsUrl({ id: theCase.id }) : getCreateCaseUrl(), }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts index f908a369b46d71..c58ca0242a5b5a 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts @@ -101,15 +101,15 @@ export default ({ getService }: FtrProviderContext): void => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest .delete( - `${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}?subCaseID=${ - caseInfo.subCase!.id + `${CASES_URL}/${caseInfo.id}/comments/${caseInfo.comments![0].id}?subCaseId=${ + caseInfo.subCases![0].id }` ) .set('kbn-xsrf', 'true') .send() .expect(204); const { body } = await supertest.get( - `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` ); expect(body.length).to.eql(0); }); @@ -117,24 +117,24 @@ export default ({ getService }: FtrProviderContext): void => { it('deletes all comments from a sub case', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send(postCommentUserReq) .expect(200); let { body: allComments } = await supertest.get( - `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` ); expect(allComments.length).to.eql(2); await supertest - .delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send() .expect(204); ({ body: allComments } = await supertest.get( - `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` )); // no comments for the sub case diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts index 585333291111eb..2d8e4c44e023e9 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts @@ -126,13 +126,13 @@ export default ({ getService }: FtrProviderContext): void => { it('finds comments for a sub case', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send(postCommentUserReq) .expect(200); const { body: subCaseComments }: { body: CommentsResponse } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`) + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseId=${caseInfo.subCases![0].id}`) .send() .expect(200); expect(subCaseComments.total).to.be(2); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts index 1af16f9e54563c..264103a2052e53 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts @@ -83,13 +83,13 @@ export default ({ getService }: FtrProviderContext): void => { it('should get comments from a sub cases', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest - .post(`${CASES_URL}/${caseInfo.subCase!.id}/comments`) + .post(`${CASES_URL}/${caseInfo.subCases![0].id}/comments`) .set('kbn-xsrf', 'true') .send(postCommentUserReq) .expect(200); const { body: comments } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .get(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .expect(200); expect(comments.length).to.eql(2); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts index 389ec3f088f95b..bf63c55938dfec 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts @@ -60,7 +60,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should get a sub case comment', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); const { body: comment }: { body: CommentResponse } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}`) + .get(`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.comments![0].id}`) .expect(200); expect(comment.type).to.be(CommentType.generatedAlert); }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts index 86b1c3031cbef7..6d9962e938249c 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts @@ -10,10 +10,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { - CollectionWithSubCaseResponse, - CommentType, -} from '../../../../../../plugins/case/common/api'; +import { CaseResponse, CommentType } from '../../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, @@ -56,42 +53,38 @@ export default ({ getService }: FtrProviderContext): void => { it('patches a comment for a sub case', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - const { - body: patchedSubCase, - }: { body: CollectionWithSubCaseResponse } = await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + const { body: patchedSubCase }: { body: CaseResponse } = await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send(postCommentUserReq) .expect(200); const newComment = 'Well I decided to update my comment. So what? Deal with it.'; const { body: patchedSubCaseUpdatedComment } = await supertest - .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send({ - id: patchedSubCase.subCase!.comments![1].id, - version: patchedSubCase.subCase!.comments![1].version, + id: patchedSubCase.comments![1].id, + version: patchedSubCase.comments![1].version, comment: newComment, type: CommentType.user, }) .expect(200); - expect(patchedSubCaseUpdatedComment.subCase.comments.length).to.be(2); - expect(patchedSubCaseUpdatedComment.subCase.comments[0].type).to.be( - CommentType.generatedAlert - ); - expect(patchedSubCaseUpdatedComment.subCase.comments[1].type).to.be(CommentType.user); - expect(patchedSubCaseUpdatedComment.subCase.comments[1].comment).to.be(newComment); + expect(patchedSubCaseUpdatedComment.comments.length).to.be(2); + expect(patchedSubCaseUpdatedComment.comments[0].type).to.be(CommentType.generatedAlert); + expect(patchedSubCaseUpdatedComment.comments[1].type).to.be(CommentType.user); + expect(patchedSubCaseUpdatedComment.comments[1].comment).to.be(newComment); }); it('fails to update the generated alert comment type', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest - .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send({ - id: caseInfo.subCase!.comments![0].id, - version: caseInfo.subCase!.comments![0].version, + id: caseInfo.comments![0].id, + version: caseInfo.comments![0].version, type: CommentType.alert, alertId: 'test-id', index: 'test-index', @@ -106,11 +99,11 @@ export default ({ getService }: FtrProviderContext): void => { it('fails to update the generated alert comment by using another generated alert comment', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); await supertest - .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send({ - id: caseInfo.subCase!.comments![0].id, - version: caseInfo.subCase!.comments![0].version, + id: caseInfo.comments![0].id, + version: caseInfo.comments![0].version, type: CommentType.generatedAlert, alerts: [{ _id: 'id1' }], index: 'test-index', diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts index fb095c117cdfb0..9447f7ad3613c2 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts @@ -393,13 +393,13 @@ export default ({ getService }: FtrProviderContext): void => { // create another sub case just to make sure we get the right comments await createSubCase({ supertest, actionID }); await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) .set('kbn-xsrf', 'true') .send(postCommentUserReq) .expect(200); const { body: subCaseComments }: { body: CommentsResponse } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`) + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseId=${caseInfo.subCases![0].id}`) .send() .expect(200); expect(subCaseComments.total).to.be(2); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts index 8edc3b0d081138..5e761e4d7e33a3 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts @@ -20,7 +20,7 @@ import { deleteComments, } from '../../../common/lib/utils'; import { getSubCaseDetailsUrl } from '../../../../../plugins/case/common/api/helpers'; -import { CollectionWithSubCaseResponse } from '../../../../../plugins/case/common/api'; +import { CaseResponse } from '../../../../../plugins/case/common/api'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -104,7 +104,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should delete the sub cases when deleting a collection', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - expect(caseInfo.subCase?.id).to.not.eql(undefined); + expect(caseInfo.subCases![0].id).to.not.eql(undefined); const { body } = await supertest .delete(`${CASES_URL}?ids=["${caseInfo.id}"]`) @@ -114,27 +114,25 @@ export default ({ getService }: FtrProviderContext): void => { expect(body).to.eql({}); await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .send() .expect(404); }); it(`should delete a sub case's comments when that case gets deleted`, async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - expect(caseInfo.subCase?.id).to.not.eql(undefined); + expect(caseInfo.subCases![0].id).to.not.eql(undefined); // there should be two comments on the sub case now - const { - body: patchedCaseWithSubCase, - }: { body: CollectionWithSubCaseResponse } = await supertest + const { body: patchedCaseWithSubCase }: { body: CaseResponse } = await supertest .post(`${CASES_URL}/${caseInfo.id}/comments`) .set('kbn-xsrf', 'true') - .query({ subCaseID: caseInfo.subCase!.id }) + .query({ subCaseId: caseInfo.subCases![0].id }) .send(postCommentUserReq) .expect(200); const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${ - patchedCaseWithSubCase.subCase!.comments![1].id + patchedCaseWithSubCase.comments![1].id }`; // make sure we can get the second comment await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(200); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts index a2bc0acbcf17cc..7514044d376ca4 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts @@ -265,8 +265,8 @@ export default ({ getService }: FtrProviderContext): void => { supertest, cases: [ { - id: collection.newSubCaseInfo.subCase!.id, - version: collection.newSubCaseInfo.subCase!.version, + id: collection.newSubCaseInfo.subCases![0].id, + version: collection.newSubCaseInfo.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -356,7 +356,7 @@ export default ({ getService }: FtrProviderContext): void => { it('correctly counts stats including a collection without sub cases', async () => { // delete the sub case on the collection so that it doesn't have any sub cases await supertest - .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCase!.id}"]`) + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCases![0].id}"]`) .set('kbn-xsrf', 'true') .send() .expect(204); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts index 537afbe8250682..1d8216ded8b7c0 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts @@ -19,7 +19,7 @@ import { deleteCaseAction, } from '../../../../common/lib/utils'; import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers'; -import { CollectionWithSubCaseResponse } from '../../../../../../plugins/case/common/api'; +import { CaseResponse } from '../../../../../../plugins/case/common/api'; // eslint-disable-next-line import/no-default-export export default function ({ getService }: FtrProviderContext) { @@ -40,10 +40,10 @@ export default function ({ getService }: FtrProviderContext) { it('should delete a sub case', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - expect(caseInfo.subCase?.id).to.not.eql(undefined); + expect(caseInfo.subCases![0].id).to.not.eql(undefined); const { body: subCase } = await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .send() .expect(200); @@ -57,33 +57,31 @@ export default function ({ getService }: FtrProviderContext) { expect(body).to.eql({}); await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .send() .expect(404); }); it(`should delete a sub case's comments when that case gets deleted`, async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - expect(caseInfo.subCase?.id).to.not.eql(undefined); + expect(caseInfo.subCases![0].id).to.not.eql(undefined); // there should be two comments on the sub case now - const { - body: patchedCaseWithSubCase, - }: { body: CollectionWithSubCaseResponse } = await supertest + const { body: patchedCaseWithSubCase }: { body: CaseResponse } = await supertest .post(`${CASES_URL}/${caseInfo.id}/comments`) .set('kbn-xsrf', 'true') - .query({ subCaseID: caseInfo.subCase!.id }) + .query({ subCaseId: caseInfo.subCases![0].id }) .send(postCommentUserReq) .expect(200); const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${ - patchedCaseWithSubCase.subCase!.comments![1].id + patchedCaseWithSubCase.comments![1].id }`; // make sure we can get the second comment await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(200); await supertest - .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${patchedCaseWithSubCase.subCase!.id}"]`) + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${patchedCaseWithSubCase.subCases![0].id}"]`) .set('kbn-xsrf', 'true') .send() .expect(204); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts index 3463b372509809..4fd4cd6ec7542b 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts @@ -74,7 +74,7 @@ export default ({ getService }: FtrProviderContext): void => { ...findSubCasesResp, total: 1, // find should not return the comments themselves only the stats - subCases: [{ ...caseInfo.subCase!, comments: [], totalComment: 1, totalAlerts: 2 }], + subCases: [{ ...caseInfo.subCases![0], comments: [], totalComment: 1, totalAlerts: 2 }], count_open_cases: 1, }); }); @@ -101,7 +101,7 @@ export default ({ getService }: FtrProviderContext): void => { status: CaseStatuses.closed, }, { - ...subCase2Resp.newSubCaseInfo.subCase, + ...subCase2Resp.newSubCaseInfo.subCases![0], comments: [], totalComment: 1, totalAlerts: 2, @@ -157,8 +157,8 @@ export default ({ getService }: FtrProviderContext): void => { supertest, cases: [ { - id: secondSub.subCase!.id, - version: secondSub.subCase!.version, + id: secondSub.subCases![0].id, + version: secondSub.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -231,8 +231,8 @@ export default ({ getService }: FtrProviderContext): void => { supertest, cases: [ { - id: secondSub.subCase!.id, - version: secondSub.subCase!.version, + id: secondSub.subCases![0].id, + version: secondSub.subCases![0].version, status: CaseStatuses['in-progress'], }, ], diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts index cd5a1ed85742f1..dff462d78ba82d 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts @@ -28,7 +28,7 @@ import { } from '../../../../../../plugins/case/common/api/helpers'; import { AssociationType, - CollectionWithSubCaseResponse, + CaseResponse, SubCaseResponse, } from '../../../../../../plugins/case/common/api'; @@ -53,14 +53,14 @@ export default ({ getService }: FtrProviderContext): void => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); const { body }: { body: SubCaseResponse } = await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .set('kbn-xsrf', 'true') .send() .expect(200); expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( commentsResp({ - comments: [{ comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }], + comments: [{ comment: defaultCreateSubComment, id: caseInfo.comments![0].id }], associationType: AssociationType.subCase, }) ); @@ -73,15 +73,15 @@ export default ({ getService }: FtrProviderContext): void => { it('should return the correct number of alerts with multiple types of alerts', async () => { const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - const { body: singleAlert }: { body: CollectionWithSubCaseResponse } = await supertest + const { body: singleAlert }: { body: CaseResponse } = await supertest .post(getCaseCommentsUrl(caseInfo.id)) - .query({ subCaseID: caseInfo.subCase!.id }) + .query({ subCaseId: caseInfo.subCases![0].id }) .set('kbn-xsrf', 'true') .send(postCommentAlertReq) .expect(200); const { body }: { body: SubCaseResponse } = await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .set('kbn-xsrf', 'true') .send() .expect(200); @@ -89,10 +89,10 @@ export default ({ getService }: FtrProviderContext): void => { expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( commentsResp({ comments: [ - { comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }, + { comment: defaultCreateSubComment, id: caseInfo.comments![0].id }, { comment: postCommentAlertReq, - id: singleAlert.subCase!.comments![1].id, + id: singleAlert.comments![1].id, }, ], associationType: AssociationType.subCase, diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts index 49b3c0b1f465b3..5a1da194a721f5 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts @@ -59,15 +59,15 @@ export default function ({ getService }: FtrProviderContext) { supertest, cases: [ { - id: caseInfo.subCase!.id, - version: caseInfo.subCase!.version, + id: caseInfo.subCases![0].id, + version: caseInfo.subCases![0].version, status: CaseStatuses['in-progress'], }, ], type: 'sub_case', }); const { body: subCase }: { body: SubCaseResponse } = await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) .expect(200); expect(subCase.status).to.eql(CaseStatuses['in-progress']); @@ -102,8 +102,8 @@ export default function ({ getService }: FtrProviderContext) { supertest, cases: [ { - id: caseInfo.subCase!.id, - version: caseInfo.subCase!.version, + id: caseInfo.subCases![0].id, + version: caseInfo.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -159,8 +159,8 @@ export default function ({ getService }: FtrProviderContext) { supertest, cases: [ { - id: caseInfo.subCase!.id, - version: caseInfo.subCase!.version, + id: caseInfo.subCases![0].id, + version: caseInfo.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -239,8 +239,8 @@ export default function ({ getService }: FtrProviderContext) { supertest, cases: [ { - id: collectionWithSecondSub.subCase!.id, - version: collectionWithSecondSub.subCase!.version, + id: collectionWithSecondSub.subCases![0].id, + version: collectionWithSecondSub.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -349,8 +349,8 @@ export default function ({ getService }: FtrProviderContext) { supertest, cases: [ { - id: caseInfo.subCase!.id, - version: caseInfo.subCase!.version, + id: caseInfo.subCases![0].id, + version: caseInfo.subCases![0].version, status: CaseStatuses['in-progress'], }, ], @@ -450,8 +450,8 @@ export default function ({ getService }: FtrProviderContext) { .send({ subCases: [ { - id: caseInfo.subCase!.id, - version: caseInfo.subCase!.version, + id: caseInfo.subCases![0].id, + version: caseInfo.subCases![0].version, type: 'blah', }, ], diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index f6fd2b1a6b3bed..c3c37bd20f1404 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -26,7 +26,6 @@ import { CaseClientPostRequest, SubCaseResponse, AssociationType, - CollectionWithSubCaseResponse, SubCasesFindResponse, CommentRequest, } from '../../../../plugins/case/common/api'; @@ -159,18 +158,17 @@ export const subCaseResp = ({ interface FormattedCollectionResponse { caseInfo: Partial; - subCase?: Partial; + subCases?: Array>; comments?: Array>; } -export const formatCollectionResponse = ( - caseInfo: CollectionWithSubCaseResponse -): FormattedCollectionResponse => { +export const formatCollectionResponse = (caseInfo: CaseResponse): FormattedCollectionResponse => { + const subCase = removeServerGeneratedPropertiesFromSubCase(caseInfo.subCases?.[0]); return { caseInfo: removeServerGeneratedPropertiesFromCaseCollection(caseInfo), - subCase: removeServerGeneratedPropertiesFromSubCase(caseInfo.subCase), + subCases: subCase ? [subCase] : undefined, comments: removeServerGeneratedPropertiesFromComments( - caseInfo.subCase?.comments ?? caseInfo.comments + caseInfo.subCases?.[0].comments ?? caseInfo.comments ), }; }; @@ -187,10 +185,10 @@ export const removeServerGeneratedPropertiesFromSubCase = ( }; export const removeServerGeneratedPropertiesFromCaseCollection = ( - config: Partial -): Partial => { + config: Partial +): Partial => { // eslint-disable-next-line @typescript-eslint/naming-convention - const { closed_at, created_at, updated_at, version, subCase, ...rest } = config; + const { closed_at, created_at, updated_at, version, subCases, ...rest } = config; return rest; }; diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 7aee6170c3d5ab..3ade7ef96f9dd1 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -17,7 +17,7 @@ import { CaseConnector, ConnectorTypes, CasePostRequest, - CollectionWithSubCaseResponse, + CaseResponse, SubCasesFindResponse, CaseStatuses, SubCasesResponse, @@ -120,7 +120,7 @@ export const defaultCreateSubPost = postCollectionReq; * Response structure for the createSubCase and createSubCaseComment functions. */ export interface CreateSubCaseResp { - newSubCaseInfo: CollectionWithSubCaseResponse; + newSubCaseInfo: CaseResponse; modifiedSubCases?: SubCasesResponse; } From 910a19f3c418cdae2d0c3d607595844844098324 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Fri, 26 Feb 2021 07:25:06 -0800 Subject: [PATCH 38/40] Add warning for EQL and Threshold rules if exception list contains value list items (#92914) --- .../common/detection_engine/utils.ts | 12 ++++++- .../routes/__mocks__/request_responses.ts | 29 +++++++++++++++++ .../signals/signal_rule_alert_type.test.ts | 32 ++++++++++++++++++- .../signals/signal_rule_alert_type.ts | 13 ++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts index 725a2eb9fea7bb..79b912e082fdb1 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -5,9 +5,19 @@ * 2.0. */ -import { EntriesArray } from '../shared_imports'; +import { + CreateExceptionListItemSchema, + EntriesArray, + ExceptionListItemSchema, +} from '../shared_imports'; import { Type } from './schemas/common/schemas'; +export const hasLargeValueItem = ( + exceptionItems: Array +) => { + return exceptionItems.some((exceptionItem) => hasLargeValueList(exceptionItem.entries)); +}; + export const hasLargeValueList = (entries: EntriesArray): boolean => { const found = entries.filter(({ type }) => type === 'list'); return found.length > 0; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index cf6ea572aa8561..649ce9ed643651 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -440,6 +440,35 @@ export const getMlResult = (): RuleAlertType => { }; }; +export const getThresholdResult = (): RuleAlertType => { + const result = getResult(); + + return { + ...result, + params: { + ...result.params, + type: 'threshold', + threshold: { + field: 'host.ip', + value: 5, + }, + }, + }; +}; + +export const getEqlResult = (): RuleAlertType => { + const result = getResult(); + + return { + ...result, + params: { + ...result.params, + type: 'eql', + query: 'process where true', + }, + }; +}; + export const updateActionResult = (): ActionResult => ({ id: 'result-1', actionTypeId: 'action-id-1', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index cadc6d0c5b7c01..d3d82682cbb4a0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -7,7 +7,12 @@ import moment from 'moment'; import { loggingSystemMock } from 'src/core/server/mocks'; -import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; +import { + getResult, + getMlResult, + getThresholdResult, + getEqlResult, +} from '../routes/__mocks__/request_responses'; import { signalRulesAlertType } from './signal_rule_alert_type'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import { ruleStatusServiceFactory } from './rule_status_service'; @@ -24,6 +29,7 @@ import { getListClientMock } from '../../../../../lists/server/services/lists/li import { getExceptionListClientMock } from '../../../../../lists/server/services/exception_lists/exception_list_client.mock'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { ApiResponse } from '@elastic/elasticsearch/lib/Transport'; +import { getEntryListMock } from '../../../../../lists/common/schemas/types/entry_list.mock'; jest.mock('./rule_status_saved_objects_client'); jest.mock('./rule_status_service'); @@ -211,6 +217,30 @@ describe('rules_notification_alert_type', () => { ); }); + it('should set a warning when exception list for threshold rule contains value list exceptions', async () => { + (getExceptions as jest.Mock).mockReturnValue([ + getExceptionListItemSchemaMock({ entries: [getEntryListMock()] }), + ]); + payload = getPayload(getThresholdResult(), alertServices); + await alert.executor(payload); + expect(ruleStatusService.warning).toHaveBeenCalled(); + expect(ruleStatusService.warning.mock.calls[0][0]).toContain( + 'Exceptions that use "is in list" or "is not in list" operators are not applied to Threshold rules' + ); + }); + + it('should set a warning when exception list for EQL rule contains value list exceptions', async () => { + (getExceptions as jest.Mock).mockReturnValue([ + getExceptionListItemSchemaMock({ entries: [getEntryListMock()] }), + ]); + payload = getPayload(getEqlResult(), alertServices); + await alert.executor(payload); + expect(ruleStatusService.warning).toHaveBeenCalled(); + expect(ruleStatusService.warning.mock.calls[0][0]).toContain( + 'Exceptions that use "is in list" or "is not in list" operators are not applied to EQL rules' + ); + }); + it('should set a failure status for when rules cannot read ANY provided indices', async () => { (checkPrivileges as jest.Mock).mockResolvedValueOnce({ username: 'elastic', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 2025ba512cb653..14a65bc1eeb7ba 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -24,6 +24,7 @@ import { isThresholdRule, isEqlRule, isThreatMatchRule, + hasLargeValueItem, } from '../../../../common/detection_engine/utils'; import { parseScheduleDates } from '../../../../common/detection_engine/parse_schedule_dates'; import { SetupPlugins } from '../../../plugin'; @@ -365,6 +366,12 @@ export const signalRulesAlertType = ({ }), ]); } else if (isThresholdRule(type) && threshold) { + if (hasLargeValueItem(exceptionItems ?? [])) { + await ruleStatusService.warning( + 'Exceptions that use "is in list" or "is not in list" operators are not applied to Threshold rules' + ); + wroteWarningStatus = true; + } const inputIndex = await getInputIndex(services, version, index); const thresholdFields = Array.isArray(threshold.field) @@ -552,6 +559,12 @@ export const signalRulesAlertType = ({ if (query === undefined) { throw new Error('EQL query rule must have a query defined'); } + if (hasLargeValueItem(exceptionItems ?? [])) { + await ruleStatusService.warning( + 'Exceptions that use "is in list" or "is not in list" operators are not applied to EQL rules' + ); + wroteWarningStatus = true; + } try { const signalIndexVersion = await getIndexVersion(services.callCluster, outputIndex); if (isOutdated({ current: signalIndexVersion, target: MIN_EQL_RULE_INDEX_VERSION })) { From 83234aad2d460a757c7929a3adcaf03f7df50fbc Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Fri, 26 Feb 2021 08:48:51 -0800 Subject: [PATCH 39/40] [Alerts][Doc] Added README documentation for alerts plugin status and framework health checks configuration options. (#92761) * [Alerts][Doc] Added README documentation for alerts plugin status and framework health checks configuration options. * Apply suggestions from code review Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- x-pack/plugins/alerts/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index c57216603665dc..07bad42a3bfa3d 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -15,6 +15,7 @@ Table of Contents - [Usage](#usage) - [Alerts API keys](#alerts-api-keys) - [Limitations](#limitations) + - [Plugin status](#plugin-status) - [Alert types](#alert-types) - [Methods](#methods) - [Executor](#executor) @@ -79,6 +80,27 @@ Note that the `manage_own_api_key` cluster privilege is not enough - it can be u is unauthorized for user [user-name-here] ``` +## Plugin status + +The plugin status of an alert is customized by including information about checking failures for the framework decryption: +``` +core.status.set( + combineLatest([ + core.status.derivedStatus$, + getHealthStatusStream(startPlugins.taskManager), + ]).pipe( + map(([derivedStatus, healthStatus]) => { + if (healthStatus.level > derivedStatus.level) { + return healthStatus as ServiceStatus; + } else { + return derivedStatus; + } + }) + ) + ); +``` +To check for framework decryption failures, we use the task `alerting_health_check`, which runs every 60 minutes by default. To change the default schedule, use the kibana.yml configuration option `xpack.alerts.healthCheck.interval`. + ## Alert types ### Methods From 993ac501053dc18607c9a006aaeb3f104c994ff8 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Fri, 26 Feb 2021 12:48:09 -0500 Subject: [PATCH 40/40] [Security Solution][Case][Bug] Improve case logging (#91924) * First pass at bringing in more logging * Adding more logging to routes * Adding more logging fixing tests * Removing duplicate case string in logs * Removing unneeded export * Fixing type error * Adding line breaks to make the messages more readable * Fixing type errors Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/case/server/client/alerts/get.ts | 6 +- .../client/alerts/update_status.test.ts | 1 + .../server/client/alerts/update_status.ts | 6 +- .../case/server/client/cases/create.test.ts | 101 +++--- .../case/server/client/cases/create.ts | 80 +++-- .../plugins/case/server/client/cases/get.ts | 66 ++-- .../plugins/case/server/client/cases/push.ts | 32 +- .../case/server/client/cases/update.test.ts | 73 ++-- .../case/server/client/cases/update.ts | 313 +++++++++--------- x-pack/plugins/case/server/client/client.ts | 220 ++++++++---- .../case/server/client/comments/add.test.ts | 41 ++- .../case/server/client/comments/add.ts | 275 ++++++++------- .../server/client/configure/get_mappings.ts | 75 +++-- .../plugins/case/server/client/index.test.ts | 3 + x-pack/plugins/case/server/client/mocks.ts | 2 +- x-pack/plugins/case/server/client/types.ts | 3 +- x-pack/plugins/case/server/common/error.ts | 73 ++++ .../server/common/models/commentable_case.ts | 310 +++++++++-------- .../case/server/connectors/case/index.ts | 60 +++- x-pack/plugins/case/server/plugin.ts | 6 + .../routes/api/__fixtures__/mock_router.ts | 1 + .../routes/api/__fixtures__/route_contexts.ts | 1 + .../api/cases/comments/delete_all_comments.ts | 10 +- .../api/cases/comments/delete_comment.ts | 10 +- .../api/cases/comments/find_comments.ts | 5 +- .../api/cases/comments/get_all_comment.ts | 5 +- .../routes/api/cases/comments/get_comment.ts | 5 +- .../api/cases/comments/patch_comment.ts | 32 +- .../routes/api/cases/comments/post_comment.ts | 5 +- .../api/cases/configure/get_configure.ts | 3 +- .../api/cases/configure/get_connectors.ts | 3 +- .../api/cases/configure/patch_configure.ts | 8 +- .../api/cases/configure/post_configure.ts | 8 +- .../server/routes/api/cases/delete_cases.ts | 5 +- .../server/routes/api/cases/find_cases.ts | 3 +- .../case/server/routes/api/cases/get_case.ts | 11 +- .../server/routes/api/cases/patch_cases.ts | 15 +- .../case/server/routes/api/cases/post_case.ts | 15 +- .../case/server/routes/api/cases/push_case.ts | 21 +- .../api/cases/reporters/get_reporters.ts | 3 +- .../routes/api/cases/status/get_status.ts | 3 +- .../api/cases/sub_case/delete_sub_cases.ts | 10 +- .../api/cases/sub_case/find_sub_cases.ts | 5 +- .../routes/api/cases/sub_case/get_sub_case.ts | 5 +- .../api/cases/sub_case/patch_sub_cases.ts | 308 +++++++++-------- .../user_actions/get_all_user_actions.ts | 36 +- .../plugins/case/server/routes/api/types.ts | 3 + .../plugins/case/server/routes/api/utils.ts | 17 +- .../case/server/services/alerts/index.test.ts | 5 +- .../case/server/services/alerts/index.ts | 80 +++-- .../services/connector_mappings/index.ts | 4 +- x-pack/plugins/case/server/services/index.ts | 60 ++-- .../server/services/user_actions/index.ts | 4 +- 53 files changed, 1501 insertions(+), 954 deletions(-) create mode 100644 x-pack/plugins/case/server/common/error.ts diff --git a/x-pack/plugins/case/server/client/alerts/get.ts b/x-pack/plugins/case/server/client/alerts/get.ts index a7ca5d9742c6bb..0b2663b7372041 100644 --- a/x-pack/plugins/case/server/client/alerts/get.ts +++ b/x-pack/plugins/case/server/client/alerts/get.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ElasticsearchClient } from 'kibana/server'; +import { ElasticsearchClient, Logger } from 'kibana/server'; import { AlertServiceContract } from '../../services'; import { CaseClientGetAlertsResponse } from './types'; @@ -14,6 +14,7 @@ interface GetParams { ids: string[]; indices: Set; scopedClusterClient: ElasticsearchClient; + logger: Logger; } export const get = async ({ @@ -21,12 +22,13 @@ export const get = async ({ ids, indices, scopedClusterClient, + logger, }: GetParams): Promise => { if (ids.length === 0 || indices.size <= 0) { return []; } - const alerts = await alertsService.getAlerts({ ids, indices, scopedClusterClient }); + const alerts = await alertsService.getAlerts({ ids, indices, scopedClusterClient, logger }); if (!alerts) { return []; } diff --git a/x-pack/plugins/case/server/client/alerts/update_status.test.ts b/x-pack/plugins/case/server/client/alerts/update_status.test.ts index c8df1c8ab74f36..b3ed3c2b84a993 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.test.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.test.ts @@ -22,6 +22,7 @@ describe('updateAlertsStatus', () => { expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ scopedClusterClient: expect.anything(), + logger: expect.anything(), ids: ['alert-id-1'], indices: new Set(['.siem-signals']), status: CaseStatuses.closed, diff --git a/x-pack/plugins/case/server/client/alerts/update_status.ts b/x-pack/plugins/case/server/client/alerts/update_status.ts index cb18bd4fc16e3e..2194c3a18afdd6 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ElasticsearchClient } from 'src/core/server'; +import { ElasticsearchClient, Logger } from 'src/core/server'; import { CaseStatuses } from '../../../common/api'; import { AlertServiceContract } from '../../services'; @@ -15,6 +15,7 @@ interface UpdateAlertsStatusArgs { status: CaseStatuses; indices: Set; scopedClusterClient: ElasticsearchClient; + logger: Logger; } export const updateAlertsStatus = async ({ @@ -23,6 +24,7 @@ export const updateAlertsStatus = async ({ status, indices, scopedClusterClient, + logger, }: UpdateAlertsStatusArgs): Promise => { - await alertsService.updateAlertsStatus({ ids, status, indices, scopedClusterClient }); + await alertsService.updateAlertsStatus({ ids, status, indices, scopedClusterClient, logger }); }; diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index 3016a57f218757..e8cc1a8898f047 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -6,6 +6,7 @@ */ import { ConnectorTypes, CaseStatuses, CaseType, CaseClientPostRequest } from '../../../common/api'; +import { isCaseError } from '../../common/error'; import { createMockSavedObjectsRepository, @@ -276,14 +277,16 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .create({ theCase: postCase }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when missing description', async () => { @@ -303,14 +306,16 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .create({ theCase: postCase }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when missing tags', async () => { @@ -330,14 +335,16 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .create({ theCase: postCase }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when missing connector ', async () => { @@ -352,14 +359,16 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .create({ theCase: postCase }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when connector missing the right fields', async () => { @@ -380,14 +389,16 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .create({ theCase: postCase }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws if you passing status for a new case', async () => { @@ -413,7 +424,7 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create(postCase).catch((e) => { + return caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(400); @@ -441,10 +452,12 @@ describe('create', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create(postCase).catch((e) => { + return caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(400); }); }); }); diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index ee47c59072fdd9..f88924483e0b87 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -10,7 +10,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObjectsClientContract } from 'src/core/server'; +import { SavedObjectsClientContract, Logger } from 'src/core/server'; import { flattenCaseSavedObject, transformNewCase } from '../../routes/api/utils'; import { @@ -34,6 +34,7 @@ import { CaseServiceSetup, CaseUserActionServiceSetup, } from '../../services'; +import { createCaseError } from '../../common/error'; interface CreateCaseArgs { caseConfigureService: CaseConfigureServiceSetup; @@ -42,8 +43,12 @@ interface CreateCaseArgs { savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; theCase: CasePostRequest; + logger: Logger; } +/** + * Creates a new case. + */ export const create = async ({ savedObjectsClient, caseService, @@ -51,6 +56,7 @@ export const create = async ({ userActionService, user, theCase, + logger, }: CreateCaseArgs): Promise => { // default to an individual case if the type is not defined. const { type = CaseType.individual, ...nonTypeCaseFields } = theCase; @@ -60,41 +66,45 @@ export const create = async ({ fold(throwErrors(Boom.badRequest), identity) ); - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = user; - const createdDate = new Date().toISOString(); - const myCaseConfigure = await caseConfigureService.find({ client: savedObjectsClient }); - const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); - - const newCase = await caseService.postNewCase({ - client: savedObjectsClient, - attributes: transformNewCase({ - createdDate, - newCase: query, - username, - full_name, - email, - connector: transformCaseConnectorToEsConnector(query.connector ?? caseConfigureConnector), - }), - }); + try { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const createdDate = new Date().toISOString(); + const myCaseConfigure = await caseConfigureService.find({ client: savedObjectsClient }); + const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: [ - buildCaseUserActionItem({ - action: 'create', - actionAt: createdDate, - actionBy: { username, full_name, email }, - caseId: newCase.id, - fields: ['description', 'status', 'tags', 'title', 'connector', 'settings'], - newValue: JSON.stringify(query), + const newCase = await caseService.postNewCase({ + client: savedObjectsClient, + attributes: transformNewCase({ + createdDate, + newCase: query, + username, + full_name, + email, + connector: transformCaseConnectorToEsConnector(query.connector ?? caseConfigureConnector), }), - ], - }); + }); - return CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: newCase, - }) - ); + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCaseUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: newCase.id, + fields: ['description', 'status', 'tags', 'title', 'connector', 'settings'], + newValue: JSON.stringify(query), + }), + ], + }); + + return CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: newCase, + }) + ); + } catch (error) { + throw createCaseError({ message: `Failed to create case: ${error}`, error, logger }); + } }; diff --git a/x-pack/plugins/case/server/client/cases/get.ts b/x-pack/plugins/case/server/client/cases/get.ts index ab0b97abbcb76a..fa556986ee8d34 100644 --- a/x-pack/plugins/case/server/client/cases/get.ts +++ b/x-pack/plugins/case/server/client/cases/get.ts @@ -5,11 +5,12 @@ * 2.0. */ -import { SavedObjectsClientContract } from 'kibana/server'; +import { SavedObjectsClientContract, Logger } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; import { CaseResponseRt, CaseResponse } from '../../../common/api'; import { CaseServiceSetup } from '../../services'; import { countAlertsForID } from '../../common'; +import { createCaseError } from '../../common/error'; interface GetParams { savedObjectsClient: SavedObjectsClientContract; @@ -17,50 +18,59 @@ interface GetParams { id: string; includeComments?: boolean; includeSubCaseComments?: boolean; + logger: Logger; } +/** + * Retrieves a case and optionally its comments and sub case comments. + */ export const get = async ({ savedObjectsClient, caseService, id, + logger, includeComments = false, includeSubCaseComments = false, }: GetParams): Promise => { - const [theCase, subCasesForCaseId] = await Promise.all([ - caseService.getCase({ + try { + const [theCase, subCasesForCaseId] = await Promise.all([ + caseService.getCase({ + client: savedObjectsClient, + id, + }), + caseService.findSubCasesByCaseId({ client: savedObjectsClient, ids: [id] }), + ]); + + const subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id); + + if (!includeComments) { + return CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + subCaseIds, + }) + ); + } + const theComments = await caseService.getAllCaseComments({ client: savedObjectsClient, id, - }), - caseService.findSubCasesByCaseId({ client: savedObjectsClient, ids: [id] }), - ]); + options: { + sortField: 'created_at', + sortOrder: 'asc', + }, + includeSubCaseComments, + }); - const subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id); - - if (!includeComments) { return CaseResponseRt.encode( flattenCaseSavedObject({ savedObject: theCase, + comments: theComments.saved_objects, subCaseIds, + totalComment: theComments.total, + totalAlerts: countAlertsForID({ comments: theComments, id }), }) ); + } catch (error) { + throw createCaseError({ message: `Failed to get case id: ${id}: ${error}`, error, logger }); } - const theComments = await caseService.getAllCaseComments({ - client: savedObjectsClient, - id, - options: { - sortField: 'created_at', - sortOrder: 'asc', - }, - includeSubCaseComments, - }); - - return CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: theCase, - comments: theComments.saved_objects, - subCaseIds, - totalComment: theComments.total, - totalAlerts: countAlertsForID({ comments: theComments, id }), - }) - ); }; diff --git a/x-pack/plugins/case/server/client/cases/push.ts b/x-pack/plugins/case/server/client/cases/push.ts index 1e0c246855d88b..352328ed1dd40d 100644 --- a/x-pack/plugins/case/server/client/cases/push.ts +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -5,11 +5,12 @@ * 2.0. */ -import Boom, { isBoom, Boom as BoomType } from '@hapi/boom'; +import Boom from '@hapi/boom'; import { SavedObjectsBulkUpdateResponse, SavedObjectsClientContract, SavedObjectsUpdateResponse, + Logger, } from 'kibana/server'; import { ActionResult, ActionsClient } from '../../../../actions/server'; import { flattenCaseSavedObject, getAlertIndicesAndIDs } from '../../routes/api/utils'; @@ -34,16 +35,7 @@ import { CaseUserActionServiceSetup, } from '../../services'; import { CaseClientHandler } from '../client'; - -const createError = (e: Error | BoomType, message: string): Error | BoomType => { - if (isBoom(e)) { - e.message = message; - e.output.payload.message = message; - return e; - } - - return Error(message); -}; +import { createCaseError } from '../../common/error'; interface PushParams { savedObjectsClient: SavedObjectsClientContract; @@ -55,6 +47,7 @@ interface PushParams { connectorId: string; caseClient: CaseClientHandler; actionsClient: ActionsClient; + logger: Logger; } export const push = async ({ @@ -67,6 +60,7 @@ export const push = async ({ connectorId, caseId, user, + logger, }: PushParams): Promise => { /* Start of push to external service */ let theCase: CaseResponse; @@ -84,7 +78,7 @@ export const push = async ({ ]); } catch (e) { const message = `Error getting case and/or connector and/or user actions: ${e.message}`; - throw createError(e, message); + throw createCaseError({ message, error: e, logger }); } // We need to change the logic when we support subcases @@ -102,7 +96,11 @@ export const push = async ({ indices, }); } catch (e) { - throw new Error(`Error getting alerts for case with id ${theCase.id}: ${e.message}`); + throw createCaseError({ + message: `Error getting alerts for case with id ${theCase.id}: ${e.message}`, + logger, + error: e, + }); } try { @@ -113,7 +111,7 @@ export const push = async ({ }); } catch (e) { const message = `Error getting mapping for connector with id ${connector.id}: ${e.message}`; - throw createError(e, message); + throw createCaseError({ message, error: e, logger }); } try { @@ -127,7 +125,7 @@ export const push = async ({ }); } catch (e) { const message = `Error creating incident for case with id ${theCase.id}: ${e.message}`; - throw createError(e, message); + throw createCaseError({ error: e, message, logger }); } const pushRes = await actionsClient.execute({ @@ -171,7 +169,7 @@ export const push = async ({ ]); } catch (e) { const message = `Error getting user and/or case and/or case configuration and/or case comments: ${e.message}`; - throw createError(e, message); + throw createCaseError({ error: e, message, logger }); } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -257,7 +255,7 @@ export const push = async ({ ]); } catch (e) { const message = `Error updating case and/or comments and/or creating user action: ${e.message}`; - throw createError(e, message); + throw createCaseError({ error: e, message, logger }); } /* End of update case with push information */ diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts index 7a3e4458f25c54..752b0ab369de09 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -6,6 +6,7 @@ */ import { ConnectorTypes, CasesPatchRequest, CaseStatuses } from '../../../common/api'; +import { isCaseError } from '../../common/error'; import { createMockSavedObjectsRepository, mockCaseNoConnectorId, @@ -640,14 +641,16 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .update({ cases: patchCases }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .update({ cases: patchCases }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when missing version', async () => { @@ -671,18 +674,20 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client - // @ts-expect-error - .update({ cases: patchCases }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); + return ( + caseClient.client + // @ts-expect-error + .update({ cases: patchCases }) + .catch((e) => { + expect(e).not.toBeNull(); + expect(e.isBoom).toBe(true); + expect(e.output.statusCode).toBe(400); + }) + ); }); test('it throws when fields are identical', async () => { - expect.assertions(4); + expect.assertions(5); const patchCases = { cases: [ { @@ -698,16 +703,18 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update(patchCases).catch((e) => { + return caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(406); - expect(e.message).toBe('All update fields are identical to current version.'); + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(406); + expect(boomErr.message).toContain('All update fields are identical to current version.'); }); }); test('it throws when case does not exist', async () => { - expect.assertions(4); + expect.assertions(5); const patchCases = { cases: [ { @@ -728,18 +735,20 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update(patchCases).catch((e) => { + return caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(404); - expect(e.message).toBe( + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(404); + expect(boomErr.message).toContain( 'These cases not-exists do not exist. Please check you have the correct ids.' ); }); }); test('it throws when cases conflicts', async () => { - expect.assertions(4); + expect.assertions(5); const patchCases = { cases: [ { @@ -755,11 +764,13 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update(patchCases).catch((e) => { + return caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(409); - expect(e.message).toBe( + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(409); + expect(boomErr.message).toContain( 'These cases mock-id-1 has been updated. Please refresh before saving additional updates.' ); }); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index a4ca2b4cbdef98..36318f03bd33f5 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -15,6 +15,7 @@ import { SavedObjectsClientContract, SavedObjectsFindResponse, SavedObjectsFindResult, + Logger, } from 'kibana/server'; import { AlertInfo, @@ -53,6 +54,7 @@ import { } from '../../saved_object_types'; import { CaseClientHandler } from '..'; import { addAlertInfoToStatusMap } from '../../common'; +import { createCaseError } from '../../common/error'; /** * Throws an error if any of the requests attempt to update a collection style cases' status field. @@ -325,6 +327,7 @@ interface UpdateArgs { user: User; caseClient: CaseClientHandler; cases: CasesPatchRequest; + logger: Logger; } export const update = async ({ @@ -334,175 +337,189 @@ export const update = async ({ user, caseClient, cases, + logger, }: UpdateArgs): Promise => { const query = pipe( excess(CasesPatchRequestRt).decode(cases), fold(throwErrors(Boom.badRequest), identity) ); - const myCases = await caseService.getCases({ - client: savedObjectsClient, - caseIds: query.cases.map((q) => q.id), - }); - - let nonExistingCases: CasePatchRequest[] = []; - const conflictedCases = query.cases.filter((q) => { - const myCase = myCases.saved_objects.find((c) => c.id === q.id); + try { + const myCases = await caseService.getCases({ + client: savedObjectsClient, + caseIds: query.cases.map((q) => q.id), + }); - if (myCase && myCase.error) { - nonExistingCases = [...nonExistingCases, q]; - return false; - } - return myCase == null || myCase?.version !== q.version; - }); + let nonExistingCases: CasePatchRequest[] = []; + const conflictedCases = query.cases.filter((q) => { + const myCase = myCases.saved_objects.find((c) => c.id === q.id); - if (nonExistingCases.length > 0) { - throw Boom.notFound( - `These cases ${nonExistingCases - .map((c) => c.id) - .join(', ')} do not exist. Please check you have the correct ids.` - ); - } + if (myCase && myCase.error) { + nonExistingCases = [...nonExistingCases, q]; + return false; + } + return myCase == null || myCase?.version !== q.version; + }); - if (conflictedCases.length > 0) { - throw Boom.conflict( - `These cases ${conflictedCases - .map((c) => c.id) - .join(', ')} has been updated. Please refresh before saving additional updates.` - ); - } + if (nonExistingCases.length > 0) { + throw Boom.notFound( + `These cases ${nonExistingCases + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } - const updateCases: ESCasePatchRequest[] = query.cases.map((updateCase) => { - const currentCase = myCases.saved_objects.find((c) => c.id === updateCase.id); - const { connector, ...thisCase } = updateCase; - return currentCase != null - ? getCaseToUpdate(currentCase.attributes, { - ...thisCase, - ...(connector != null - ? { connector: transformCaseConnectorToEsConnector(connector) } - : {}), - }) - : { id: thisCase.id, version: thisCase.version }; - }); + if (conflictedCases.length > 0) { + throw Boom.conflict( + `These cases ${conflictedCases + .map((c) => c.id) + .join(', ')} has been updated. Please refresh before saving additional updates.` + ); + } - const updateFilterCases = updateCases.filter((updateCase) => { - const { id, version, ...updateCaseAttributes } = updateCase; - return Object.keys(updateCaseAttributes).length > 0; - }); + const updateCases: ESCasePatchRequest[] = query.cases.map((updateCase) => { + const currentCase = myCases.saved_objects.find((c) => c.id === updateCase.id); + const { connector, ...thisCase } = updateCase; + return currentCase != null + ? getCaseToUpdate(currentCase.attributes, { + ...thisCase, + ...(connector != null + ? { connector: transformCaseConnectorToEsConnector(connector) } + : {}), + }) + : { id: thisCase.id, version: thisCase.version }; + }); - if (updateFilterCases.length <= 0) { - throw Boom.notAcceptable('All update fields are identical to current version.'); - } + const updateFilterCases = updateCases.filter((updateCase) => { + const { id, version, ...updateCaseAttributes } = updateCase; + return Object.keys(updateCaseAttributes).length > 0; + }); - const casesMap = myCases.saved_objects.reduce((acc, so) => { - acc.set(so.id, so); - return acc; - }, new Map>()); + if (updateFilterCases.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } - throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); - throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); - await throwIfInvalidUpdateOfTypeWithAlerts({ - requests: updateFilterCases, - caseService, - client: savedObjectsClient, - }); + const casesMap = myCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); + + throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); + throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); + await throwIfInvalidUpdateOfTypeWithAlerts({ + requests: updateFilterCases, + caseService, + client: savedObjectsClient, + }); - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = user; - const updatedDt = new Date().toISOString(); - const updatedCases = await caseService.patchCases({ - client: savedObjectsClient, - cases: updateFilterCases.map((thisCase) => { - const { id: caseId, version, ...updateCaseAttributes } = thisCase; - let closedInfo = {}; - if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { - closedInfo = { - closed_at: updatedDt, - closed_by: { email, full_name, username }, - }; - } else if ( - updateCaseAttributes.status && - (updateCaseAttributes.status === CaseStatuses.open || - updateCaseAttributes.status === CaseStatuses['in-progress']) - ) { - closedInfo = { - closed_at: null, - closed_by: null, + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const updatedDt = new Date().toISOString(); + const updatedCases = await caseService.patchCases({ + client: savedObjectsClient, + cases: updateFilterCases.map((thisCase) => { + const { id: caseId, version, ...updateCaseAttributes } = thisCase; + let closedInfo = {}; + if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { + closedInfo = { + closed_at: updatedDt, + closed_by: { email, full_name, username }, + }; + } else if ( + updateCaseAttributes.status && + (updateCaseAttributes.status === CaseStatuses.open || + updateCaseAttributes.status === CaseStatuses['in-progress']) + ) { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } + return { + caseId, + updatedAttributes: { + ...updateCaseAttributes, + ...closedInfo, + updated_at: updatedDt, + updated_by: { email, full_name, username }, + }, + version, }; - } - return { - caseId, - updatedAttributes: { - ...updateCaseAttributes, - ...closedInfo, - updated_at: updatedDt, - updated_by: { email, full_name, username }, - }, - version, - }; - }), - }); + }), + }); - // If a status update occurred and the case is synced then we need to update all alerts' status - // attached to the case to the new status. - const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); - return ( - currentCase != null && - caseToUpdate.status != null && - currentCase.attributes.status !== caseToUpdate.status && - currentCase.attributes.settings.syncAlerts - ); - }); + // If a status update occurred and the case is synced then we need to update all alerts' status + // attached to the case to the new status. + const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.status != null && + currentCase.attributes.status !== caseToUpdate.status && + currentCase.attributes.settings.syncAlerts + ); + }); - // If syncAlerts setting turned on we need to update all alerts' status - // attached to the case to the current status. - const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); - return ( - currentCase != null && - caseToUpdate.settings?.syncAlerts != null && - currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && - caseToUpdate.settings.syncAlerts - ); - }); + // If syncAlerts setting turned on we need to update all alerts' status + // attached to the case to the current status. + const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.settings?.syncAlerts != null && + currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && + caseToUpdate.settings.syncAlerts + ); + }); - // Update the alert's status to match any case status or sync settings changes - await updateAlerts({ - casesWithStatusChangedAndSynced, - casesWithSyncSettingChangedToOn, - caseService, - client: savedObjectsClient, - caseClient, - casesMap, - }); + // Update the alert's status to match any case status or sync settings changes + await updateAlerts({ + casesWithStatusChangedAndSynced, + casesWithSyncSettingChangedToOn, + caseService, + client: savedObjectsClient, + caseClient, + casesMap, + }); - const returnUpdatedCase = myCases.saved_objects - .filter((myCase) => - updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) - ) - .map((myCase) => { - const updatedCase = updatedCases.saved_objects.find((c) => c.id === myCase.id); - return flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase?.attributes }, - references: myCase.references, - version: updatedCase?.version ?? myCase.version, - }, + const returnUpdatedCase = myCases.saved_objects + .filter((myCase) => + updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) + ) + .map((myCase) => { + const updatedCase = updatedCases.saved_objects.find((c) => c.id === myCase.id); + return flattenCaseSavedObject({ + savedObject: { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + version: updatedCase?.version ?? myCase.version, + }, + }); }); - }); - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: buildCaseUserActions({ - originalCases: myCases.saved_objects, - updatedCases: updatedCases.saved_objects, - actionDate: updatedDt, - actionBy: { email, full_name, username }, - }), - }); + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: buildCaseUserActions({ + originalCases: myCases.saved_objects, + updatedCases: updatedCases.saved_objects, + actionDate: updatedDt, + actionBy: { email, full_name, username }, + }), + }); - return CasesResponseRt.encode(returnUpdatedCase); + return CasesResponseRt.encode(returnUpdatedCase); + } catch (error) { + const idVersions = cases.cases.map((caseInfo) => ({ + id: caseInfo.id, + version: caseInfo.version, + })); + + throw createCaseError({ + message: `Failed to update case, ids: ${JSON.stringify(idVersions)}: ${error}`, + error, + logger, + }); + } }; diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts index c684548decbe6f..c34c3942b18d0e 100644 --- a/x-pack/plugins/case/server/client/client.ts +++ b/x-pack/plugins/case/server/client/client.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'src/core/server'; import { CaseClientFactoryArguments, CaseClient, @@ -36,6 +36,7 @@ import { get } from './cases/get'; import { get as getUserActions } from './user_actions/get'; import { get as getAlerts } from './alerts/get'; import { push } from './cases/push'; +import { createCaseError } from '../common/error'; /** * This class is a pass through for common case functionality (like creating, get a case). @@ -49,6 +50,7 @@ export class CaseClientHandler implements CaseClient { private readonly _savedObjectsClient: SavedObjectsClientContract; private readonly _userActionService: CaseUserActionServiceSetup; private readonly _alertsService: AlertServiceContract; + private readonly logger: Logger; constructor(clientArgs: CaseClientFactoryArguments) { this._scopedClusterClient = clientArgs.scopedClusterClient; @@ -59,96 +61,190 @@ export class CaseClientHandler implements CaseClient { this._savedObjectsClient = clientArgs.savedObjectsClient; this._userActionService = clientArgs.userActionService; this._alertsService = clientArgs.alertsService; + this.logger = clientArgs.logger; } public async create(caseInfo: CasePostRequest) { - return create({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - caseConfigureService: this._caseConfigureService, - userActionService: this._userActionService, - user: this.user, - theCase: caseInfo, - }); + try { + return create({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + caseConfigureService: this._caseConfigureService, + userActionService: this._userActionService, + user: this.user, + theCase: caseInfo, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to create a new case using client: ${error}`, + error, + logger: this.logger, + }); + } } public async update(cases: CasesPatchRequest) { - return update({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - user: this.user, - cases, - caseClient: this, - }); + try { + return update({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + user: this.user, + cases, + caseClient: this, + logger: this.logger, + }); + } catch (error) { + const caseIDVersions = cases.cases.map((caseInfo) => ({ + id: caseInfo.id, + version: caseInfo.version, + })); + throw createCaseError({ + message: `Failed to update cases using client: ${JSON.stringify(caseIDVersions)}: ${error}`, + error, + logger: this.logger, + }); + } } public async addComment({ caseId, comment }: CaseClientAddComment) { - return addComment({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - caseClient: this, - caseId, - comment, - user: this.user, - }); + try { + return addComment({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + caseClient: this, + caseId, + comment, + user: this.user, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to add comment using client case id: ${caseId}: ${error}`, + error, + logger: this.logger, + }); + } } public async getFields(fields: ConfigureFields) { - return getFields(fields); + try { + return getFields(fields); + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve fields using client: ${error}`, + error, + logger: this.logger, + }); + } } public async getMappings(args: MappingsClient) { - return getMappings({ - ...args, - savedObjectsClient: this._savedObjectsClient, - connectorMappingsService: this._connectorMappingsService, - caseClient: this, - }); + try { + return getMappings({ + ...args, + savedObjectsClient: this._savedObjectsClient, + connectorMappingsService: this._connectorMappingsService, + caseClient: this, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to get mappings using client: ${error}`, + error, + logger: this.logger, + }); + } } public async updateAlertsStatus(args: CaseClientUpdateAlertsStatus) { - return updateAlertsStatus({ - ...args, - alertsService: this._alertsService, - scopedClusterClient: this._scopedClusterClient, - }); + try { + return updateAlertsStatus({ + ...args, + alertsService: this._alertsService, + scopedClusterClient: this._scopedClusterClient, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to update alerts status using client ids: ${JSON.stringify( + args.ids + )} \nindices: ${JSON.stringify([...args.indices])} \nstatus: ${args.status}: ${error}`, + error, + logger: this.logger, + }); + } } public async get(args: CaseClientGet) { - return get({ - ...args, - caseService: this._caseService, - savedObjectsClient: this._savedObjectsClient, - }); + try { + return get({ + ...args, + caseService: this._caseService, + savedObjectsClient: this._savedObjectsClient, + logger: this.logger, + }); + } catch (error) { + this.logger.error(`Failed to get case using client id: ${args.id}: ${error}`); + throw error; + } } public async getUserActions(args: CaseClientGetUserActions) { - return getUserActions({ - ...args, - savedObjectsClient: this._savedObjectsClient, - userActionService: this._userActionService, - }); + try { + return getUserActions({ + ...args, + savedObjectsClient: this._savedObjectsClient, + userActionService: this._userActionService, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to get user actions using client id: ${args.caseId}: ${error}`, + error, + logger: this.logger, + }); + } } public async getAlerts(args: CaseClientGetAlerts) { - return getAlerts({ - ...args, - alertsService: this._alertsService, - scopedClusterClient: this._scopedClusterClient, - }); + try { + return getAlerts({ + ...args, + alertsService: this._alertsService, + scopedClusterClient: this._scopedClusterClient, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to get alerts using client ids: ${JSON.stringify( + args.ids + )} \nindices: ${JSON.stringify([...args.indices])}: ${error}`, + error, + logger: this.logger, + }); + } } public async push(args: CaseClientPush) { - return push({ - ...args, - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - user: this.user, - caseClient: this, - caseConfigureService: this._caseConfigureService, - }); + try { + return push({ + ...args, + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + user: this.user, + caseClient: this, + caseConfigureService: this._caseConfigureService, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to push case using client id: ${args.caseId}: ${error}`, + error, + logger: this.logger, + }); + } } } diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts index c9b1e4fd132722..123ecec6abea33 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -7,6 +7,7 @@ import { omit } from 'lodash/fp'; import { CommentType } from '../../../common/api'; +import { isCaseError } from '../../common/error'; import { createMockSavedObjectsRepository, mockCaseComments, @@ -297,7 +298,7 @@ describe('addComment', () => { caseCommentSavedObject: mockCaseComments, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', // @ts-expect-error @@ -326,7 +327,7 @@ describe('addComment', () => { ['comment'].forEach((attribute) => { const requestAttributes = omit(attribute, allRequestAttributes); - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', // @ts-expect-error @@ -353,7 +354,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); ['alertId', 'index'].forEach((attribute) => { - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', comment: { @@ -387,7 +388,7 @@ describe('addComment', () => { ['alertId', 'index'].forEach((attribute) => { const requestAttributes = omit(attribute, allRequestAttributes); - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', // @ts-expect-error @@ -414,7 +415,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); ['comment'].forEach((attribute) => { - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', comment: { @@ -437,14 +438,14 @@ describe('addComment', () => { }); test('it throws when the case does not exists', async () => { - expect.assertions(3); + expect.assertions(4); const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client + return caseClient.client .addComment({ caseId: 'not-exists', comment: { @@ -454,20 +455,22 @@ describe('addComment', () => { }) .catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(404); + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(404); }); }); test('it throws when postNewCase throws', async () => { - expect.assertions(3); + expect.assertions(4); const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-1', comment: { @@ -477,13 +480,15 @@ describe('addComment', () => { }) .catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(400); }); }); test('it throws when the case is closed and the comment is of type alert', async () => { - expect.assertions(3); + expect.assertions(4); const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, @@ -491,7 +496,7 @@ describe('addComment', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client + return caseClient.client .addComment({ caseId: 'mock-id-4', comment: { @@ -506,8 +511,10 @@ describe('addComment', () => { }) .catch((e) => { expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); + expect(isCaseError(e)).toBeTruthy(); + const boomErr = e.boomify(); + expect(boomErr.isBoom).toBe(true); + expect(boomErr.output.statusCode).toBe(400); }); }); }); diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 4c1cc59a957509..d3d7047e71bd3c 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -10,7 +10,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; +import { SavedObject, SavedObjectsClientContract, Logger } from 'src/core/server'; import { decodeCommentRequest, getAlertIds, @@ -38,6 +38,7 @@ import { import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; import { CommentableCase } from '../../common'; import { CaseClientHandler } from '..'; +import { createCaseError } from '../../common/error'; import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; import { MAX_GENERATED_ALERTS_PER_SUB_CASE } from '../../../common/constants'; @@ -104,6 +105,7 @@ interface AddCommentFromRuleArgs { savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; + logger: Logger; } const addGeneratedAlerts = async ({ @@ -113,6 +115,7 @@ const addGeneratedAlerts = async ({ caseClient, caseId, comment, + logger, }: AddCommentFromRuleArgs): Promise => { const query = pipe( AlertCommentRequestRt.decode(comment), @@ -125,88 +128,104 @@ const addGeneratedAlerts = async ({ if (comment.type !== CommentType.generatedAlert) { throw Boom.internal('Attempting to add a non generated alert in the wrong context'); } - const createdDate = new Date().toISOString(); - const caseInfo = await caseService.getCase({ - client: savedObjectsClient, - id: caseId, - }); + try { + const createdDate = new Date().toISOString(); - if ( - query.type === CommentType.generatedAlert && - caseInfo.attributes.type !== CaseType.collection - ) { - throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); - } + const caseInfo = await caseService.getCase({ + client: savedObjectsClient, + id: caseId, + }); - const userDetails: User = { - username: caseInfo.attributes.created_by?.username, - full_name: caseInfo.attributes.created_by?.full_name, - email: caseInfo.attributes.created_by?.email, - }; + if ( + query.type === CommentType.generatedAlert && + caseInfo.attributes.type !== CaseType.collection + ) { + throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); + } - const subCase = await getSubCase({ - caseService, - savedObjectsClient, - caseId, - createdAt: createdDate, - userActionService, - user: userDetails, - }); + const userDetails: User = { + username: caseInfo.attributes.created_by?.username, + full_name: caseInfo.attributes.created_by?.full_name, + email: caseInfo.attributes.created_by?.email, + }; - const commentableCase = new CommentableCase({ - collection: caseInfo, - subCase, - soClient: savedObjectsClient, - service: caseService, - }); + const subCase = await getSubCase({ + caseService, + savedObjectsClient, + caseId, + createdAt: createdDate, + userActionService, + user: userDetails, + }); - const { - comment: newComment, - commentableCase: updatedCase, - } = await commentableCase.createComment({ createdDate, user: userDetails, commentReq: query }); - - if ( - (newComment.attributes.type === CommentType.alert || - newComment.attributes.type === CommentType.generatedAlert) && - caseInfo.attributes.settings.syncAlerts - ) { - const ids = getAlertIds(query); - await caseClient.updateAlertsStatus({ - ids, - status: subCase.attributes.status, - indices: new Set([ - ...(Array.isArray(newComment.attributes.index) - ? newComment.attributes.index - : [newComment.attributes.index]), - ]), + const commentableCase = new CommentableCase({ + logger, + collection: caseInfo, + subCase, + soClient: savedObjectsClient, + service: caseService, }); - } - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: [ - buildCommentUserActionItem({ - action: 'create', - actionAt: createdDate, - actionBy: { ...userDetails }, - caseId: updatedCase.caseId, - subCaseId: updatedCase.subCaseId, - commentId: newComment.id, - fields: ['comment'], - newValue: JSON.stringify(query), - }), - ], - }); + const { + comment: newComment, + commentableCase: updatedCase, + } = await commentableCase.createComment({ createdDate, user: userDetails, commentReq: query }); + + if ( + (newComment.attributes.type === CommentType.alert || + newComment.attributes.type === CommentType.generatedAlert) && + caseInfo.attributes.settings.syncAlerts + ) { + const ids = getAlertIds(query); + await caseClient.updateAlertsStatus({ + ids, + status: subCase.attributes.status, + indices: new Set([ + ...(Array.isArray(newComment.attributes.index) + ? newComment.attributes.index + : [newComment.attributes.index]), + ]), + }); + } + + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { ...userDetails }, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], + }); - return updatedCase.encode(); + return updatedCase.encode(); + } catch (error) { + throw createCaseError({ + message: `Failed while adding a generated alert to case id: ${caseId} error: ${error}`, + error, + logger, + }); + } }; -async function getCombinedCase( - service: CaseServiceSetup, - client: SavedObjectsClientContract, - id: string -): Promise { +async function getCombinedCase({ + service, + client, + id, + logger, +}: { + service: CaseServiceSetup; + client: SavedObjectsClientContract; + id: string; + logger: Logger; +}): Promise { const [casePromise, subCasePromise] = await Promise.allSettled([ service.getCase({ client, @@ -225,6 +244,7 @@ async function getCombinedCase( id: subCasePromise.value.references[0].id, }); return new CommentableCase({ + logger, collection: caseValue, subCase: subCasePromise.value, service, @@ -238,7 +258,12 @@ async function getCombinedCase( if (casePromise.status === 'rejected') { throw casePromise.reason; } else { - return new CommentableCase({ collection: casePromise.value, service, soClient: client }); + return new CommentableCase({ + logger, + collection: casePromise.value, + service, + soClient: client, + }); } } @@ -250,6 +275,7 @@ interface AddCommentArgs { caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; user: User; + logger: Logger; } export const addComment = async ({ @@ -260,6 +286,7 @@ export const addComment = async ({ caseId, comment, user, + logger, }: AddCommentArgs): Promise => { const query = pipe( CommentRequestRt.decode(comment), @@ -274,56 +301,70 @@ export const addComment = async ({ savedObjectsClient, userActionService, caseService, + logger, }); } decodeCommentRequest(comment); - const createdDate = new Date().toISOString(); - - const combinedCase = await getCombinedCase(caseService, savedObjectsClient, caseId); - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = user; - const userInfo: User = { - username, - full_name, - email, - }; - - const { comment: newComment, commentableCase: updatedCase } = await combinedCase.createComment({ - createdDate, - user: userInfo, - commentReq: query, - }); + try { + const createdDate = new Date().toISOString(); - if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { - const ids = getAlertIds(query); - await caseClient.updateAlertsStatus({ - ids, - status: updatedCase.status, - indices: new Set([ - ...(Array.isArray(newComment.attributes.index) - ? newComment.attributes.index - : [newComment.attributes.index]), - ]), + const combinedCase = await getCombinedCase({ + service: caseService, + client: savedObjectsClient, + id: caseId, + logger, }); - } - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: [ - buildCommentUserActionItem({ - action: 'create', - actionAt: createdDate, - actionBy: { username, full_name, email }, - caseId: updatedCase.caseId, - subCaseId: updatedCase.subCaseId, - commentId: newComment.id, - fields: ['comment'], - newValue: JSON.stringify(query), - }), - ], - }); + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const userInfo: User = { + username, + full_name, + email, + }; + + const { comment: newComment, commentableCase: updatedCase } = await combinedCase.createComment({ + createdDate, + user: userInfo, + commentReq: query, + }); + + if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { + const ids = getAlertIds(query); + await caseClient.updateAlertsStatus({ + ids, + status: updatedCase.status, + indices: new Set([ + ...(Array.isArray(newComment.attributes.index) + ? newComment.attributes.index + : [newComment.attributes.index]), + ]), + }); + } + + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], + }); - return updatedCase.encode(); + return updatedCase.encode(); + } catch (error) { + throw createCaseError({ + message: `Failed while adding a comment to case id: ${caseId} error: ${error}`, + error, + logger, + }); + } }; diff --git a/x-pack/plugins/case/server/client/configure/get_mappings.ts b/x-pack/plugins/case/server/client/configure/get_mappings.ts index 5dd90efd8a2d74..5553580a41560f 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.ts @@ -5,13 +5,14 @@ * 2.0. */ -import { SavedObjectsClientContract } from 'src/core/server'; +import { SavedObjectsClientContract, Logger } from 'src/core/server'; import { ActionsClient } from '../../../../actions/server'; import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server/saved_objects'; import { ConnectorMappingsServiceSetup } from '../../services'; import { CaseClientHandler } from '..'; +import { createCaseError } from '../../common/error'; interface GetMappingsArgs { savedObjectsClient: SavedObjectsClientContract; @@ -20,6 +21,7 @@ interface GetMappingsArgs { caseClient: CaseClientHandler; connectorType: string; connectorId: string; + logger: Logger; } export const getMappings = async ({ @@ -29,42 +31,51 @@ export const getMappings = async ({ caseClient, connectorType, connectorId, + logger, }: GetMappingsArgs): Promise => { - if (connectorType === ConnectorTypes.none) { - return []; - } - const myConnectorMappings = await connectorMappingsService.find({ - client: savedObjectsClient, - options: { - hasReference: { - type: ACTION_SAVED_OBJECT_TYPE, - id: connectorId, - }, - }, - }); - let theMapping; - // Create connector mappings if there are none - if (myConnectorMappings.total === 0) { - const res = await caseClient.getFields({ - actionsClient, - connectorId, - connectorType, - }); - theMapping = await connectorMappingsService.post({ + try { + if (connectorType === ConnectorTypes.none) { + return []; + } + const myConnectorMappings = await connectorMappingsService.find({ client: savedObjectsClient, - attributes: { - mappings: res.defaultMappings, - }, - references: [ - { + options: { + hasReference: { type: ACTION_SAVED_OBJECT_TYPE, - name: `associated-${ACTION_SAVED_OBJECT_TYPE}`, id: connectorId, }, - ], + }, + }); + let theMapping; + // Create connector mappings if there are none + if (myConnectorMappings.total === 0) { + const res = await caseClient.getFields({ + actionsClient, + connectorId, + connectorType, + }); + theMapping = await connectorMappingsService.post({ + client: savedObjectsClient, + attributes: { + mappings: res.defaultMappings, + }, + references: [ + { + type: ACTION_SAVED_OBJECT_TYPE, + name: `associated-${ACTION_SAVED_OBJECT_TYPE}`, + id: connectorId, + }, + ], + }); + } else { + theMapping = myConnectorMappings.saved_objects[0]; + } + return theMapping ? theMapping.attributes.mappings : []; + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve mapping connector id: ${connectorId} type: ${connectorType}: ${error}`, + error, + logger, }); - } else { - theMapping = myConnectorMappings.saved_objects[0]; } - return theMapping ? theMapping.attributes.mappings : []; }; diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 8a085bf29f2147..6f4b4b136f68fb 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -7,6 +7,7 @@ import { elasticsearchServiceMock, + loggingSystemMock, savedObjectsClientMock, } from '../../../../../src/core/server/mocks'; import { nullUser } from '../common'; @@ -22,6 +23,7 @@ jest.mock('./client'); import { CaseClientHandler } from './client'; import { createExternalCaseClient } from './index'; +const logger = loggingSystemMock.create().get('case'); const esClient = elasticsearchServiceMock.createElasticsearchClient(); const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); @@ -41,6 +43,7 @@ describe('createExternalCaseClient()', () => { user: nullUser, savedObjectsClient, userActionService, + logger, }); expect(CaseClientHandler).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 302745913babbd..98ffed0eaf8c5e 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -47,7 +47,6 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ }; }> => { const esClient = elasticsearchServiceMock.createElasticsearchClient(); - // const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); @@ -78,6 +77,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ userActionService, alertsService, scopedClusterClient: esClient, + logger: log, }); return { client: caseClient, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index d6a8f6b5d706cd..adc66d8b1ea77b 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server'; +import { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { CasePostRequest, @@ -76,6 +76,7 @@ export interface CaseClientFactoryArguments { savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; + logger: Logger; } export interface ConfigureFields { diff --git a/x-pack/plugins/case/server/common/error.ts b/x-pack/plugins/case/server/common/error.ts new file mode 100644 index 00000000000000..95b05fd612e602 --- /dev/null +++ b/x-pack/plugins/case/server/common/error.ts @@ -0,0 +1,73 @@ +/* + * 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 { Boom, isBoom } from '@hapi/boom'; +import { Logger } from 'src/core/server'; + +/** + * Helper class for wrapping errors while preserving the original thrown error. + */ +class CaseError extends Error { + public readonly wrappedError?: Error; + constructor(message?: string, originalError?: Error) { + super(message); + this.name = this.constructor.name; // for stack traces + if (isCaseError(originalError)) { + this.wrappedError = originalError.wrappedError; + } else { + this.wrappedError = originalError; + } + } + + /** + * This function creates a boom representation of the error. If the wrapped error is a boom we'll grab the statusCode + * and data from that. + */ + public boomify(): Boom { + const message = this.message ?? this.wrappedError?.message; + let statusCode = 500; + let data: unknown | undefined; + + if (isBoom(this.wrappedError)) { + data = this.wrappedError?.data; + statusCode = this.wrappedError?.output?.statusCode ?? 500; + } + + return new Boom(message, { + data, + statusCode, + }); + } +} + +/** + * Type guard for determining if an error is a CaseError + */ +export function isCaseError(error: unknown): error is CaseError { + return error instanceof CaseError; +} + +/** + * Create a CaseError that wraps the original thrown error. This also logs the message that will be placed in the CaseError + * if the logger was defined. + */ +export function createCaseError({ + message, + error, + logger, +}: { + message?: string; + error?: Error; + logger?: Logger; +}) { + const logMessage: string | undefined = message ?? error?.toString(); + if (logMessage !== undefined) { + logger?.error(logMessage); + } + + return new CaseError(message, error); +} diff --git a/x-pack/plugins/case/server/common/models/commentable_case.ts b/x-pack/plugins/case/server/common/models/commentable_case.ts index 3ae225999db4ee..1ff5b7beadcaf1 100644 --- a/x-pack/plugins/case/server/common/models/commentable_case.ts +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -11,6 +11,7 @@ import { SavedObjectReference, SavedObjectsClientContract, SavedObjectsUpdateResponse, + Logger, } from 'src/core/server'; import { AssociationType, @@ -35,6 +36,7 @@ import { } from '../../routes/api/utils'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; import { CaseServiceSetup } from '../../services'; +import { createCaseError } from '../error'; import { countAlertsForID } from '../index'; interface UpdateCommentResp { @@ -52,6 +54,7 @@ interface CommentableCaseParams { subCase?: SavedObject; soClient: SavedObjectsClientContract; service: CaseServiceSetup; + logger: Logger; } /** @@ -63,11 +66,14 @@ export class CommentableCase { private readonly subCase?: SavedObject; private readonly soClient: SavedObjectsClientContract; private readonly service: CaseServiceSetup; - constructor({ collection, subCase, soClient, service }: CommentableCaseParams) { + private readonly logger: Logger; + + constructor({ collection, subCase, soClient, service, logger }: CommentableCaseParams) { this.collection = collection; this.subCase = subCase; this.soClient = soClient; this.service = service; + this.logger = logger; } public get status(): CaseStatuses { @@ -119,55 +125,64 @@ export class CommentableCase { } private async update({ date, user }: { date: string; user: User }): Promise { - let updatedSubCaseAttributes: SavedObject | undefined; + try { + let updatedSubCaseAttributes: SavedObject | undefined; + + if (this.subCase) { + const updatedSubCase = await this.service.patchSubCase({ + client: this.soClient, + subCaseId: this.subCase.id, + updatedAttributes: { + updated_at: date, + updated_by: { + ...user, + }, + }, + version: this.subCase.version, + }); + + updatedSubCaseAttributes = { + ...this.subCase, + attributes: { + ...this.subCase.attributes, + ...updatedSubCase.attributes, + }, + version: updatedSubCase.version ?? this.subCase.version, + }; + } - if (this.subCase) { - const updatedSubCase = await this.service.patchSubCase({ + const updatedCase = await this.service.patchCase({ client: this.soClient, - subCaseId: this.subCase.id, + caseId: this.collection.id, updatedAttributes: { updated_at: date, - updated_by: { - ...user, - }, + updated_by: { ...user }, }, - version: this.subCase.version, + version: this.collection.version, }); - updatedSubCaseAttributes = { - ...this.subCase, - attributes: { - ...this.subCase.attributes, - ...updatedSubCase.attributes, + // this will contain the updated sub case information if the sub case was defined initially + return new CommentableCase({ + collection: { + ...this.collection, + attributes: { + ...this.collection.attributes, + ...updatedCase.attributes, + }, + version: updatedCase.version ?? this.collection.version, }, - version: updatedSubCase.version ?? this.subCase.version, - }; + subCase: updatedSubCaseAttributes, + soClient: this.soClient, + service: this.service, + logger: this.logger, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to update commentable case, sub case id: ${this.subCaseId} case id: ${this.caseId}: ${error}`, + error, + logger: this.logger, + }); } - - const updatedCase = await this.service.patchCase({ - client: this.soClient, - caseId: this.collection.id, - updatedAttributes: { - updated_at: date, - updated_by: { ...user }, - }, - version: this.collection.version, - }); - - // this will contain the updated sub case information if the sub case was defined initially - return new CommentableCase({ - collection: { - ...this.collection, - attributes: { - ...this.collection.attributes, - ...updatedCase.attributes, - }, - version: updatedCase.version ?? this.collection.version, - }, - subCase: updatedSubCaseAttributes, - soClient: this.soClient, - service: this.service, - }); } /** @@ -182,25 +197,33 @@ export class CommentableCase { updatedAt: string; user: User; }): Promise { - const { id, version, ...queryRestAttributes } = updateRequest; + try { + const { id, version, ...queryRestAttributes } = updateRequest; - const [comment, commentableCase] = await Promise.all([ - this.service.patchComment({ - client: this.soClient, - commentId: id, - updatedAttributes: { - ...queryRestAttributes, - updated_at: updatedAt, - updated_by: user, - }, - version, - }), - this.update({ date: updatedAt, user }), - ]); - return { - comment, - commentableCase, - }; + const [comment, commentableCase] = await Promise.all([ + this.service.patchComment({ + client: this.soClient, + commentId: id, + updatedAttributes: { + ...queryRestAttributes, + updated_at: updatedAt, + updated_by: user, + }, + version, + }), + this.update({ date: updatedAt, user }), + ]); + return { + comment, + commentableCase, + }; + } catch (error) { + throw createCaseError({ + message: `Failed to update comment in commentable case, sub case id: ${this.subCaseId} case id: ${this.caseId}: ${error}`, + error, + logger: this.logger, + }); + } } /** @@ -215,33 +238,41 @@ export class CommentableCase { user: User; commentReq: CommentRequest; }): Promise { - if (commentReq.type === CommentType.alert) { - if (this.status === CaseStatuses.closed) { - throw Boom.badRequest('Alert cannot be attached to a closed case'); - } + try { + if (commentReq.type === CommentType.alert) { + if (this.status === CaseStatuses.closed) { + throw Boom.badRequest('Alert cannot be attached to a closed case'); + } - if (!this.subCase && this.collection.attributes.type === CaseType.collection) { - throw Boom.badRequest('Alert cannot be attached to a collection case'); + if (!this.subCase && this.collection.attributes.type === CaseType.collection) { + throw Boom.badRequest('Alert cannot be attached to a collection case'); + } } - } - const [comment, commentableCase] = await Promise.all([ - this.service.postNewComment({ - client: this.soClient, - attributes: transformNewComment({ - associationType: this.subCase ? AssociationType.subCase : AssociationType.case, - createdDate, - ...commentReq, - ...user, + const [comment, commentableCase] = await Promise.all([ + this.service.postNewComment({ + client: this.soClient, + attributes: transformNewComment({ + associationType: this.subCase ? AssociationType.subCase : AssociationType.case, + createdDate, + ...commentReq, + ...user, + }), + references: this.buildRefsToCase(), }), - references: this.buildRefsToCase(), - }), - this.update({ date: createdDate, user }), - ]); - return { - comment, - commentableCase, - }; + this.update({ date: createdDate, user }), + ]); + return { + comment, + commentableCase, + }; + } catch (error) { + throw createCaseError({ + message: `Failed creating a comment on a commentable case, sub case id: ${this.subCaseId} case id: ${this.caseId}: ${error}`, + error, + logger: this.logger, + }); + } } private formatCollectionForEncoding(totalComment: number) { @@ -255,65 +286,74 @@ export class CommentableCase { } public async encode(): Promise { - const collectionCommentStats = await this.service.getAllCaseComments({ - client: this.soClient, - id: this.collection.id, - options: { - fields: [], - page: 1, - perPage: 1, - }, - }); + try { + const collectionCommentStats = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }); - const collectionComments = await this.service.getAllCaseComments({ - client: this.soClient, - id: this.collection.id, - options: { - fields: [], - page: 1, - perPage: collectionCommentStats.total, - }, - }); + const collectionComments = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: collectionCommentStats.total, + }, + }); - const collectionTotalAlerts = - countAlertsForID({ comments: collectionComments, id: this.collection.id }) ?? 0; + const collectionTotalAlerts = + countAlertsForID({ comments: collectionComments, id: this.collection.id }) ?? 0; - const caseResponse = { - comments: flattenCommentSavedObjects(collectionComments.saved_objects), - totalAlerts: collectionTotalAlerts, - ...this.formatCollectionForEncoding(collectionCommentStats.total), - }; + const caseResponse = { + comments: flattenCommentSavedObjects(collectionComments.saved_objects), + totalAlerts: collectionTotalAlerts, + ...this.formatCollectionForEncoding(collectionCommentStats.total), + }; - if (this.subCase) { - const subCaseComments = await this.service.getAllSubCaseComments({ - client: this.soClient, - id: this.subCase.id, - }); - const totalAlerts = countAlertsForID({ comments: subCaseComments, id: this.subCase.id }) ?? 0; + if (this.subCase) { + const subCaseComments = await this.service.getAllSubCaseComments({ + client: this.soClient, + id: this.subCase.id, + }); + const totalAlerts = + countAlertsForID({ comments: subCaseComments, id: this.subCase.id }) ?? 0; - return CaseResponseRt.encode({ - ...caseResponse, - /** - * For now we need the sub case comments and totals to be exposed on the top level of the response so that the UI - * functionality can stay the same. Ideally in the future we can refactor this so that the UI will look for the - * comments either in the top level for a case or a collection or in the subCases field if it is a sub case. - * - * If we ever need to return both the collection's comments and the sub case comments we'll need to refactor it then - * as well. - */ - comments: flattenCommentSavedObjects(subCaseComments.saved_objects), - totalComment: subCaseComments.saved_objects.length, - totalAlerts, - subCases: [ - flattenSubCaseSavedObject({ - savedObject: this.subCase, - totalComment: subCaseComments.saved_objects.length, - totalAlerts, - }), - ], + return CaseResponseRt.encode({ + ...caseResponse, + /** + * For now we need the sub case comments and totals to be exposed on the top level of the response so that the UI + * functionality can stay the same. Ideally in the future we can refactor this so that the UI will look for the + * comments either in the top level for a case or a collection or in the subCases field if it is a sub case. + * + * If we ever need to return both the collection's comments and the sub case comments we'll need to refactor it then + * as well. + */ + comments: flattenCommentSavedObjects(subCaseComments.saved_objects), + totalComment: subCaseComments.saved_objects.length, + totalAlerts, + subCases: [ + flattenSubCaseSavedObject({ + savedObject: this.subCase, + totalComment: subCaseComments.saved_objects.length, + totalAlerts, + }), + ], + }); + } + + return CaseResponseRt.encode(caseResponse); + } catch (error) { + throw createCaseError({ + message: `Failed encoding the commentable case, sub case id: ${this.subCaseId} case id: ${this.caseId}: ${error}`, + error, + logger: this.logger, }); } - - return CaseResponseRt.encode(caseResponse); } } diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index fd2fc7389f9ca9..4a1d0569bde6df 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -6,7 +6,7 @@ */ import { curry } from 'lodash'; - +import { Logger } from 'src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, @@ -26,6 +26,7 @@ import * as i18n from './translations'; import { GetActionTypeParams, isCommentGeneratedAlert, separator } from '..'; import { nullUser } from '../../common'; +import { createCaseError } from '../../common/error'; const supportedSubActions: string[] = ['create', 'update', 'addComment']; @@ -84,6 +85,7 @@ async function executor( connectorMappingsService, userActionService, alertsService, + logger, }); if (!supportedSubActions.includes(subAction)) { @@ -93,9 +95,17 @@ async function executor( } if (subAction === 'create') { - data = await caseClient.create({ - ...(subActionParams as CasePostRequest), - }); + try { + data = await caseClient.create({ + ...(subActionParams as CasePostRequest), + }); + } catch (error) { + throw createCaseError({ + message: `Failed to create a case using connector: ${error}`, + error, + logger, + }); + } } if (subAction === 'update') { @@ -107,13 +117,29 @@ async function executor( {} as CasePatchRequest ); - data = await caseClient.update({ cases: [updateParamsWithoutNullValues] }); + try { + data = await caseClient.update({ cases: [updateParamsWithoutNullValues] }); + } catch (error) { + throw createCaseError({ + message: `Failed to update case using connector id: ${updateParamsWithoutNullValues?.id} version: ${updateParamsWithoutNullValues?.version}: ${error}`, + error, + logger, + }); + } } if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - const formattedComment = transformConnectorComment(comment); - data = await caseClient.addComment({ caseId, comment: formattedComment }); + try { + const formattedComment = transformConnectorComment(comment, logger); + data = await caseClient.addComment({ caseId, comment: formattedComment }); + } catch (error) { + throw createCaseError({ + message: `Failed to create comment using connector case id: ${caseId}: ${error}`, + error, + logger, + }); + } } return { status: 'ok', data: data ?? {}, actionId }; @@ -127,7 +153,19 @@ interface AttachmentAlerts { indices: string[]; rule: { id: string | null; name: string | null }; } -export const transformConnectorComment = (comment: CommentSchemaType): CommentRequest => { + +/** + * Convert a connector style comment passed through the action plugin to the expected format for the add comment functionality. + * + * @param comment an object defining the comment to be attached to a case/sub case + * @param logger an optional logger to handle logging an error if parsing failed + * + * Note: This is exported so that the integration tests can use it. + */ +export const transformConnectorComment = ( + comment: CommentSchemaType, + logger?: Logger +): CommentRequest => { if (isCommentGeneratedAlert(comment)) { try { const genAlerts: Array<{ @@ -162,7 +200,11 @@ export const transformConnectorComment = (comment: CommentSchemaType): CommentRe rule, }; } catch (e) { - throw new Error(`Error parsing generated alert in case connector -> ${e.message}`); + throw createCaseError({ + message: `Error parsing generated alert in case connector -> ${e}`, + error: e, + logger, + }); } } else { return comment; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 1c00c26a7c0b09..43daa519584297 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -97,11 +97,13 @@ export class CasePlugin { connectorMappingsService: this.connectorMappingsService, userActionService: this.userActionService, alertsService: this.alertsService, + logger: this.log, }) ); const router = core.http.createRouter(); initCaseApi({ + logger: this.log, caseService: this.caseService, caseConfigureService: this.caseConfigureService, connectorMappingsService: this.connectorMappingsService, @@ -137,6 +139,7 @@ export class CasePlugin { connectorMappingsService: this.connectorMappingsService!, userActionService: this.userActionService!, alertsService: this.alertsService!, + logger: this.log, }); }; @@ -156,6 +159,7 @@ export class CasePlugin { connectorMappingsService, userActionService, alertsService, + logger, }: { core: CoreSetup; caseService: CaseServiceSetup; @@ -163,6 +167,7 @@ export class CasePlugin { connectorMappingsService: ConnectorMappingsServiceSetup; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; + logger: Logger; }): IContextProvider => { return async (context, request, response) => { const [{ savedObjects }] = await core.getStartServices(); @@ -178,6 +183,7 @@ export class CasePlugin { userActionService, alertsService, user, + logger, }); }, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index b4230a05749a15..6b0c4adf9a6804 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -35,6 +35,7 @@ export const createRoute = async ( postUserActions: jest.fn(), getUserActions: jest.fn(), }, + logger: log, }); return router[method].mock.calls[0][1]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 492be96fb4aa92..7f66602c61fffc 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -57,6 +57,7 @@ export const createRouteContext = async (client: any, badAuth = false) => { userActionService, alertsService, scopedClusterClient: esClient, + logger: log, }); return { context, services: { userActionService } }; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index e0b3a4420f4b5d..fd250b74fff1e3 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -13,7 +13,12 @@ import { wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; import { AssociationType } from '../../../../../common/api'; -export function initDeleteAllCommentsApi({ caseService, router, userActionService }: RouteDeps) { +export function initDeleteAllCommentsApi({ + caseService, + router, + userActionService, + logger, +}: RouteDeps) { router.delete( { path: CASE_COMMENTS_URL, @@ -70,6 +75,9 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic return response.noContent(); } catch (error) { + logger.error( + `Failed to delete all comments in route case id: ${request.params.case_id} sub case id: ${request.query?.subCaseId}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index cae0809ea5f0bf..f1c5fdc2b7cc82 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -14,7 +14,12 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; -export function initDeleteCommentApi({ caseService, router, userActionService }: RouteDeps) { +export function initDeleteCommentApi({ + caseService, + router, + userActionService, + logger, +}: RouteDeps) { router.delete( { path: CASE_COMMENT_DETAILS_URL, @@ -78,6 +83,9 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: return response.noContent(); } catch (error) { + logger.error( + `Failed to delete comment in route case id: ${request.params.case_id} comment id: ${request.params.comment_id} sub case id: ${request.query?.subCaseId}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts index 0ec0f1871c7adf..57ddd84e8742c5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -30,7 +30,7 @@ const FindQueryParamsRt = rt.partial({ subCaseId: rt.string, }); -export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { +export function initFindCaseCommentsApi({ caseService, router, logger }: RouteDeps) { router.get( { path: `${CASE_COMMENTS_URL}/_find`, @@ -82,6 +82,9 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { const theComments = await caseService.getCommentsByAssociation(args); return response.ok({ body: CommentsResponseRt.encode(transformComments(theComments)) }); } catch (error) { + logger.error( + `Failed to find comments in route case id: ${request.params.case_id}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index 8bf49ec3e27a12..770efe0109744c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -14,7 +14,7 @@ import { flattenCommentSavedObjects, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; import { defaultSortField } from '../../../../common'; -export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { +export function initGetAllCommentsApi({ caseService, router, logger }: RouteDeps) { router.get( { path: CASE_COMMENTS_URL, @@ -58,6 +58,9 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), }); } catch (error) { + logger.error( + `Failed to get all comments in route case id: ${request.params.case_id} include sub case comments: ${request.query?.includeSubCaseComments} sub case id: ${request.query?.subCaseId}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts index 6484472af0c3c1..9dedfccd3a250e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts @@ -12,7 +12,7 @@ import { RouteDeps } from '../../types'; import { flattenCommentSavedObject, wrapError } from '../../utils'; import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; -export function initGetCommentApi({ caseService, router }: RouteDeps) { +export function initGetCommentApi({ caseService, router, logger }: RouteDeps) { router.get( { path: CASE_COMMENT_DETAILS_URL, @@ -35,6 +35,9 @@ export function initGetCommentApi({ caseService, router }: RouteDeps) { body: CommentResponseRt.encode(flattenCommentSavedObject(comment)), }); } catch (error) { + logger.error( + `Failed to get comment in route case id: ${request.params.case_id} comment id: ${request.params.comment_id}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index 01b0e174640537..f5db2dc004a1d7 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -12,7 +12,7 @@ import { identity } from 'fp-ts/lib/function'; import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; -import { SavedObjectsClientContract } from 'kibana/server'; +import { SavedObjectsClientContract, Logger } from 'kibana/server'; import { CommentableCase } from '../../../../common'; import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/api'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; @@ -26,10 +26,17 @@ interface CombinedCaseParams { service: CaseServiceSetup; client: SavedObjectsClientContract; caseID: string; + logger: Logger; subCaseId?: string; } -async function getCommentableCase({ service, client, caseID, subCaseId }: CombinedCaseParams) { +async function getCommentableCase({ + service, + client, + caseID, + subCaseId, + logger, +}: CombinedCaseParams) { if (subCaseId) { const [caseInfo, subCase] = await Promise.all([ service.getCase({ @@ -41,22 +48,23 @@ async function getCommentableCase({ service, client, caseID, subCaseId }: Combin id: subCaseId, }), ]); - return new CommentableCase({ collection: caseInfo, service, subCase, soClient: client }); + return new CommentableCase({ + collection: caseInfo, + service, + subCase, + soClient: client, + logger, + }); } else { const caseInfo = await service.getCase({ client, id: caseID, }); - return new CommentableCase({ collection: caseInfo, service, soClient: client }); + return new CommentableCase({ collection: caseInfo, service, soClient: client, logger }); } } -export function initPatchCommentApi({ - caseConfigureService, - caseService, - router, - userActionService, -}: RouteDeps) { +export function initPatchCommentApi({ caseService, router, userActionService, logger }: RouteDeps) { router.patch( { path: CASE_COMMENTS_URL, @@ -88,6 +96,7 @@ export function initPatchCommentApi({ client, caseID: request.params.case_id, subCaseId: request.query?.subCaseId, + logger, }); const myComment = await caseService.getComment({ @@ -161,6 +170,9 @@ export function initPatchCommentApi({ body: await updatedCase.encode(), }); } catch (error) { + logger.error( + `Failed to patch comment in route case id: ${request.params.case_id} sub case id: ${request.query?.subCaseId}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 607f3f381f0675..b8dc43dbf3fae6 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -11,7 +11,7 @@ import { RouteDeps } from '../../types'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; import { CommentRequest } from '../../../../../common/api'; -export function initPostCommentApi({ router }: RouteDeps) { +export function initPostCommentApi({ router, logger }: RouteDeps) { router.post( { path: CASE_COMMENTS_URL, @@ -41,6 +41,9 @@ export function initPostCommentApi({ router }: RouteDeps) { body: await caseClient.addComment({ caseId, comment }), }); } catch (error) { + logger.error( + `Failed to post comment in route case id: ${request.params.case_id} sub case id: ${request.query?.subCaseId}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts index 33226d39a25957..2ca34d25482dd4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts @@ -12,7 +12,7 @@ import { wrapError } from '../../utils'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; import { transformESConnectorToCaseConnector } from '../helpers'; -export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps) { +export function initGetCaseConfigure({ caseConfigureService, router, logger }: RouteDeps) { router.get( { path: CASE_CONFIGURE_URL, @@ -63,6 +63,7 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps : {}, }); } catch (error) { + logger.error(`Failed to get case configure in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts index 0a368e0276bb5c..81ffc06355ff5e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts @@ -28,7 +28,7 @@ const isConnectorSupported = ( * Be aware that this api will only return 20 connectors */ -export function initCaseConfigureGetActionConnector({ router }: RouteDeps) { +export function initCaseConfigureGetActionConnector({ router, logger }: RouteDeps) { router.get( { path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, @@ -52,6 +52,7 @@ export function initCaseConfigureGetActionConnector({ router }: RouteDeps) { ); return response.ok({ body: results }); } catch (error) { + logger.error(`Failed to get connectors in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 02d39465373f98..cd764bb0e8a3e5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -24,7 +24,12 @@ import { transformESConnectorToCaseConnector, } from '../helpers'; -export function initPatchCaseConfigure({ caseConfigureService, caseService, router }: RouteDeps) { +export function initPatchCaseConfigure({ + caseConfigureService, + caseService, + router, + logger, +}: RouteDeps) { router.patch( { path: CASE_CONFIGURE_URL, @@ -107,6 +112,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout }), }); } catch (error) { + logger.error(`Failed to get patch configure in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index db3d5cd6a2e56e..f619a727e2e7aa 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -24,7 +24,12 @@ import { transformESConnectorToCaseConnector, } from '../helpers'; -export function initPostCaseConfigure({ caseConfigureService, caseService, router }: RouteDeps) { +export function initPostCaseConfigure({ + caseConfigureService, + caseService, + router, + logger, +}: RouteDeps) { router.post( { path: CASE_CONFIGURE_URL, @@ -96,6 +101,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route }), }); } catch (error) { + logger.error(`Failed to post case configure in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts index 497e33d7feb308..5f2a6c67220c3f 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -46,7 +46,7 @@ async function deleteSubCases({ ); } -export function initDeleteCasesApi({ caseService, router, userActionService }: RouteDeps) { +export function initDeleteCasesApi({ caseService, router, userActionService, logger }: RouteDeps) { router.delete( { path: CASES_URL, @@ -111,6 +111,9 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R return response.noContent(); } catch (error) { + logger.error( + `Failed to delete cases in route ids: ${JSON.stringify(request.query.ids)}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 8ba83b42c06d70..d04f01eb735379 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -22,7 +22,7 @@ import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; import { constructQueryOptions } from './helpers'; -export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { +export function initFindCasesApi({ caseService, router, logger }: RouteDeps) { router.get( { path: `${CASES_URL}/_find`, @@ -77,6 +77,7 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: ), }); } catch (error) { + logger.error(`Failed to find cases in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index a3311796fa5cd9..8a34e3a5b24316 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -11,7 +11,7 @@ import { RouteDeps } from '../types'; import { wrapError } from '../utils'; import { CASE_DETAILS_URL } from '../../../../common/constants'; -export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) { +export function initGetCaseApi({ router, logger }: RouteDeps) { router.get( { path: CASE_DETAILS_URL, @@ -26,10 +26,10 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro }, }, async (context, request, response) => { - const caseClient = context.case.getCaseClient(); - const id = request.params.case_id; - try { + const caseClient = context.case.getCaseClient(); + const id = request.params.case_id; + return response.ok({ body: await caseClient.get({ id, @@ -38,6 +38,9 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro }), }); } catch (error) { + logger.error( + `Failed to retrieve case in route case id: ${request.params.case_id} \ninclude comments: ${request.query.includeComments} \ninclude sub comments: ${request.query.includeSubCaseComments}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 67d4d21a576340..2bff6000d5d6a9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -10,7 +10,7 @@ import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; import { CasesPatchRequest } from '../../../../common/api'; -export function initPatchCasesApi({ router }: RouteDeps) { +export function initPatchCasesApi({ router, logger }: RouteDeps) { router.patch( { path: CASES_URL, @@ -19,18 +19,19 @@ export function initPatchCasesApi({ router }: RouteDeps) { }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } + try { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const caseClient = context.case.getCaseClient(); - const cases = request.body as CasesPatchRequest; + const caseClient = context.case.getCaseClient(); + const cases = request.body as CasesPatchRequest; - try { return response.ok({ body: await caseClient.update(cases), }); } catch (error) { + logger.error(`Failed to patch cases in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 349ed6c3e5af93..1328d958261302 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -11,7 +11,7 @@ import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; import { CasePostRequest } from '../../../../common/api'; -export function initPostCaseApi({ router }: RouteDeps) { +export function initPostCaseApi({ router, logger }: RouteDeps) { router.post( { path: CASES_URL, @@ -20,17 +20,18 @@ export function initPostCaseApi({ router }: RouteDeps) { }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } - const caseClient = context.case.getCaseClient(); - const theCase = request.body as CasePostRequest; - try { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } + const caseClient = context.case.getCaseClient(); + const theCase = request.body as CasePostRequest; + return response.ok({ body: await caseClient.create({ ...theCase }), }); } catch (error) { + logger.error(`Failed to post case in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index c1f0a2cb59cb1d..cfd2f6b9a61ad5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -16,7 +16,7 @@ import { throwErrors, CasePushRequestParamsRt } from '../../../../common/api'; import { RouteDeps } from '../types'; import { CASE_PUSH_URL } from '../../../../common/constants'; -export function initPushCaseApi({ router }: RouteDeps) { +export function initPushCaseApi({ router, logger }: RouteDeps) { router.post( { path: CASE_PUSH_URL, @@ -26,18 +26,18 @@ export function initPushCaseApi({ router }: RouteDeps) { }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } + try { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const caseClient = context.case.getCaseClient(); - const actionsClient = context.actions?.getActionsClient(); + const caseClient = context.case.getCaseClient(); + const actionsClient = context.actions?.getActionsClient(); - if (actionsClient == null) { - return response.badRequest({ body: 'Action client not found' }); - } + if (actionsClient == null) { + return response.badRequest({ body: 'Action client not found' }); + } - try { const params = pipe( CasePushRequestParamsRt.decode(request.params), fold(throwErrors(Boom.badRequest), identity) @@ -51,6 +51,7 @@ export function initPushCaseApi({ router }: RouteDeps) { }), }); } catch (error) { + logger.error(`Failed to push case in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts index 3d724ca5fc966f..e5433f49722395 100644 --- a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts +++ b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts @@ -10,7 +10,7 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_REPORTERS_URL } from '../../../../../common/constants'; -export function initGetReportersApi({ caseService, router }: RouteDeps) { +export function initGetReportersApi({ caseService, router, logger }: RouteDeps) { router.get( { path: CASE_REPORTERS_URL, @@ -24,6 +24,7 @@ export function initGetReportersApi({ caseService, router }: RouteDeps) { }); return response.ok({ body: UsersRt.encode(reporters) }); } catch (error) { + logger.error(`Failed to get reporters in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts index f3cd0e2bdda5c2..d0addfff091243 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts @@ -12,7 +12,7 @@ import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api'; import { CASE_STATUS_URL } from '../../../../../common/constants'; import { constructQueryOptions } from '../helpers'; -export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { +export function initGetCasesStatusApi({ caseService, router, logger }: RouteDeps) { router.get( { path: CASE_STATUS_URL, @@ -41,6 +41,7 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { }), }); } catch (error) { + logger.error(`Failed to get status stats in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts index db701dd0fc82b5..fd33afbd7df8ee 100644 --- a/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts @@ -13,7 +13,12 @@ import { wrapError } from '../../utils'; import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; -export function initDeleteSubCasesApi({ caseService, router, userActionService }: RouteDeps) { +export function initDeleteSubCasesApi({ + caseService, + router, + userActionService, + logger, +}: RouteDeps) { router.delete( { path: SUB_CASES_PATCH_DEL_URL, @@ -80,6 +85,9 @@ export function initDeleteSubCasesApi({ caseService, router, userActionService } return response.noContent(); } catch (error) { + logger.error( + `Failed to delete sub cases in route ids: ${JSON.stringify(request.query.ids)}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts index 98052ccaeaba8e..c24dde1944f832 100644 --- a/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts @@ -24,7 +24,7 @@ import { SUB_CASES_URL } from '../../../../../common/constants'; import { constructQueryOptions } from '../helpers'; import { defaultPage, defaultPerPage } from '../..'; -export function initFindSubCasesApi({ caseService, router }: RouteDeps) { +export function initFindSubCasesApi({ caseService, router, logger }: RouteDeps) { router.get( { path: `${SUB_CASES_URL}/_find`, @@ -88,6 +88,9 @@ export function initFindSubCasesApi({ caseService, router }: RouteDeps) { ), }); } catch (error) { + logger.error( + `Failed to find sub cases in route case id: ${request.params.case_id}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts index b6d9a7345dbdd1..32dcc924e1a083 100644 --- a/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts @@ -13,7 +13,7 @@ import { flattenSubCaseSavedObject, wrapError } from '../../utils'; import { SUB_CASE_DETAILS_URL } from '../../../../../common/constants'; import { countAlertsForID } from '../../../../common'; -export function initGetSubCaseApi({ caseService, router }: RouteDeps) { +export function initGetSubCaseApi({ caseService, router, logger }: RouteDeps) { router.get( { path: SUB_CASE_DETAILS_URL, @@ -70,6 +70,9 @@ export function initGetSubCaseApi({ caseService, router }: RouteDeps) { ), }); } catch (error) { + logger.error( + `Failed to get sub case in route case id: ${request.params.case_id} sub case id: ${request.params.sub_case_id} include comments: ${request.query?.includeComments}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts index 4b8e4920852c27..73aacc2c2b0ba4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts @@ -14,6 +14,7 @@ import { KibanaRequest, SavedObject, SavedObjectsFindResponse, + Logger, } from 'kibana/server'; import { CaseClient } from '../../../../client'; @@ -47,6 +48,7 @@ import { import { getCaseToUpdate } from '../helpers'; import { buildSubCaseUserActions } from '../../../../services/user_actions/helpers'; import { addAlertInfoToStatusMap } from '../../../../common'; +import { createCaseError } from '../../../../common/error'; interface UpdateArgs { client: SavedObjectsClientContract; @@ -55,6 +57,7 @@ interface UpdateArgs { request: KibanaRequest; caseClient: CaseClient; subCases: SubCasesPatchRequest; + logger: Logger; } function checkNonExistingOrConflict( @@ -216,41 +219,53 @@ async function updateAlerts({ caseService, client, caseClient, + logger, }: { subCasesToSync: SubCasePatchRequest[]; caseService: CaseServiceSetup; client: SavedObjectsClientContract; caseClient: CaseClient; + logger: Logger; }) { - const subCasesToSyncMap = subCasesToSync.reduce((acc, subCase) => { - acc.set(subCase.id, subCase); - return acc; - }, new Map()); - // get all the alerts for all sub cases that need to be synced - const totalAlerts = await getAlertComments({ caseService, client, subCasesToSync }); - // create a map of the status (open, closed, etc) to alert info that needs to be updated - const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { - if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { - const id = getID(alertComment); - const status = - id !== undefined - ? subCasesToSyncMap.get(id)?.status ?? CaseStatuses.open - : CaseStatuses.open; - - addAlertInfoToStatusMap({ comment: alertComment.attributes, statusMap: acc, status }); - } - return acc; - }, new Map()); - - // This does at most 3 calls to Elasticsearch to update the status of the alerts to either open, closed, or in-progress - for (const [status, alertInfo] of alertsToUpdate.entries()) { - if (alertInfo.ids.length > 0 && alertInfo.indices.size > 0) { - caseClient.updateAlertsStatus({ - ids: alertInfo.ids, - status, - indices: alertInfo.indices, - }); + try { + const subCasesToSyncMap = subCasesToSync.reduce((acc, subCase) => { + acc.set(subCase.id, subCase); + return acc; + }, new Map()); + // get all the alerts for all sub cases that need to be synced + const totalAlerts = await getAlertComments({ caseService, client, subCasesToSync }); + // create a map of the status (open, closed, etc) to alert info that needs to be updated + const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { + const id = getID(alertComment); + const status = + id !== undefined + ? subCasesToSyncMap.get(id)?.status ?? CaseStatuses.open + : CaseStatuses.open; + + addAlertInfoToStatusMap({ comment: alertComment.attributes, statusMap: acc, status }); + } + return acc; + }, new Map()); + + // This does at most 3 calls to Elasticsearch to update the status of the alerts to either open, closed, or in-progress + for (const [status, alertInfo] of alertsToUpdate.entries()) { + if (alertInfo.ids.length > 0 && alertInfo.indices.size > 0) { + caseClient.updateAlertsStatus({ + ids: alertInfo.ids, + status, + indices: alertInfo.indices, + }); + } } + } catch (error) { + throw createCaseError({ + message: `Failed to update alert status while updating sub cases: ${JSON.stringify( + subCasesToSync + )}: ${error}`, + logger, + error, + }); } } @@ -261,133 +276,152 @@ async function update({ request, caseClient, subCases, + logger, }: UpdateArgs): Promise { const query = pipe( excess(SubCasesPatchRequestRt).decode(subCases), fold(throwErrors(Boom.badRequest), identity) ); - const bulkSubCases = await caseService.getSubCases({ - client, - ids: query.subCases.map((q) => q.id), - }); + try { + const bulkSubCases = await caseService.getSubCases({ + client, + ids: query.subCases.map((q) => q.id), + }); - const subCasesMap = bulkSubCases.saved_objects.reduce((acc, so) => { - acc.set(so.id, so); - return acc; - }, new Map>()); + const subCasesMap = bulkSubCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); - checkNonExistingOrConflict(query.subCases, subCasesMap); + checkNonExistingOrConflict(query.subCases, subCasesMap); - const nonEmptySubCaseRequests = getValidUpdateRequests(query.subCases, subCasesMap); + const nonEmptySubCaseRequests = getValidUpdateRequests(query.subCases, subCasesMap); - if (nonEmptySubCaseRequests.length <= 0) { - throw Boom.notAcceptable('All update fields are identical to current version.'); - } + if (nonEmptySubCaseRequests.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } - const subIDToParentCase = await getParentCases({ - client, - caseService, - subCaseIDs: nonEmptySubCaseRequests.map((subCase) => subCase.id), - subCasesMap, - }); + const subIDToParentCase = await getParentCases({ + client, + caseService, + subCaseIDs: nonEmptySubCaseRequests.map((subCase) => subCase.id), + subCasesMap, + }); - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const updatedAt = new Date().toISOString(); - const updatedCases = await caseService.patchSubCases({ - client, - subCases: nonEmptySubCaseRequests.map((thisCase) => { - const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; - let closedInfo: { closed_at: string | null; closed_by: User | null } = { - closed_at: null, - closed_by: null, - }; - - if ( - updateSubCaseAttributes.status && - updateSubCaseAttributes.status === CaseStatuses.closed - ) { - closedInfo = { - closed_at: updatedAt, - closed_by: { email, full_name, username }, - }; - } else if ( - updateSubCaseAttributes.status && - (updateSubCaseAttributes.status === CaseStatuses.open || - updateSubCaseAttributes.status === CaseStatuses['in-progress']) - ) { - closedInfo = { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = await caseService.getUser({ request }); + const updatedAt = new Date().toISOString(); + const updatedCases = await caseService.patchSubCases({ + client, + subCases: nonEmptySubCaseRequests.map((thisCase) => { + const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; + let closedInfo: { closed_at: string | null; closed_by: User | null } = { closed_at: null, closed_by: null, }; - } - return { - subCaseId, - updatedAttributes: { - ...updateSubCaseAttributes, - ...closedInfo, - updated_at: updatedAt, - updated_by: { email, full_name, username }, - }, - version, - }; - }), - }); - const subCasesToSyncAlertsFor = nonEmptySubCaseRequests.filter((subCaseToUpdate) => { - const storedSubCase = subCasesMap.get(subCaseToUpdate.id); - const parentCase = subIDToParentCase.get(subCaseToUpdate.id); - return ( - storedSubCase !== undefined && - subCaseToUpdate.status !== undefined && - storedSubCase.attributes.status !== subCaseToUpdate.status && - parentCase?.attributes.settings.syncAlerts - ); - }); + if ( + updateSubCaseAttributes.status && + updateSubCaseAttributes.status === CaseStatuses.closed + ) { + closedInfo = { + closed_at: updatedAt, + closed_by: { email, full_name, username }, + }; + } else if ( + updateSubCaseAttributes.status && + (updateSubCaseAttributes.status === CaseStatuses.open || + updateSubCaseAttributes.status === CaseStatuses['in-progress']) + ) { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } + return { + subCaseId, + updatedAttributes: { + ...updateSubCaseAttributes, + ...closedInfo, + updated_at: updatedAt, + updated_by: { email, full_name, username }, + }, + version, + }; + }), + }); - await updateAlerts({ - caseService, - client, - caseClient, - subCasesToSync: subCasesToSyncAlertsFor, - }); + const subCasesToSyncAlertsFor = nonEmptySubCaseRequests.filter((subCaseToUpdate) => { + const storedSubCase = subCasesMap.get(subCaseToUpdate.id); + const parentCase = subIDToParentCase.get(subCaseToUpdate.id); + return ( + storedSubCase !== undefined && + subCaseToUpdate.status !== undefined && + storedSubCase.attributes.status !== subCaseToUpdate.status && + parentCase?.attributes.settings.syncAlerts + ); + }); - const returnUpdatedSubCases = updatedCases.saved_objects.reduce( - (acc, updatedSO) => { - const originalSubCase = subCasesMap.get(updatedSO.id); - if (originalSubCase) { - acc.push( - flattenSubCaseSavedObject({ - savedObject: { - ...originalSubCase, - ...updatedSO, - attributes: { ...originalSubCase.attributes, ...updatedSO.attributes }, - references: originalSubCase.references, - version: updatedSO.version ?? originalSubCase.version, - }, - }) - ); - } - return acc; - }, - [] - ); + await updateAlerts({ + caseService, + client, + caseClient, + subCasesToSync: subCasesToSyncAlertsFor, + logger, + }); - await userActionService.postUserActions({ - client, - actions: buildSubCaseUserActions({ - originalSubCases: bulkSubCases.saved_objects, - updatedSubCases: updatedCases.saved_objects, - actionDate: updatedAt, - actionBy: { email, full_name, username }, - }), - }); + const returnUpdatedSubCases = updatedCases.saved_objects.reduce( + (acc, updatedSO) => { + const originalSubCase = subCasesMap.get(updatedSO.id); + if (originalSubCase) { + acc.push( + flattenSubCaseSavedObject({ + savedObject: { + ...originalSubCase, + ...updatedSO, + attributes: { ...originalSubCase.attributes, ...updatedSO.attributes }, + references: originalSubCase.references, + version: updatedSO.version ?? originalSubCase.version, + }, + }) + ); + } + return acc; + }, + [] + ); + + await userActionService.postUserActions({ + client, + actions: buildSubCaseUserActions({ + originalSubCases: bulkSubCases.saved_objects, + updatedSubCases: updatedCases.saved_objects, + actionDate: updatedAt, + actionBy: { email, full_name, username }, + }), + }); - return SubCasesResponseRt.encode(returnUpdatedSubCases); + return SubCasesResponseRt.encode(returnUpdatedSubCases); + } catch (error) { + const idVersions = query.subCases.map((subCase) => ({ + id: subCase.id, + version: subCase.version, + })); + throw createCaseError({ + message: `Failed to update sub cases: ${JSON.stringify(idVersions)}: ${error}`, + error, + logger, + }); + } } -export function initPatchSubCasesApi({ router, caseService, userActionService }: RouteDeps) { +export function initPatchSubCasesApi({ + router, + caseService, + userActionService, + logger, +}: RouteDeps) { router.patch( { path: SUB_CASES_PATCH_DEL_URL, @@ -396,10 +430,10 @@ export function initPatchSubCasesApi({ router, caseService, userActionService }: }, }, async (context, request, response) => { - const caseClient = context.case.getCaseClient(); - const subCases = request.body as SubCasesPatchRequest; - try { + const caseClient = context.case.getCaseClient(); + const subCases = request.body as SubCasesPatchRequest; + return response.ok({ body: await update({ request, @@ -408,9 +442,11 @@ export function initPatchSubCasesApi({ router, caseService, userActionService }: client: context.core.savedObjects.client, caseService, userActionService, + logger, }), }); } catch (error) { + logger.error(`Failed to patch sub cases in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts index 488f32a795811f..2efef9ac67f80b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -11,7 +11,7 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_USER_ACTIONS_URL, SUB_CASE_USER_ACTIONS_URL } from '../../../../../common/constants'; -export function initGetAllCaseUserActionsApi({ router }: RouteDeps) { +export function initGetAllCaseUserActionsApi({ router, logger }: RouteDeps) { router.get( { path: CASE_USER_ACTIONS_URL, @@ -22,25 +22,28 @@ export function initGetAllCaseUserActionsApi({ router }: RouteDeps) { }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } + try { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const caseClient = context.case.getCaseClient(); - const caseId = request.params.case_id; + const caseClient = context.case.getCaseClient(); + const caseId = request.params.case_id; - try { return response.ok({ body: await caseClient.getUserActions({ caseId }), }); } catch (error) { + logger.error( + `Failed to retrieve case user actions in route case id: ${request.params.case_id}: ${error}` + ); return response.customError(wrapError(error)); } } ); } -export function initGetAllSubCaseUserActionsApi({ router }: RouteDeps) { +export function initGetAllSubCaseUserActionsApi({ router, logger }: RouteDeps) { router.get( { path: SUB_CASE_USER_ACTIONS_URL, @@ -52,19 +55,22 @@ export function initGetAllSubCaseUserActionsApi({ router }: RouteDeps) { }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } + try { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const caseClient = context.case.getCaseClient(); - const caseId = request.params.case_id; - const subCaseId = request.params.sub_case_id; + const caseClient = context.case.getCaseClient(); + const caseId = request.params.case_id; + const subCaseId = request.params.sub_case_id; - try { return response.ok({ body: await caseClient.getUserActions({ caseId, subCaseId }), }); } catch (error) { + logger.error( + `Failed to retrieve sub case user actions in route case id: ${request.params.case_id} sub case id: ${request.params.sub_case_id}: ${error}` + ); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index 395880e5c14101..6ce40e01c77520 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { Logger } from 'kibana/server'; + import type { CaseConfigureServiceSetup, CaseServiceSetup, @@ -20,6 +22,7 @@ export interface RouteDeps { connectorMappingsService: ConnectorMappingsServiceSetup; router: CasesRouter; userActionService: CaseUserActionServiceSetup; + logger: Logger; } export enum SortFieldCase { diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 084b1a17a1434a..298f8bb877cda5 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -6,7 +6,7 @@ */ import { isEmpty } from 'lodash'; -import { badRequest, boomify, isBoom } from '@hapi/boom'; +import { badRequest, Boom, boomify, isBoom } from '@hapi/boom'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -45,6 +45,7 @@ import { import { transformESConnectorToCaseConnector } from './cases/helpers'; import { SortFieldCase } from './types'; +import { isCaseError } from '../../common/error'; export const transformNewSubCase = ({ createdAt, @@ -182,9 +183,19 @@ export const transformNewComment = ({ }; }; +/** + * Transforms an error into the correct format for a kibana response. + */ export function wrapError(error: any): CustomHttpResponseOptions { - const options = { statusCode: error.statusCode ?? 500 }; - const boom = isBoom(error) ? error : boomify(error, options); + let boom: Boom; + + if (isCaseError(error)) { + boom = error.boomify(); + } else { + const options = { statusCode: error.statusCode ?? 500 }; + boom = isBoom(error) ? error : boomify(error, options); + } + return { body: boom, headers: boom.output.headers as { [key: string]: string }, diff --git a/x-pack/plugins/case/server/services/alerts/index.test.ts b/x-pack/plugins/case/server/services/alerts/index.test.ts index 35aa3ff80efc1e..3b1020d3ef5569 100644 --- a/x-pack/plugins/case/server/services/alerts/index.test.ts +++ b/x-pack/plugins/case/server/services/alerts/index.test.ts @@ -8,10 +8,11 @@ import { KibanaRequest } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; import { AlertService, AlertServiceContract } from '.'; -import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; describe('updateAlertsStatus', () => { const esClient = elasticsearchServiceMock.createElasticsearchClient(); + const logger = loggingSystemMock.create().get('case'); describe('happy path', () => { let alertService: AlertServiceContract; @@ -21,6 +22,7 @@ describe('updateAlertsStatus', () => { request: {} as KibanaRequest, status: CaseStatuses.closed, scopedClusterClient: esClient, + logger, }; beforeEach(async () => { @@ -50,6 +52,7 @@ describe('updateAlertsStatus', () => { status: CaseStatuses.closed, indices: new Set(['']), scopedClusterClient: esClient, + logger, }) ).toBeUndefined(); }); diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index a19e533418bc9a..45245b86ba2d51 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -9,9 +9,10 @@ import _ from 'lodash'; import type { PublicMethodsOf } from '@kbn/utility-types'; -import { ElasticsearchClient } from 'kibana/server'; +import { ElasticsearchClient, Logger } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; import { MAX_ALERTS_PER_SUB_CASE } from '../../../common/constants'; +import { createCaseError } from '../../common/error'; export type AlertServiceContract = PublicMethodsOf; @@ -20,12 +21,14 @@ interface UpdateAlertsStatusArgs { status: CaseStatuses; indices: Set; scopedClusterClient: ElasticsearchClient; + logger: Logger; } interface GetAlertsArgs { ids: string[]; indices: Set; scopedClusterClient: ElasticsearchClient; + logger: Logger; } interface Alert { @@ -57,56 +60,75 @@ export class AlertService { status, indices, scopedClusterClient, + logger, }: UpdateAlertsStatusArgs) { const sanitizedIndices = getValidIndices(indices); if (sanitizedIndices.length <= 0) { - // log that we only had invalid indices + logger.warn(`Empty alert indices when updateAlertsStatus ids: ${JSON.stringify(ids)}`); return; } - const result = await scopedClusterClient.updateByQuery({ - index: sanitizedIndices, - conflicts: 'abort', - body: { - script: { - source: `ctx._source.signal.status = '${status}'`, - lang: 'painless', + try { + const result = await scopedClusterClient.updateByQuery({ + index: sanitizedIndices, + conflicts: 'abort', + body: { + script: { + source: `ctx._source.signal.status = '${status}'`, + lang: 'painless', + }, + query: { ids: { values: ids } }, }, - query: { ids: { values: ids } }, - }, - ignore_unavailable: true, - }); - - return result; + ignore_unavailable: true, + }); + + return result; + } catch (error) { + throw createCaseError({ + message: `Failed to update alert status ids: ${JSON.stringify(ids)}: ${error}`, + error, + logger, + }); + } } public async getAlerts({ scopedClusterClient, ids, indices, + logger, }: GetAlertsArgs): Promise { const index = getValidIndices(indices); if (index.length <= 0) { + logger.warn(`Empty alert indices when retrieving alerts ids: ${JSON.stringify(ids)}`); return; } - const result = await scopedClusterClient.search({ - index, - body: { - query: { - bool: { - filter: { - ids: { - values: ids, + try { + const result = await scopedClusterClient.search({ + index, + body: { + query: { + bool: { + filter: { + ids: { + values: ids, + }, }, }, }, }, - }, - size: MAX_ALERTS_PER_SUB_CASE, - ignore_unavailable: true, - }); - - return result.body; + size: MAX_ALERTS_PER_SUB_CASE, + ignore_unavailable: true, + }); + + return result.body; + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve alerts ids: ${JSON.stringify(ids)}: ${error}`, + error, + logger, + }); + } } } diff --git a/x-pack/plugins/case/server/services/connector_mappings/index.ts b/x-pack/plugins/case/server/services/connector_mappings/index.ts index cc387d8e6fe3ee..d4fda10276d2b5 100644 --- a/x-pack/plugins/case/server/services/connector_mappings/index.ts +++ b/x-pack/plugins/case/server/services/connector_mappings/index.ts @@ -41,7 +41,7 @@ export class ConnectorMappingsService { this.log.debug(`Attempting to find all connector mappings`); return await client.find({ ...options, type: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT }); } catch (error) { - this.log.debug(`Attempting to find all connector mappings`); + this.log.error(`Attempting to find all connector mappings: ${error}`); throw error; } }, @@ -52,7 +52,7 @@ export class ConnectorMappingsService { references, }); } catch (error) { - this.log.debug(`Error on POST a new connector mappings: ${error}`); + this.log.error(`Error on POST a new connector mappings: ${error}`); throw error; } }, diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index a9e5c269608308..f74e91ca102243 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -621,7 +621,7 @@ export class CaseService implements CaseServiceSetup { ], }); } catch (error) { - this.log.debug(`Error on POST a new sub case: ${error}`); + this.log.error(`Error on POST a new sub case for id ${caseId}: ${error}`); throw error; } } @@ -642,7 +642,7 @@ export class CaseService implements CaseServiceSetup { return subCases.saved_objects[0]; } catch (error) { - this.log.debug(`Error finding the most recent sub case for case: ${caseId}`); + this.log.error(`Error finding the most recent sub case for case: ${caseId}: ${error}`); throw error; } } @@ -652,7 +652,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to DELETE sub case ${id}`); return await client.delete(SUB_CASE_SAVED_OBJECT, id); } catch (error) { - this.log.debug(`Error on DELETE sub case ${id}: ${error}`); + this.log.error(`Error on DELETE sub case ${id}: ${error}`); throw error; } } @@ -662,7 +662,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to DELETE case ${caseId}`); return await client.delete(CASE_SAVED_OBJECT, caseId); } catch (error) { - this.log.debug(`Error on DELETE case ${caseId}: ${error}`); + this.log.error(`Error on DELETE case ${caseId}: ${error}`); throw error; } } @@ -671,7 +671,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET comment ${commentId}`); return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); } catch (error) { - this.log.debug(`Error on GET comment ${commentId}: ${error}`); + this.log.error(`Error on GET comment ${commentId}: ${error}`); throw error; } } @@ -683,7 +683,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET case ${caseId}`); return await client.get(CASE_SAVED_OBJECT, caseId); } catch (error) { - this.log.debug(`Error on GET case ${caseId}: ${error}`); + this.log.error(`Error on GET case ${caseId}: ${error}`); throw error; } } @@ -692,7 +692,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET sub case ${id}`); return await client.get(SUB_CASE_SAVED_OBJECT, id); } catch (error) { - this.log.debug(`Error on GET sub case ${id}: ${error}`); + this.log.error(`Error on GET sub case ${id}: ${error}`); throw error; } } @@ -705,7 +705,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET sub cases ${ids.join(', ')}`); return await client.bulkGet(ids.map((id) => ({ type: SUB_CASE_SAVED_OBJECT, id }))); } catch (error) { - this.log.debug(`Error on GET cases ${ids.join(', ')}: ${error}`); + this.log.error(`Error on GET cases ${ids.join(', ')}: ${error}`); throw error; } } @@ -720,7 +720,7 @@ export class CaseService implements CaseServiceSetup { caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) ); } catch (error) { - this.log.debug(`Error on GET cases ${caseIds.join(', ')}: ${error}`); + this.log.error(`Error on GET cases ${caseIds.join(', ')}: ${error}`); throw error; } } @@ -732,7 +732,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET comment ${commentId}`); return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); } catch (error) { - this.log.debug(`Error on GET comment ${commentId}: ${error}`); + this.log.error(`Error on GET comment ${commentId}: ${error}`); throw error; } } @@ -749,7 +749,7 @@ export class CaseService implements CaseServiceSetup { type: CASE_SAVED_OBJECT, }); } catch (error) { - this.log.debug(`Error on find cases: ${error}`); + this.log.error(`Error on find cases: ${error}`); throw error; } } @@ -786,7 +786,7 @@ export class CaseService implements CaseServiceSetup { type: SUB_CASE_SAVED_OBJECT, }); } catch (error) { - this.log.debug(`Error on find sub cases: ${error}`); + this.log.error(`Error on find sub cases: ${error}`); throw error; } } @@ -824,7 +824,7 @@ export class CaseService implements CaseServiceSetup { }, }); } catch (error) { - this.log.debug( + this.log.error( `Error on GET all sub cases for case collection id ${ids.join(', ')}: ${error}` ); throw error; @@ -847,7 +847,7 @@ export class CaseService implements CaseServiceSetup { options, }: FindCommentsArgs): Promise> { try { - this.log.debug(`Attempting to GET all comments for id ${id}`); + this.log.debug(`Attempting to GET all comments for id ${JSON.stringify(id)}`); if (options?.page !== undefined || options?.perPage !== undefined) { return client.find({ type: CASE_COMMENT_SAVED_OBJECT, @@ -874,7 +874,7 @@ export class CaseService implements CaseServiceSetup { ...options, }); } catch (error) { - this.log.debug(`Error on GET all comments for ${id}: ${error}`); + this.log.error(`Error on GET all comments for ${JSON.stringify(id)}: ${error}`); throw error; } } @@ -915,7 +915,7 @@ export class CaseService implements CaseServiceSetup { ); } - this.log.debug(`Attempting to GET all comments for case caseID ${id}`); + this.log.debug(`Attempting to GET all comments for case caseID ${JSON.stringify(id)}`); return this.getAllComments({ client, id, @@ -927,7 +927,7 @@ export class CaseService implements CaseServiceSetup { }, }); } catch (error) { - this.log.debug(`Error on GET all comments for case ${id}: ${error}`); + this.log.error(`Error on GET all comments for case ${JSON.stringify(id)}: ${error}`); throw error; } } @@ -948,7 +948,7 @@ export class CaseService implements CaseServiceSetup { }; } - this.log.debug(`Attempting to GET all comments for sub case caseID ${id}`); + this.log.debug(`Attempting to GET all comments for sub case caseID ${JSON.stringify(id)}`); return this.getAllComments({ client, id, @@ -959,7 +959,7 @@ export class CaseService implements CaseServiceSetup { }, }); } catch (error) { - this.log.debug(`Error on GET all comments for sub case ${id}: ${error}`); + this.log.error(`Error on GET all comments for sub case ${JSON.stringify(id)}: ${error}`); throw error; } } @@ -969,7 +969,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET all reporters`); return await readReporters({ client }); } catch (error) { - this.log.debug(`Error on GET all reporters: ${error}`); + this.log.error(`Error on GET all reporters: ${error}`); throw error; } } @@ -978,7 +978,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to GET all cases`); return await readTags({ client }); } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); + this.log.error(`Error on GET cases: ${error}`); throw error; } } @@ -1003,7 +1003,7 @@ export class CaseService implements CaseServiceSetup { email: null, }; } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); + this.log.error(`Error on GET cases: ${error}`); throw error; } } @@ -1012,7 +1012,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to POST a new case`); return await client.create(CASE_SAVED_OBJECT, { ...attributes }); } catch (error) { - this.log.debug(`Error on POST a new case: ${error}`); + this.log.error(`Error on POST a new case: ${error}`); throw error; } } @@ -1021,7 +1021,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to POST a new comment`); return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); } catch (error) { - this.log.debug(`Error on POST a new comment: ${error}`); + this.log.error(`Error on POST a new comment: ${error}`); throw error; } } @@ -1030,7 +1030,7 @@ export class CaseService implements CaseServiceSetup { this.log.debug(`Attempting to UPDATE case ${caseId}`); return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }, { version }); } catch (error) { - this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); + this.log.error(`Error on UPDATE case ${caseId}: ${error}`); throw error; } } @@ -1046,7 +1046,7 @@ export class CaseService implements CaseServiceSetup { })) ); } catch (error) { - this.log.debug(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); + this.log.error(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); throw error; } } @@ -1062,7 +1062,7 @@ export class CaseService implements CaseServiceSetup { { version } ); } catch (error) { - this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); + this.log.error(`Error on UPDATE comment ${commentId}: ${error}`); throw error; } } @@ -1080,7 +1080,7 @@ export class CaseService implements CaseServiceSetup { })) ); } catch (error) { - this.log.debug( + this.log.error( `Error on UPDATE comments ${comments.map((c) => c.commentId).join(', ')}: ${error}` ); throw error; @@ -1096,7 +1096,7 @@ export class CaseService implements CaseServiceSetup { { version } ); } catch (error) { - this.log.debug(`Error on UPDATE sub case ${subCaseId}: ${error}`); + this.log.error(`Error on UPDATE sub case ${subCaseId}: ${error}`); throw error; } } @@ -1115,7 +1115,7 @@ export class CaseService implements CaseServiceSetup { })) ); } catch (error) { - this.log.debug( + this.log.error( `Error on UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}: ${error}` ); throw error; diff --git a/x-pack/plugins/case/server/services/user_actions/index.ts b/x-pack/plugins/case/server/services/user_actions/index.ts index d05ada0dba30cd..785c81021b584f 100644 --- a/x-pack/plugins/case/server/services/user_actions/index.ts +++ b/x-pack/plugins/case/server/services/user_actions/index.ts @@ -66,7 +66,7 @@ export class CaseUserActionService { sortOrder: 'asc', }); } catch (error) { - this.log.debug(`Error on GET case user action: ${error}`); + this.log.error(`Error on GET case user action case id: ${caseId}: ${error}`); throw error; } }, @@ -77,7 +77,7 @@ export class CaseUserActionService { actions.map((action) => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })) ); } catch (error) { - this.log.debug(`Error on POST a new case user action: ${error}`); + this.log.error(`Error on POST a new case user action: ${error}`); throw error; } },