+
{i18n.translate('visTypeTimeseries.indexPatternSelect.switchModePopover.title', {
defaultMessage: 'Index pattern selection mode',
@@ -59,7 +104,10 @@ export const SwitchModePopover = ({ onModeChange, useKibanaIndices }: PopoverPro
@@ -68,10 +116,11 @@ export const SwitchModePopover = ({ onModeChange, useKibanaIndices }: PopoverPro
label={i18n.translate(
'visTypeTimeseries.indexPatternSelect.switchModePopover.useKibanaIndices',
{
- defaultMessage: 'Use only Kibana index patterns',
+ defaultMessage: 'Use only index patterns',
}
)}
onChange={switchMode}
+ disabled={isSwitchDisabled}
data-test-subj="switchIndexPatternSelectionMode"
/>
diff --git a/src/plugins/vis_types/timeseries/public/application/components/use_index_patter_mode_callout.tsx b/src/plugins/vis_types/timeseries/public/application/components/use_index_patter_mode_callout.tsx
index 6191df2ecce5b..9684b7b7ff356 100644
--- a/src/plugins/vis_types/timeseries/public/application/components/use_index_patter_mode_callout.tsx
+++ b/src/plugins/vis_types/timeseries/public/application/components/use_index_patter_mode_callout.tsx
@@ -43,7 +43,7 @@ export const UseIndexPatternModeCallout = () => {
diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_types/timeseries/vis.js b/src/plugins/vis_types/timeseries/public/application/components/vis_types/timeseries/vis.js
index 75a8f11e640df..b4fe39c522de7 100644
--- a/src/plugins/vis_types/timeseries/public/application/components/vis_types/timeseries/vis.js
+++ b/src/plugins/vis_types/timeseries/public/application/components/vis_types/timeseries/vis.js
@@ -38,6 +38,8 @@ class TimeseriesVisualization extends Component {
scaledDataFormat = this.props.getConfig('dateFormat:scaled');
dateFormat = this.props.getConfig('dateFormat');
+ yAxisIdGenerator = htmlIdGenerator('yaxis');
+
xAxisFormatter = (interval) => {
const formatter = createIntervalBasedFormatter(
interval,
@@ -165,8 +167,7 @@ class TimeseriesVisualization extends Component {
} = this.props;
const series = get(visData, `${model.id}.series`, []);
const interval = getInterval(visData, model);
- const yAxisIdGenerator = htmlIdGenerator('yaxis');
- const mainAxisGroupId = yAxisIdGenerator('main_group');
+ const mainAxisGroupId = this.yAxisIdGenerator('main_group');
const seriesModel = model.series.filter((s) => !s.hidden).map((s) => cloneDeep(s));
@@ -226,7 +227,7 @@ class TimeseriesVisualization extends Component {
TimeseriesVisualization.addYAxis(yAxis, {
domain,
groupId,
- id: yAxisIdGenerator(seriesGroup.id),
+ id: this.yAxisIdGenerator(seriesGroup.id),
position: seriesGroup.axis_position,
hide: isStackedWithinSeries,
tickFormatter:
@@ -241,7 +242,7 @@ class TimeseriesVisualization extends Component {
TimeseriesVisualization.addYAxis(yAxis, {
tickFormatter,
- id: yAxisIdGenerator('main'),
+ id: this.yAxisIdGenerator('main'),
groupId: mainAxisGroupId,
position: model.axis_position,
domain: mainAxisDomain,
diff --git a/src/plugins/vis_types/timeseries/server/index.ts b/src/plugins/vis_types/timeseries/server/index.ts
index a78ddade30965..7a10740a53d32 100644
--- a/src/plugins/vis_types/timeseries/server/index.ts
+++ b/src/plugins/vis_types/timeseries/server/index.ts
@@ -13,20 +13,6 @@ import { VisTypeTimeseriesPlugin } from './plugin';
export { VisTypeTimeseriesSetup } from './plugin';
export const config: PluginConfigDescriptor = {
- deprecations: ({ unused, renameFromRoot }) => [
- // In Kibana v7.8 plugin id was renamed from 'metrics' to 'vis_type_timeseries':
- renameFromRoot('metrics.enabled', 'vis_type_timeseries.enabled', { silent: true }),
- renameFromRoot('metrics.chartResolution', 'vis_type_timeseries.chartResolution', {
- silent: true,
- }),
- renameFromRoot('metrics.minimumBucketSize', 'vis_type_timeseries.minimumBucketSize', {
- silent: true,
- }),
-
- // Unused properties which should be removed after releasing Kibana v8.0:
- unused('chartResolution'),
- unused('minimumBucketSize'),
- ],
schema: configSchema,
};
diff --git a/src/plugins/vis_types/timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_types/timeseries/server/lib/get_vis_data.ts
index bc4fbf9159a00..a76132e0fbd21 100644
--- a/src/plugins/vis_types/timeseries/server/lib/get_vis_data.ts
+++ b/src/plugins/vis_types/timeseries/server/lib/get_vis_data.ts
@@ -20,8 +20,8 @@ import { getSeriesData } from './vis_data/get_series_data';
import { getTableData } from './vis_data/get_table_data';
import { getEsQueryConfig } from './vis_data/helpers/get_es_query_uisettings';
import { getCachedIndexPatternFetcher } from './search_strategies/lib/cached_index_pattern_fetcher';
-import { MAX_BUCKETS_SETTING } from '../../common/constants';
import { getIntervalAndTimefield } from './vis_data/get_interval_and_timefield';
+import { UI_SETTINGS } from '../../common/constants';
export async function getVisData(
requestContext: VisTypeTimeseriesRequestHandlerContext,
@@ -57,7 +57,7 @@ export async function getVisData(
index = await cachedIndexPatternFetcher(index.indexPatternString, true);
}
- const maxBuckets = await uiSettings.get(MAX_BUCKETS_SETTING);
+ const maxBuckets = await uiSettings.get(UI_SETTINGS.MAX_BUCKETS_SETTING);
const { min, max } = request.body.timerange;
return getIntervalAndTimefield(
diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts
index 0fa92b5f061fa..ff1c3c0ac71ee 100644
--- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts
+++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts
@@ -15,7 +15,7 @@ import type {
VisTypeTimeseriesRequestHandlerContext,
VisTypeTimeseriesRequest,
} from '../../../types';
-import { MAX_BUCKETS_SETTING } from '../../../../common/constants';
+import { UI_SETTINGS } from '../../../../common/constants';
export class DefaultSearchStrategy extends AbstractSearchStrategy {
async checkForViability(
@@ -29,7 +29,7 @@ export class DefaultSearchStrategy extends AbstractSearchStrategy {
capabilities: new DefaultSearchCapabilities({
panel: req.body.panels ? req.body.panels[0] : null,
timezone: req.body.timerange?.timezone,
- maxBucketsLimit: await uiSettings.get(MAX_BUCKETS_SETTING),
+ maxBucketsLimit: await uiSettings.get(UI_SETTINGS.MAX_BUCKETS_SETTING),
}),
};
}
diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts
index 903e7f239f824..e3ede57774224 100644
--- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts
+++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts
@@ -20,7 +20,7 @@ import type {
VisTypeTimeseriesRequestHandlerContext,
VisTypeTimeseriesVisDataRequest,
} from '../../../types';
-import { MAX_BUCKETS_SETTING } from '../../../../common/constants';
+import { UI_SETTINGS } from '../../../../common/constants';
const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData);
const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*');
@@ -75,7 +75,7 @@ export class RollupSearchStrategy extends AbstractSearchStrategy {
capabilities = new RollupSearchCapabilities(
{
- maxBucketsLimit: await uiSettings.get(MAX_BUCKETS_SETTING),
+ maxBucketsLimit: await uiSettings.get(UI_SETTINGS.MAX_BUCKETS_SETTING),
panel: req.body.panels ? req.body.panels[0] : null,
},
fieldsCapabilities,
diff --git a/src/plugins/vis_types/timeseries/server/ui_settings.ts b/src/plugins/vis_types/timeseries/server/ui_settings.ts
index e61635058cee0..2adbc31482f04 100644
--- a/src/plugins/vis_types/timeseries/server/ui_settings.ts
+++ b/src/plugins/vis_types/timeseries/server/ui_settings.ts
@@ -10,11 +10,10 @@ import { i18n } from '@kbn/i18n';
import { schema } from '@kbn/config-schema';
import { UiSettingsParams } from 'kibana/server';
-
-import { MAX_BUCKETS_SETTING } from '../common/constants';
+import { UI_SETTINGS } from '../common/constants';
export const getUiSettings: () => Record = () => ({
- [MAX_BUCKETS_SETTING]: {
+ [UI_SETTINGS.MAX_BUCKETS_SETTING]: {
name: i18n.translate('visTypeTimeseries.advancedSettings.maxBucketsTitle', {
defaultMessage: 'TSVB buckets limit',
}),
@@ -25,4 +24,16 @@ export const getUiSettings: () => Record = () => ({
}),
schema: schema.number(),
},
+ [UI_SETTINGS.ALLOW_STRING_INDICES]: {
+ name: i18n.translate('visTypeTimeseries.advancedSettings.allowStringIndicesTitle', {
+ defaultMessage: 'Allow string indices in TSVB',
+ }),
+ value: false,
+ requiresPageReload: true,
+ description: i18n.translate('visTypeTimeseries.advancedSettings.allowStringIndicesText', {
+ defaultMessage:
+ 'Enables you to use index patterns and Elasticsearch indices in TSVB visualizations.',
+ }),
+ schema: schema.boolean(),
+ },
});
diff --git a/src/plugins/vis_types/vega/public/components/deprecated_interval_info.test.tsx b/src/plugins/vis_types/vega/public/components/deprecated_interval_info.test.tsx
new file mode 100644
index 0000000000000..d88cf279881b3
--- /dev/null
+++ b/src/plugins/vis_types/vega/public/components/deprecated_interval_info.test.tsx
@@ -0,0 +1,135 @@
+/*
+ * 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 { shouldShowDeprecatedHistogramIntervalInfo } from './deprecated_interval_info';
+
+describe('shouldShowDeprecatedHistogramIntervalInfo', () => {
+ test('should show deprecated histogram interval', () => {
+ expect(
+ shouldShowDeprecatedHistogramIntervalInfo({
+ data: {
+ url: {
+ body: {
+ aggs: {
+ test: {
+ date_histogram: {
+ interval: 'day',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ ).toBeTruthy();
+
+ expect(
+ shouldShowDeprecatedHistogramIntervalInfo({
+ data: [
+ {
+ url: {
+ body: {
+ aggs: {
+ test: {
+ date_histogram: {
+ interval: 'day',
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ url: {
+ body: {
+ aggs: {
+ test: {
+ date_histogram: {
+ calendar_interval: 'day',
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
+ })
+ ).toBeTruthy();
+ });
+
+ test('should not show deprecated histogram interval', () => {
+ expect(
+ shouldShowDeprecatedHistogramIntervalInfo({
+ data: {
+ url: {
+ body: {
+ aggs: {
+ test: {
+ date_histogram: {
+ interval: { '%autointerval%': true },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ ).toBeFalsy();
+
+ expect(
+ shouldShowDeprecatedHistogramIntervalInfo({
+ data: {
+ url: {
+ body: {
+ aggs: {
+ test: {
+ auto_date_histogram: {
+ field: 'bytes',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ ).toBeFalsy();
+
+ expect(
+ shouldShowDeprecatedHistogramIntervalInfo({
+ data: [
+ {
+ url: {
+ body: {
+ aggs: {
+ test: {
+ date_histogram: {
+ calendar_interval: 'week',
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ url: {
+ body: {
+ aggs: {
+ test: {
+ date_histogram: {
+ fixed_interval: '23d',
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
+ })
+ ).toBeFalsy();
+ });
+});
diff --git a/src/plugins/vis_types/vega/public/components/deprecated_interval_info.tsx b/src/plugins/vis_types/vega/public/components/deprecated_interval_info.tsx
new file mode 100644
index 0000000000000..23144a4c2084d
--- /dev/null
+++ b/src/plugins/vis_types/vega/public/components/deprecated_interval_info.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 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 React from 'react';
+import { EuiCallOut, EuiButtonIcon } from '@elastic/eui';
+import { FormattedMessage } from '@kbn/i18n/react';
+import { VegaSpec } from '../data_model/types';
+import { getDocLinks } from '../services';
+
+import { BUCKET_TYPES } from '../../../../data/public';
+
+export const DeprecatedHistogramIntervalInfo = () => (
+
+ ),
+ }}
+ />
+ }
+ iconType="help"
+ />
+);
+
+export const shouldShowDeprecatedHistogramIntervalInfo = (spec: VegaSpec) => {
+ const data = Array.isArray(spec.data) ? spec?.data : [spec.data];
+
+ return data.some((dataItem = {}) => {
+ const aggs = dataItem.url?.body?.aggs ?? {};
+
+ return Object.keys(aggs).some((key) => {
+ const dateHistogram = aggs[key]?.[BUCKET_TYPES.DATE_HISTOGRAM] || {};
+ return 'interval' in dateHistogram && typeof dateHistogram.interval !== 'object';
+ });
+ });
+};
diff --git a/src/plugins/vis_types/vega/public/components/experimental_map_vis_info.tsx b/src/plugins/vis_types/vega/public/components/experimental_map_vis_info.tsx
index 2de6eb490196c..8a1f2c2794974 100644
--- a/src/plugins/vis_types/vega/public/components/experimental_map_vis_info.tsx
+++ b/src/plugins/vis_types/vega/public/components/experimental_map_vis_info.tsx
@@ -6,55 +6,37 @@
* Side Public License, v 1.
*/
-import { parse } from 'hjson';
import React from 'react';
import { EuiCallOut, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
-import { Vis } from '../../../../visualizations/public';
-function ExperimentalMapLayerInfo() {
- const title = (
-
- GitHub
-
- ),
- }}
- />
- );
-
- return (
-
- );
-}
+import type { VegaSpec } from '../data_model/types';
-export const getInfoMessage = (vis: Vis) => {
- if (vis.params.spec) {
- try {
- const spec = parse(vis.params.spec, { legacyRoot: false, keepWsc: true });
-
- if (spec.config?.kibana?.type === 'map') {
- return ;
- }
- } catch (e) {
- // spec is invalid
+export const ExperimentalMapLayerInfo = () => (
+
+ GitHub
+
+ ),
+ }}
+ />
}
- }
+ iconType="beaker"
+ />
+);
- return null;
-};
+export const shouldShowMapLayerInfo = (spec: VegaSpec) => spec.config?.kibana?.type === 'map';
diff --git a/src/plugins/vis_types/vega/public/components/vega_info_message.tsx b/src/plugins/vis_types/vega/public/components/vega_info_message.tsx
new file mode 100644
index 0000000000000..265613ef1e6ce
--- /dev/null
+++ b/src/plugins/vis_types/vega/public/components/vega_info_message.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 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 React, { useMemo } from 'react';
+import { parse } from 'hjson';
+import { ExperimentalMapLayerInfo, shouldShowMapLayerInfo } from './experimental_map_vis_info';
+import {
+ DeprecatedHistogramIntervalInfo,
+ shouldShowDeprecatedHistogramIntervalInfo,
+} from './deprecated_interval_info';
+
+import type { Vis } from '../../../../visualizations/public';
+import type { VegaSpec } from '../data_model/types';
+
+const parseSpec = (spec: string) => {
+ if (spec) {
+ try {
+ return parse(spec, { legacyRoot: false, keepWsc: true });
+ } catch (e) {
+ // spec is invalid
+ }
+ }
+};
+
+const InfoMessage = ({ spec }: { spec: string }) => {
+ const vegaSpec: VegaSpec = useMemo(() => parseSpec(spec), [spec]);
+
+ if (!vegaSpec) {
+ return null;
+ }
+
+ return (
+ <>
+ {shouldShowMapLayerInfo(vegaSpec) && }
+ {shouldShowDeprecatedHistogramIntervalInfo(vegaSpec) && }
+ >
+ );
+};
+
+export const getInfoMessage = (vis: Vis) => ;
diff --git a/src/plugins/vis_types/vega/public/data_model/es_query_parser.test.js b/src/plugins/vis_types/vega/public/data_model/es_query_parser.test.js
index 27ed5aa18a96d..bb3c0276f4cf9 100644
--- a/src/plugins/vis_types/vega/public/data_model/es_query_parser.test.js
+++ b/src/plugins/vis_types/vega/public/data_model/es_query_parser.test.js
@@ -178,11 +178,11 @@ describe(`EsQueryParser.injectQueryContextVars`, () => {
);
test(
`%autointerval% = true`,
- check({ interval: { '%autointerval%': true } }, { interval: `1h` }, ctxObj)
+ check({ interval: { '%autointerval%': true } }, { calendar_interval: `1h` }, ctxObj)
);
test(
`%autointerval% = 10`,
- check({ interval: { '%autointerval%': 10 } }, { interval: `3h` }, ctxObj)
+ check({ interval: { '%autointerval%': 10 } }, { fixed_interval: `3h` }, ctxObj)
);
test(`%timefilter% = min`, check({ a: { '%timefilter%': 'min' } }, { a: rangeStart }));
test(`%timefilter% = max`, check({ a: { '%timefilter%': 'max' } }, { a: rangeEnd }));
diff --git a/src/plugins/vis_types/vega/public/data_model/es_query_parser.ts b/src/plugins/vis_types/vega/public/data_model/es_query_parser.ts
index d0c63b8f2a6a0..134e82d676763 100644
--- a/src/plugins/vis_types/vega/public/data_model/es_query_parser.ts
+++ b/src/plugins/vis_types/vega/public/data_model/es_query_parser.ts
@@ -10,6 +10,7 @@ import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { cloneDeep, isPlainObject } from 'lodash';
import type { estypes } from '@elastic/elasticsearch';
+import { Assign } from 'utility-types';
import { TimeCache } from './time_cache';
import { SearchAPI } from './search_api';
import {
@@ -22,6 +23,7 @@ import {
Query,
ContextVarsObject,
} from './types';
+import { dateHistogramInterval } from '../../../../data/common';
const TIMEFILTER: string = '%timefilter%';
const AUTOINTERVAL: string = '%autointerval%';
@@ -226,7 +228,15 @@ export class EsQueryParser {
* @param {*} obj
* @param {boolean} isQuery - if true, the `obj` belongs to the req's query portion
*/
- _injectContextVars(obj: Query | estypes.SearchRequest['body']['aggs'], isQuery: boolean) {
+ _injectContextVars(
+ obj: Assign<
+ Query | estypes.SearchRequest['body']['aggs'],
+ {
+ interval?: { '%autointerval%': true | number } | string;
+ }
+ >,
+ isQuery: boolean
+ ) {
if (obj && typeof obj === 'object') {
if (Array.isArray(obj)) {
// For arrays, replace MUST_CLAUSE and MUST_NOT_CLAUSE string elements
@@ -270,27 +280,33 @@ export class EsQueryParser {
const subObj = (obj as ContextVarsObject)[prop];
if (!subObj || typeof obj !== 'object') continue;
- // replace "interval": { "%autointerval%": true|integer } with
- // auto-generated range based on the timepicker
- if (prop === 'interval' && subObj[AUTOINTERVAL]) {
- let size = subObj[AUTOINTERVAL];
- if (size === true) {
- size = 50; // by default, try to get ~80 values
- } else if (typeof size !== 'number') {
- throw new Error(
- i18n.translate('visTypeVega.esQueryParser.autointervalValueTypeErrorMessage', {
- defaultMessage: '{autointerval} must be either {trueValue} or a number',
- values: {
- autointerval: `"${AUTOINTERVAL}"`,
- trueValue: 'true',
- },
- })
- );
+ // replace "interval" with ES acceptable fixed_interval / calendar_interval
+ if (prop === 'interval') {
+ let intervalString: string;
+
+ if (typeof subObj === 'string') {
+ intervalString = subObj;
+ } else if (subObj[AUTOINTERVAL]) {
+ let size = subObj[AUTOINTERVAL];
+ if (size === true) {
+ size = 50; // by default, try to get ~80 values
+ } else if (typeof size !== 'number') {
+ throw new Error(
+ i18n.translate('visTypeVega.esQueryParser.autointervalValueTypeErrorMessage', {
+ defaultMessage: '{autointerval} must be either {trueValue} or a number',
+ values: {
+ autointerval: `"${AUTOINTERVAL}"`,
+ trueValue: 'true',
+ },
+ })
+ );
+ }
+ const { max, min } = this._timeCache.getTimeBounds();
+ intervalString = EsQueryParser._roundInterval((max - min) / size);
}
- const bounds = this._timeCache.getTimeBounds();
- (obj as ContextVarsObject).interval = EsQueryParser._roundInterval(
- (bounds.max - bounds.min) / size
- );
+
+ Object.assign(obj, dateHistogramInterval(intervalString));
+ delete obj.interval;
continue;
}
diff --git a/src/plugins/vis_types/vega/public/data_model/search_api.ts b/src/plugins/vis_types/vega/public/data_model/search_api.ts
index e00cf647930a8..11302ad65d56b 100644
--- a/src/plugins/vis_types/vega/public/data_model/search_api.ts
+++ b/src/plugins/vis_types/vega/public/data_model/search_api.ts
@@ -95,7 +95,16 @@ export class SearchAPI {
}
)
.pipe(
- tap((data) => this.inspectSearchResult(data, requestResponders[requestId])),
+ tap(
+ (data) => this.inspectSearchResult(data, requestResponders[requestId]),
+ (err) =>
+ this.inspectSearchResult(
+ {
+ rawResponse: err?.err,
+ },
+ requestResponders[requestId]
+ )
+ ),
map((data) => ({
name: requestId,
rawResponse: data.rawResponse,
diff --git a/src/plugins/vis_types/vega/public/data_model/types.ts b/src/plugins/vis_types/vega/public/data_model/types.ts
index 75b1132176d67..d1568bba6c98c 100644
--- a/src/plugins/vis_types/vega/public/data_model/types.ts
+++ b/src/plugins/vis_types/vega/public/data_model/types.ts
@@ -192,7 +192,6 @@ export type EmsQueryRequest = Requests & {
export interface ContextVarsObject {
[index: string]: any;
prop: ContextVarsObjectProps;
- interval: string;
}
export interface TooltipConfig {
diff --git a/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js b/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js
index be356ea4e05ce..cfeed174307ac 100644
--- a/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js
+++ b/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js
@@ -5,8 +5,8 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
-
import { cloneDeep } from 'lodash';
+import 'jest-canvas-mock';
import { euiThemeVars } from '@kbn/ui-shared-deps-src/theme';
import { VegaParser } from './vega_parser';
import { bypassExternalUrlCheck } from '../vega_view/vega_base_view';
diff --git a/src/plugins/vis_types/vega/public/vega_type.ts b/src/plugins/vis_types/vega/public/vega_type.ts
index 74899f5cfb3a4..23f0e385d2b33 100644
--- a/src/plugins/vis_types/vega/public/vega_type.ts
+++ b/src/plugins/vis_types/vega/public/vega_type.ts
@@ -16,7 +16,7 @@ import { getDefaultSpec } from './default_spec';
import { extractIndexPatternsFromSpec } from './lib/extract_index_pattern';
import { createInspectorAdapters } from './vega_inspector';
import { toExpressionAst } from './to_ast';
-import { getInfoMessage } from './components/experimental_map_vis_info';
+import { getInfoMessage } from './components/vega_info_message';
import { VegaVisEditorComponent } from './components/vega_vis_editor_lazy';
import type { VisParams } from './vega_fn';
diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/map_service_settings.ts b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/map_service_settings.ts
index 3399d0628ad65..9772e693358b6 100644
--- a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/map_service_settings.ts
+++ b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/map_service_settings.ts
@@ -66,6 +66,11 @@ export class MapServiceSettings {
tileApiUrl: this.config.emsTileApiUrl,
landingPageUrl: this.config.emsLandingPageUrl,
});
+
+ // Allow zooms > 10 for Vega Maps
+ // any kibana user, regardless of distribution, should get all zoom levels
+ // use `sspl` license to indicate this
+ this.emsClient.addQueryParams({ license: 'sspl' });
}
public async getTmsService(tmsTileLayer: string) {
diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts
index d3d0b6cb0411e..8ca2b2bd26eed 100644
--- a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts
+++ b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.test.ts
@@ -124,6 +124,8 @@ describe('vega_map_view/view', () => {
} as unknown as VegaViewParams);
}
+ let mockedConsoleLog: jest.SpyInstance;
+
beforeEach(() => {
vegaParser = new VegaParser(
JSON.stringify(vegaMap),
@@ -137,10 +139,13 @@ describe('vega_map_view/view', () => {
{},
mockGetServiceSettings
);
+ mockedConsoleLog = jest.spyOn(console, 'log'); // mocked console.log to avoid messages in the console when running tests
+ mockedConsoleLog.mockImplementation(() => {}); // comment this line when console logging for debugging
});
afterEach(() => {
jest.clearAllMocks();
+ mockedConsoleLog.mockRestore();
});
test('should be added TmsRasterLayer and do not use tmsService if mapStyle is "user_configured"', async () => {
diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.ts b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.ts
index cf5bf15d15051..777806d90d9a6 100644
--- a/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.ts
+++ b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.ts
@@ -72,7 +72,7 @@ export class VegaMapView extends VegaBaseView {
const { zoom, maxZoom, minZoom } = validateZoomSettings(
this._parser.mapConfig,
defaults,
- this.onWarn
+ this.onWarn.bind(this)
);
const { signals } = this._vegaStateRestorer.restore() || {};
diff --git a/src/plugins/vis_types/vega/public/vega_visualization.test.js b/src/plugins/vis_types/vega/public/vega_visualization.test.js
index 05a88880822ca..dd76e2d470004 100644
--- a/src/plugins/vis_types/vega/public/vega_visualization.test.js
+++ b/src/plugins/vis_types/vega/public/vega_visualization.test.js
@@ -81,7 +81,11 @@ describe('VegaVisualizations', () => {
mockWidth.mockRestore();
mockHeight.mockRestore();
});
+
test('should show vegalite graph and update on resize (may fail in dev env)', async () => {
+ const mockedConsoleLog = jest.spyOn(console, 'log'); // mocked console.log to avoid messages in the console when running tests
+ mockedConsoleLog.mockImplementation(() => {}); // comment this line when console logging for debugging comment this line
+
let vegaVis;
try {
vegaVis = new VegaVisualization(domNode, jest.fn());
@@ -111,6 +115,8 @@ describe('VegaVisualizations', () => {
} finally {
vegaVis.destroy();
}
+ expect(console.log).toBeCalledTimes(2);
+ mockedConsoleLog.mockRestore();
});
test('should show vega graph (may fail in dev env)', async () => {
@@ -130,7 +136,6 @@ describe('VegaVisualizations', () => {
mockGetServiceSettings
);
await vegaParser.parseAsync();
-
await vegaVis.render(vegaParser);
expect(domNode.innerHTML).toMatchSnapshot();
} finally {
diff --git a/src/plugins/vis_types/vega/server/index.ts b/src/plugins/vis_types/vega/server/index.ts
index 156dec027372a..9c448f6c618d3 100644
--- a/src/plugins/vis_types/vega/server/index.ts
+++ b/src/plugins/vis_types/vega/server/index.ts
@@ -16,10 +16,6 @@ export const config: PluginConfigDescriptor = {
enableExternalUrls: true,
},
schema: configSchema,
- deprecations: ({ renameFromRoot }) => [
- renameFromRoot('vega.enableExternalUrls', 'vis_type_vega.enableExternalUrls'),
- renameFromRoot('vega.enabled', 'vis_type_vega.enabled'),
- ],
};
export function plugin(initializerContext: PluginInitializerContext) {
diff --git a/src/plugins/vis_types/xy/config.ts b/src/plugins/vis_types/xy/config.ts
new file mode 100644
index 0000000000000..b831d26854c30
--- /dev/null
+++ b/src/plugins/vis_types/xy/config.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 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 { schema, TypeOf } from '@kbn/config-schema';
+
+export const configSchema = schema.object({
+ enabled: schema.boolean({ defaultValue: true }),
+});
+
+export type ConfigSchema = TypeOf;
diff --git a/src/plugins/vis_types/xy/kibana.json b/src/plugins/vis_types/xy/kibana.json
index 1666a346e3482..1606af5944ad3 100644
--- a/src/plugins/vis_types/xy/kibana.json
+++ b/src/plugins/vis_types/xy/kibana.json
@@ -2,7 +2,7 @@
"id": "visTypeXy",
"version": "kibana",
"ui": true,
- "server": false,
+ "server": true,
"requiredPlugins": ["charts", "data", "expressions", "visualizations", "usageCollection"],
"requiredBundles": ["kibanaUtils", "visDefaultEditor"],
"extraPublicDirs": ["common/index"],
diff --git a/src/plugins/vis_types/xy/public/utils/accessors.test.ts b/src/plugins/vis_types/xy/public/utils/accessors.test.ts
index 61d175fa8ff7d..06920ceebe980 100644
--- a/src/plugins/vis_types/xy/public/utils/accessors.test.ts
+++ b/src/plugins/vis_types/xy/public/utils/accessors.test.ts
@@ -6,7 +6,11 @@
* Side Public License, v 1.
*/
-import { COMPLEX_SPLIT_ACCESSOR, getComplexAccessor } from './accessors';
+import {
+ COMPLEX_SPLIT_ACCESSOR,
+ getComplexAccessor,
+ isPercentileIdEqualToSeriesId,
+} from './accessors';
import { BUCKET_TYPES } from '../../../../data/common';
import { AccessorFn, Datum } from '@elastic/charts';
@@ -99,3 +103,37 @@ describe('XY chart datum accessors', () => {
expect(accessor).toBeUndefined();
});
});
+
+describe('isPercentileIdEqualToSeriesId', () => {
+ it('should be equal for plain column ids', () => {
+ const seriesColumnId = 'col-0-1';
+ const columnId = `${seriesColumnId}`;
+
+ const isEqual = isPercentileIdEqualToSeriesId(columnId, seriesColumnId);
+ expect(isEqual).toBeTruthy();
+ });
+
+ it('should be equal for column with percentile', () => {
+ const seriesColumnId = '1';
+ const columnId = `${seriesColumnId}.95`;
+
+ const isEqual = isPercentileIdEqualToSeriesId(columnId, seriesColumnId);
+ expect(isEqual).toBeTruthy();
+ });
+
+ it('should not be equal for column with percentile equal to seriesColumnId', () => {
+ const seriesColumnId = '1';
+ const columnId = `2.1`;
+
+ const isEqual = isPercentileIdEqualToSeriesId(columnId, seriesColumnId);
+ expect(isEqual).toBeFalsy();
+ });
+
+ it('should not be equal for column with percentile, where columnId contains seriesColumnId', () => {
+ const seriesColumnId = '1';
+ const columnId = `${seriesColumnId}2.1`;
+
+ const isEqual = isPercentileIdEqualToSeriesId(columnId, seriesColumnId);
+ expect(isEqual).toBeFalsy();
+ });
+});
diff --git a/src/plugins/vis_types/xy/public/utils/accessors.tsx b/src/plugins/vis_types/xy/public/utils/accessors.tsx
index 9566f819ba145..2b552c9f3f9cf 100644
--- a/src/plugins/vis_types/xy/public/utils/accessors.tsx
+++ b/src/plugins/vis_types/xy/public/utils/accessors.tsx
@@ -9,7 +9,7 @@
import { AccessorFn, Accessor } from '@elastic/charts';
import { BUCKET_TYPES } from '../../../../data/public';
import { FakeParams } from '../../../../visualizations/public';
-import { Aspect } from '../types';
+import type { Aspect } from '../types';
export const COMPLEX_X_ACCESSOR = '__customXAccessor__';
export const COMPLEX_SPLIT_ACCESSOR = '__complexSplitAccessor__';
@@ -77,3 +77,11 @@ export const getSplitSeriesAccessorFnMap = (
return m;
};
+
+// For percentile, the aggregation id is coming in the form %s.%d, where %s is agg_id and %d - percents
+export const isPercentileIdEqualToSeriesId = (columnId: number | string, seriesColumnId: string) =>
+ columnId.toString().split('.')[0] === seriesColumnId;
+
+export const isValidSeriesForDimension = (seriesColumnId: string, { aggId, accessor }: Aspect) =>
+ (aggId === seriesColumnId || isPercentileIdEqualToSeriesId(aggId ?? '', seriesColumnId)) &&
+ accessor !== null;
diff --git a/src/plugins/vis_types/xy/public/utils/render_all_series.tsx b/src/plugins/vis_types/xy/public/utils/render_all_series.tsx
index f8ca1d059ae4f..c248b3b86e42a 100644
--- a/src/plugins/vis_types/xy/public/utils/render_all_series.tsx
+++ b/src/plugins/vis_types/xy/public/utils/render_all_series.tsx
@@ -22,10 +22,10 @@ import {
} from '@elastic/charts';
import { DatatableRow } from '../../../../expressions/public';
-import { METRIC_TYPES } from '../../../../data/public';
import { ChartType } from '../../common';
import { SeriesParam, VisConfig } from '../types';
+import { isValidSeriesForDimension } from './accessors';
/**
* Matches vislib curve to elastic charts
@@ -82,17 +82,7 @@ export const renderAllSeries = (
interpolate,
type,
}) => {
- const yAspects = aspects.y.filter(({ aggId, aggType, accessor }) => {
- if (
- aggType === METRIC_TYPES.PERCENTILES ||
- aggType === METRIC_TYPES.PERCENTILE_RANKS ||
- aggType === METRIC_TYPES.STD_DEV
- ) {
- return aggId?.includes(paramId) && accessor !== null;
- } else {
- return aggId === paramId && accessor !== null;
- }
- });
+ const yAspects = aspects.y.filter((aspect) => isValidSeriesForDimension(paramId, aspect));
if (!show || !yAspects.length) {
return null;
}
diff --git a/src/plugins/vis_types/xy/server/index.ts b/src/plugins/vis_types/xy/server/index.ts
new file mode 100644
index 0000000000000..9dfa405ee27b8
--- /dev/null
+++ b/src/plugins/vis_types/xy/server/index.ts
@@ -0,0 +1,17 @@
+/*
+ * 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 { PluginConfigDescriptor } from 'src/core/server';
+import { configSchema, ConfigSchema } from '../config';
+import { VisTypeXYServerPlugin } from './plugin';
+
+export const config: PluginConfigDescriptor = {
+ schema: configSchema,
+};
+
+export const plugin = () => new VisTypeXYServerPlugin();
diff --git a/src/plugins/data/common/data_views/lib/is_default.ts b/src/plugins/vis_types/xy/server/plugin.ts
similarity index 65%
rename from src/plugins/data/common/data_views/lib/is_default.ts
rename to src/plugins/vis_types/xy/server/plugin.ts
index 5a50d2862c58b..5cb0687cf1889 100644
--- a/src/plugins/data/common/data_views/lib/is_default.ts
+++ b/src/plugins/vis_types/xy/server/plugin.ts
@@ -6,9 +6,14 @@
* Side Public License, v 1.
*/
-import { IIndexPattern } from '../..';
+import { Plugin } from 'src/core/server';
-export const isDefault = (indexPattern: IIndexPattern) => {
- // Default index patterns don't have `type` defined.
- return !indexPattern.type;
-};
+export class VisTypeXYServerPlugin implements Plugin {
+ public setup() {
+ return {};
+ }
+
+ public start() {
+ return {};
+ }
+}
diff --git a/src/plugins/vis_types/xy/tsconfig.json b/src/plugins/vis_types/xy/tsconfig.json
index f1f65b6218e82..ab3f3d1252ed8 100644
--- a/src/plugins/vis_types/xy/tsconfig.json
+++ b/src/plugins/vis_types/xy/tsconfig.json
@@ -9,7 +9,8 @@
"include": [
"common/**/*",
"public/**/*",
- "server/**/*"
+ "server/**/*",
+ "*.ts"
],
"references": [
{ "path": "../../../core/tsconfig.json" },
diff --git a/src/plugins/visualizations/server/saved_objects/visualization.ts b/src/plugins/visualizations/server/saved_objects/visualization.ts
index 880e277294fc3..53027d5d5046c 100644
--- a/src/plugins/visualizations/server/saved_objects/visualization.ts
+++ b/src/plugins/visualizations/server/saved_objects/visualization.ts
@@ -20,9 +20,6 @@ export const visualizationSavedObjectType: SavedObjectsType = {
getTitle(obj) {
return obj.attributes.title;
},
- getEditUrl(obj) {
- return `/management/kibana/objects/savedVisualizations/${encodeURIComponent(obj.id)}`;
- },
getInAppUrl(obj) {
return {
path: `/app/visualize#/edit/${encodeURIComponent(obj.id)}`,
diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts
index aef131ce8d530..b128c09209743 100644
--- a/src/plugins/visualize/public/plugin.ts
+++ b/src/plugins/visualize/public/plugin.ts
@@ -162,7 +162,7 @@ export class VisualizePlugin
pluginsStart.data.indexPatterns.clearCache();
// make sure a default index pattern exists
// if not, the page will be redirected to management and visualize won't be rendered
- await pluginsStart.data.indexPatterns.ensureDefaultIndexPattern();
+ await pluginsStart.data.indexPatterns.ensureDefaultDataView();
appMounted();
diff --git a/test/accessibility/apps/dashboard.ts b/test/accessibility/apps/dashboard.ts
index 5a3ec9d8fc869..c8a7ac566b55c 100644
--- a/test/accessibility/apps/dashboard.ts
+++ b/test/accessibility/apps/dashboard.ts
@@ -15,7 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const listingTable = getService('listingTable');
- describe('Dashboard', () => {
+ describe.skip('Dashboard', () => {
const dashboardName = 'Dashboard Listing A11y';
const clonedDashboardName = 'Dashboard Listing A11y Copy';
diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/intro.test.tsx b/test/api_integration/apis/custom_integration/index.ts
similarity index 50%
rename from src/plugins/saved_objects_management/public/management_section/object_view/components/intro.test.tsx
rename to test/api_integration/apis/custom_integration/index.ts
index 0b869743f03c7..d3d34fc3ccfce 100644
--- a/src/plugins/saved_objects_management/public/management_section/object_view/components/intro.test.tsx
+++ b/test/api_integration/apis/custom_integration/index.ts
@@ -6,18 +6,10 @@
* Side Public License, v 1.
*/
-import React from 'react';
-import { mount } from 'enzyme';
-import { I18nProvider } from '@kbn/i18n/react';
-import { Intro } from './intro';
+import { FtrProviderContext } from '../../ftr_provider_context';
-describe('Intro component', () => {
- it('renders correctly', () => {
- const mounted = mount(
-
-
-
- );
- expect(mounted.find('Intro')).toMatchSnapshot();
+export default function ({ loadTestFile }: FtrProviderContext) {
+ describe('custom integrations', () => {
+ loadTestFile(require.resolve('./integrations'));
});
-});
+}
diff --git a/test/api_integration/apis/custom_integration/integrations.ts b/test/api_integration/apis/custom_integration/integrations.ts
new file mode 100644
index 0000000000000..d8f098fdc1fcf
--- /dev/null
+++ b/test/api_integration/apis/custom_integration/integrations.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 expect from '@kbn/expect';
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+export default function ({ getService }: FtrProviderContext) {
+ const supertest = getService('supertest');
+
+ describe('get list of append integrations', () => {
+ it('should return list of custom integrations that can be appended', async () => {
+ const resp = await supertest
+ .get(`/api/customIntegrations/appendCustomIntegrations`)
+ .set('kbn-xsrf', 'kibana')
+ .expect(200);
+
+ expect(resp.body).to.be.an('array');
+ expect(resp.body.length).to.be.above(0);
+ });
+ });
+}
diff --git a/test/api_integration/apis/index.ts b/test/api_integration/apis/index.ts
index 998c0b834d224..a6b8b746f68cf 100644
--- a/test/api_integration/apis/index.ts
+++ b/test/api_integration/apis/index.ts
@@ -12,6 +12,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('apis', () => {
loadTestFile(require.resolve('./console'));
loadTestFile(require.resolve('./core'));
+ loadTestFile(require.resolve('./custom_integration'));
loadTestFile(require.resolve('./general'));
loadTestFile(require.resolve('./home'));
loadTestFile(require.resolve('./index_pattern_field_editor'));
diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts
index f3f4b56cdccf5..9a5f94f9d8b9d 100644
--- a/test/api_integration/apis/saved_objects_management/find.ts
+++ b/test/api_integration/apis/saved_objects_management/find.ts
@@ -180,8 +180,6 @@ export default function ({ getService }: FtrProviderContext) {
icon: 'discoverApp',
title: 'OneRecord',
hiddenType: false,
- editUrl:
- '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'discover.show',
@@ -200,8 +198,6 @@ export default function ({ getService }: FtrProviderContext) {
icon: 'dashboardApp',
title: 'Dashboard',
hiddenType: false,
- editUrl:
- '/management/kibana/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'dashboard.show',
@@ -220,8 +216,6 @@ export default function ({ getService }: FtrProviderContext) {
icon: 'visualizeApp',
title: 'VisualizationFromSavedSearch',
hiddenType: false,
- editUrl:
- '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
@@ -232,8 +226,6 @@ export default function ({ getService }: FtrProviderContext) {
icon: 'visualizeApp',
title: 'Visualization',
hiddenType: false,
- editUrl:
- '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts
index 5fbd5cad8ec84..8ee5005348bcd 100644
--- a/test/api_integration/apis/saved_objects_management/relationships.ts
+++ b/test/api_integration/apis/saved_objects_management/relationships.ts
@@ -21,7 +21,7 @@ export default function ({ getService }: FtrProviderContext) {
meta: schema.object({
title: schema.string(),
icon: schema.string(),
- editUrl: schema.string(),
+ editUrl: schema.maybe(schema.string()),
inAppUrl: schema.object({
path: schema.string(),
uiCapabilitiesPath: schema.string(),
@@ -103,8 +103,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
title: 'VisualizationFromSavedSearch',
icon: 'visualizeApp',
- editUrl:
- '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
@@ -147,8 +145,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
icon: 'visualizeApp',
title: 'VisualizationFromSavedSearch',
- editUrl:
- '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
@@ -192,8 +188,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
icon: 'visualizeApp',
title: 'Visualization',
- editUrl:
- '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
@@ -209,8 +203,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
icon: 'visualizeApp',
title: 'VisualizationFromSavedSearch',
- editUrl:
- '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
@@ -234,8 +226,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
icon: 'visualizeApp',
title: 'Visualization',
- editUrl:
- '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
@@ -251,8 +241,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
icon: 'visualizeApp',
title: 'VisualizationFromSavedSearch',
- editUrl:
- '/management/kibana/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
@@ -296,8 +284,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
icon: 'discoverApp',
title: 'OneRecord',
- editUrl:
- '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'discover.show',
@@ -313,8 +299,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
icon: 'dashboardApp',
title: 'Dashboard',
- editUrl:
- '/management/kibana/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'dashboard.show',
@@ -340,8 +324,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
icon: 'discoverApp',
title: 'OneRecord',
- editUrl:
- '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'discover.show',
@@ -385,8 +367,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
icon: 'discoverApp',
title: 'OneRecord',
- editUrl:
- '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'discover.show',
@@ -402,8 +382,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
icon: 'visualizeApp',
title: 'Visualization',
- editUrl:
- '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'visualize.show',
@@ -429,8 +407,6 @@ export default function ({ getService }: FtrProviderContext) {
meta: {
icon: 'discoverApp',
title: 'OneRecord',
- editUrl:
- '/management/kibana/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357',
inAppUrl: {
path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357',
uiCapabilitiesPath: 'discover.show',
@@ -475,8 +451,6 @@ export default function ({ getService }: FtrProviderContext) {
{
id: 'add810b0-3224-11e8-a572-ffca06da1357',
meta: {
- editUrl:
- '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357',
icon: 'visualizeApp',
inAppUrl: {
path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357',
diff --git a/test/examples/config.js b/test/examples/config.js
index ee0c3b63b55c1..c2930068b631f 100644
--- a/test/examples/config.js
+++ b/test/examples/config.js
@@ -32,6 +32,7 @@ export default async function ({ readConfigFile }) {
require.resolve('./expressions_explorer'),
require.resolve('./index_pattern_field_editor_example'),
require.resolve('./field_formats'),
+ require.resolve('./partial_results'),
],
services: {
...functionalConfig.get('services'),
diff --git a/test/examples/partial_results/index.ts b/test/examples/partial_results/index.ts
new file mode 100644
index 0000000000000..8fb2824163024
--- /dev/null
+++ b/test/examples/partial_results/index.ts
@@ -0,0 +1,47 @@
+/*
+ * 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 expect from '@kbn/expect';
+import { FtrProviderContext } from 'test/functional/ftr_provider_context';
+
+// eslint-disable-next-line import/no-default-export
+export default function ({ getService, getPageObjects }: FtrProviderContext) {
+ const testSubjects = getService('testSubjects');
+ const PageObjects = getPageObjects(['common']);
+
+ describe('Partial Results Example', function () {
+ before(async () => {
+ this.tags('ciGroup2');
+ await PageObjects.common.navigateToApp('partialResultsExample');
+
+ const element = await testSubjects.find('example-help');
+
+ await element.click();
+ await element.click();
+ await element.click();
+ });
+
+ it('should trace mouse events', async () => {
+ const events = await Promise.all(
+ (
+ await testSubjects.findAll('example-column-event')
+ ).map((wrapper) => wrapper.getVisibleText())
+ );
+ expect(events).to.eql(['mousedown', 'mouseup', 'click']);
+ });
+
+ it('should keep track of the events number', async () => {
+ const counters = await Promise.all(
+ (
+ await testSubjects.findAll('example-column-count')
+ ).map((wrapper) => wrapper.getVisibleText())
+ );
+ expect(counters).to.eql(['3', '3', '3']);
+ });
+ });
+}
diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts
index 8043c8bf8cc37..c2da82a96cd0c 100644
--- a/test/functional/apps/dashboard/dashboard_unsaved_state.ts
+++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts
@@ -24,7 +24,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
let unsavedPanelCount = 0;
const testQuery = 'Test Query';
- describe('dashboard unsaved state', () => {
+ // FLAKY https://github.com/elastic/kibana/issues/112812
+ describe.skip('dashboard unsaved state', () => {
before(async () => {
await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana');
await kibanaServer.uiSettings.replace({
diff --git a/test/functional/apps/dashboard/saved_search_embeddable.ts b/test/functional/apps/dashboard/saved_search_embeddable.ts
index 5bcec338aad1e..ce1033fa02075 100644
--- a/test/functional/apps/dashboard/saved_search_embeddable.ts
+++ b/test/functional/apps/dashboard/saved_search_embeddable.ts
@@ -5,12 +5,13 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
-
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dashboardAddPanel = getService('dashboardAddPanel');
+ const dashboardPanelActions = getService('dashboardPanelActions');
+ const testSubjects = getService('testSubjects');
const filterBar = getService('filterBar');
const find = getService('find');
const esArchiver = getService('esArchiver');
@@ -61,5 +62,33 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
.map((mark) => $(mark).text());
expect(marks.length).to.be(0);
});
+
+ it('view action leads to a saved search', async function () {
+ await filterBar.removeAllFilters();
+ await PageObjects.dashboard.saveDashboard('Dashboard With Saved Search');
+ await PageObjects.dashboard.clickCancelOutOfEditMode(false);
+ const inViewMode = await PageObjects.dashboard.getIsInViewMode();
+ expect(inViewMode).to.equal(true);
+
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.dashboard.waitForRenderComplete();
+
+ await dashboardPanelActions.openContextMenu();
+ const actionExists = await testSubjects.exists(
+ 'embeddablePanelAction-ACTION_VIEW_SAVED_SEARCH'
+ );
+ if (!actionExists) {
+ await dashboardPanelActions.clickContextMenuMoreItem();
+ }
+ const actionElement = await testSubjects.find(
+ 'embeddablePanelAction-ACTION_VIEW_SAVED_SEARCH'
+ );
+ await actionElement.click();
+
+ await PageObjects.discover.waitForDiscoverAppOnScreen();
+ expect(await PageObjects.discover.getSavedSearchTitle()).to.equal(
+ 'Rendering Test: saved search'
+ );
+ });
});
}
diff --git a/test/functional/apps/discover/_runtime_fields_editor.ts b/test/functional/apps/discover/_runtime_fields_editor.ts
index 642743d3a0377..4757807cb7ac1 100644
--- a/test/functional/apps/discover/_runtime_fields_editor.ts
+++ b/test/functional/apps/discover/_runtime_fields_editor.ts
@@ -31,7 +31,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await fieldEditor.save();
};
- describe('discover integration with runtime fields editor', function describeIndexTests() {
+ // Failing: https://github.com/elastic/kibana/issues/111922
+ describe.skip('discover integration with runtime fields editor', function describeIndexTests() {
before(async function () {
await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']);
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
diff --git a/test/functional/apps/management/_test_huge_fields.js b/test/functional/apps/management/_test_huge_fields.js
index c8710a79e4fc8..7b75683940928 100644
--- a/test/functional/apps/management/_test_huge_fields.js
+++ b/test/functional/apps/management/_test_huge_fields.js
@@ -14,7 +14,7 @@ export default function ({ getService, getPageObjects }) {
const PageObjects = getPageObjects(['common', 'home', 'settings']);
// FLAKY: https://github.com/elastic/kibana/issues/89031
- describe.skip('test large number of fields', function () {
+ describe('test large number of fields', function () {
this.tags(['skipCloud']);
const EXPECTED_FIELD_COUNT = '10006';
diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts
deleted file mode 100644
index f4bf45c0b7f70..0000000000000
--- a/test/functional/apps/saved_objects_management/edit_saved_object.ts
+++ /dev/null
@@ -1,182 +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 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 expect from '@kbn/expect';
-import { FtrProviderContext } from '../../ftr_provider_context';
-
-const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
-
-export default function ({ getPageObjects, getService }: FtrProviderContext) {
- const esArchiver = getService('esArchiver');
- const testSubjects = getService('testSubjects');
- const PageObjects = getPageObjects(['common', 'settings', 'savedObjects']);
- const browser = getService('browser');
- const find = getService('find');
-
- const setFieldValue = async (fieldName: string, value: string) => {
- return testSubjects.setValue(`savedObjects-editField-${fieldName}`, value);
- };
-
- const getFieldValue = async (fieldName: string) => {
- return testSubjects.getAttribute(`savedObjects-editField-${fieldName}`, 'value');
- };
-
- const setAceEditorFieldValue = async (fieldName: string, fieldValue: string) => {
- const editorId = `savedObjects-editField-${fieldName}-aceEditor`;
- await find.clickByCssSelector(`#${editorId}`);
- return browser.execute(
- (editor: string, value: string) => {
- return (window as any).ace.edit(editor).setValue(value);
- },
- editorId,
- fieldValue
- );
- };
-
- const getAceEditorFieldValue = async (fieldName: string) => {
- const editorId = `savedObjects-editField-${fieldName}-aceEditor`;
- await find.clickByCssSelector(`#${editorId}`);
- return browser.execute((editor: string) => {
- return (window as any).ace.edit(editor).getValue() as string;
- }, editorId);
- };
-
- const focusAndClickButton = async (buttonSubject: string) => {
- const button = await testSubjects.find(buttonSubject);
- await button.scrollIntoViewIfNecessary();
- await delay(10);
- await button.focus();
- await delay(10);
- await button.click();
- // Allow some time for the transition/animations to occur before assuming the click is done
- await delay(10);
- };
-
- describe('saved objects edition page', () => {
- beforeEach(async () => {
- await esArchiver.load(
- 'test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object'
- );
- });
-
- afterEach(async () => {
- await esArchiver.unload(
- 'test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object'
- );
- });
-
- it('allows to update the saved object when submitting', async () => {
- await PageObjects.settings.navigateTo();
- await PageObjects.settings.clickKibanaSavedObjects();
-
- let objects = await PageObjects.savedObjects.getRowTitles();
- expect(objects.includes('A Dashboard')).to.be(true);
-
- await PageObjects.common.navigateToUrl(
- 'management',
- 'kibana/objects/savedDashboards/i-exist',
- {
- shouldUseHashForSubUrl: false,
- }
- );
-
- await testSubjects.existOrFail('savedObjectEditSave');
-
- expect(await getFieldValue('title')).to.eql('A Dashboard');
-
- await setFieldValue('title', 'Edited Dashboard');
- await setFieldValue('description', 'Some description');
-
- await focusAndClickButton('savedObjectEditSave');
-
- objects = await PageObjects.savedObjects.getRowTitles();
- expect(objects.includes('A Dashboard')).to.be(false);
- expect(objects.includes('Edited Dashboard')).to.be(true);
-
- await PageObjects.common.navigateToUrl(
- 'management',
- 'kibana/objects/savedDashboards/i-exist',
- {
- shouldUseHashForSubUrl: false,
- }
- );
-
- expect(await getFieldValue('title')).to.eql('Edited Dashboard');
- expect(await getFieldValue('description')).to.eql('Some description');
- });
-
- it('allows to delete a saved object', async () => {
- await PageObjects.common.navigateToUrl(
- 'management',
- 'kibana/objects/savedDashboards/i-exist',
- {
- shouldUseHashForSubUrl: false,
- }
- );
-
- await focusAndClickButton('savedObjectEditDelete');
- await PageObjects.common.clickConfirmOnModal();
-
- const objects = await PageObjects.savedObjects.getRowTitles();
- expect(objects.includes('A Dashboard')).to.be(false);
- });
-
- it('preserves the object references when saving', async () => {
- const testVisualizationUrl =
- 'kibana/objects/savedVisualizations/75c3e060-1e7c-11e9-8488-65449e65d0ed';
- const visualizationRefs = [
- {
- name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
- type: 'index-pattern',
- id: 'logstash-*',
- },
- ];
-
- await PageObjects.settings.navigateTo();
- await PageObjects.settings.clickKibanaSavedObjects();
-
- const objects = await PageObjects.savedObjects.getRowTitles();
- expect(objects.includes('A Pie')).to.be(true);
-
- await PageObjects.common.navigateToUrl('management', testVisualizationUrl, {
- shouldUseHashForSubUrl: false,
- });
-
- await testSubjects.existOrFail('savedObjectEditSave');
-
- let displayedReferencesValue = await getAceEditorFieldValue('references');
-
- expect(JSON.parse(displayedReferencesValue)).to.eql(visualizationRefs);
-
- await focusAndClickButton('savedObjectEditSave');
-
- await PageObjects.savedObjects.getRowTitles();
-
- await PageObjects.common.navigateToUrl('management', testVisualizationUrl, {
- shouldUseHashForSubUrl: false,
- });
-
- // Parsing to avoid random keys ordering issues in raw string comparison
- expect(JSON.parse(await getAceEditorFieldValue('references'))).to.eql(visualizationRefs);
-
- await setAceEditorFieldValue('references', JSON.stringify([], undefined, 2));
-
- await focusAndClickButton('savedObjectEditSave');
-
- await PageObjects.savedObjects.getRowTitles();
-
- await PageObjects.common.navigateToUrl('management', testVisualizationUrl, {
- shouldUseHashForSubUrl: false,
- });
-
- displayedReferencesValue = await getAceEditorFieldValue('references');
-
- expect(JSON.parse(displayedReferencesValue)).to.eql([]);
- });
- });
-}
diff --git a/test/functional/apps/saved_objects_management/index.ts b/test/functional/apps/saved_objects_management/index.ts
index 0b367b284e741..12e0cc8863f12 100644
--- a/test/functional/apps/saved_objects_management/index.ts
+++ b/test/functional/apps/saved_objects_management/index.ts
@@ -11,7 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function savedObjectsManagementApp({ loadTestFile }: FtrProviderContext) {
describe('saved objects management', function savedObjectsManagementAppTestSuite() {
this.tags('ciGroup7');
- loadTestFile(require.resolve('./edit_saved_object'));
+ loadTestFile(require.resolve('./inspect_saved_objects'));
loadTestFile(require.resolve('./show_relationships'));
});
}
diff --git a/test/functional/apps/saved_objects_management/inspect_saved_objects.ts b/test/functional/apps/saved_objects_management/inspect_saved_objects.ts
new file mode 100644
index 0000000000000..839c262acffa0
--- /dev/null
+++ b/test/functional/apps/saved_objects_management/inspect_saved_objects.ts
@@ -0,0 +1,87 @@
+/*
+ * 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 expect from '@kbn/expect';
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+export default function ({ getPageObjects, getService }: FtrProviderContext) {
+ const esArchiver = getService('esArchiver');
+ const testSubjects = getService('testSubjects');
+ const PageObjects = getPageObjects(['common', 'settings', 'savedObjects']);
+ const find = getService('find');
+
+ const focusAndClickButton = async (buttonSubject: string) => {
+ const button = await testSubjects.find(buttonSubject);
+ await button.scrollIntoViewIfNecessary();
+ await delay(10);
+ await button.focus();
+ await delay(10);
+ await button.click();
+ // Allow some time for the transition/animations to occur before assuming the click is done
+ await delay(10);
+ };
+ const textIncludesAll = (text: string, items: string[]) => {
+ const bools = items.map((item) => !!text.includes(item));
+ return bools.every((currBool) => currBool === true);
+ };
+
+ describe('saved objects edition page', () => {
+ beforeEach(async () => {
+ await esArchiver.load(
+ 'test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object'
+ );
+ });
+
+ afterEach(async () => {
+ await esArchiver.unload(
+ 'test/functional/fixtures/es_archiver/saved_objects_management/edit_saved_object'
+ );
+ });
+
+ it('allows to view the saved object', async () => {
+ await PageObjects.settings.navigateTo();
+ await PageObjects.settings.clickKibanaSavedObjects();
+ const objects = await PageObjects.savedObjects.getRowTitles();
+ expect(objects.includes('A Dashboard')).to.be(true);
+ await PageObjects.common.navigateToUrl('management', 'kibana/objects/dashboard/i-exist', {
+ shouldUseHashForSubUrl: false,
+ });
+ const inspectContainer = await find.byClassName('kibanaCodeEditor');
+ const visibleContainerText = await inspectContainer.getVisibleText();
+ // ensure that something renders visibly
+ expect(
+ textIncludesAll(visibleContainerText, [
+ 'A Dashboard',
+ 'title',
+ 'id',
+ 'type',
+ 'attributes',
+ 'references',
+ ])
+ ).to.be(true);
+ });
+
+ it('allows to delete a saved object', async () => {
+ await PageObjects.settings.navigateTo();
+ await PageObjects.settings.clickKibanaSavedObjects();
+ let objects = await PageObjects.savedObjects.getRowTitles();
+ expect(objects.includes('A Dashboard')).to.be(true);
+ await PageObjects.savedObjects.clickInspectByTitle('A Dashboard');
+ await PageObjects.common.navigateToUrl('management', 'kibana/objects/dashboard/i-exist', {
+ shouldUseHashForSubUrl: false,
+ });
+ await focusAndClickButton('savedObjectEditDelete');
+ await PageObjects.common.clickConfirmOnModal();
+
+ objects = await PageObjects.savedObjects.getRowTitles();
+ expect(objects.includes('A Dashboard')).to.be(false);
+ });
+ });
+}
diff --git a/test/functional/apps/visualize/_timelion.ts b/test/functional/apps/visualize/_timelion.ts
index ea8cb8b13ba49..bb85b6821df31 100644
--- a/test/functional/apps/visualize/_timelion.ts
+++ b/test/functional/apps/visualize/_timelion.ts
@@ -18,6 +18,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
'timelion',
'common',
]);
+ const security = getService('security');
const monacoEditor = getService('monacoEditor');
const kibanaServer = getService('kibanaServer');
const elasticChart = getService('elasticChart');
@@ -26,6 +27,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
describe('Timelion visualization', () => {
before(async () => {
+ await security.testUser.setRoles([
+ 'kibana_admin',
+ 'long_window_logstash',
+ 'test_logstash_reader',
+ ]);
await kibanaServer.uiSettings.update({
'timelion:legacyChartsLibrary': false,
});
@@ -277,17 +283,20 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
it('should show field suggestions for split argument when index pattern set', async () => {
await monacoEditor.setCodeEditorValue('');
await monacoEditor.typeCodeEditorValue(
- '.es(index=logstash-*, timefield=@timestamp ,split=',
+ '.es(index=logstash-*, timefield=@timestamp, split=',
'timelionCodeEditor'
);
+ // wait for split fields to load
+ await common.sleep(300);
const suggestions = await timelion.getSuggestionItemsText();
+
expect(suggestions.length).not.to.eql(0);
expect(suggestions[0].includes('@message.raw')).to.eql(true);
});
it('should show field suggestions for metric argument when index pattern set', async () => {
await monacoEditor.typeCodeEditorValue(
- '.es(index=logstash-*, timefield=@timestamp ,metric=avg:',
+ '.es(index=logstash-*, timefield=@timestamp, metric=avg:',
'timelionCodeEditor'
);
const suggestions = await timelion.getSuggestionItemsText();
diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts
index 6a5c062268c25..c530b00364fd1 100644
--- a/test/functional/apps/visualize/_tsvb_chart.ts
+++ b/test/functional/apps/visualize/_tsvb_chart.ts
@@ -16,6 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const inspector = getService('inspector');
const retry = getService('retry');
const security = getService('security');
+ const kibanaServer = getService('kibanaServer');
const { timePicker, visChart, visualBuilder, visualize, settings } = getPageObjects([
'timePicker',
@@ -95,6 +96,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await visualBuilder.setFieldForAggregation('machine.ram');
const kibanaIndexPatternModeValue = await visualBuilder.getMetricValue();
+ await kibanaServer.uiSettings.update({ 'metrics:allowStringIndices': true });
+ await browser.refresh();
await visualBuilder.clickPanelOptions('metric');
await visualBuilder.switchIndexPatternSelectionMode(false);
const stringIndexPatternModeValue = await visualBuilder.getMetricValue();
diff --git a/test/functional/apps/visualize/_tsvb_time_series.ts b/test/functional/apps/visualize/_tsvb_time_series.ts
index 21bee2d16442f..009e4a07cd42a 100644
--- a/test/functional/apps/visualize/_tsvb_time_series.ts
+++ b/test/functional/apps/visualize/_tsvb_time_series.ts
@@ -17,6 +17,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
'timeToVisualize',
'dashboard',
]);
+ const security = getService('security');
const testSubjects = getService('testSubjects');
const retry = getService('retry');
const filterBar = getService('filterBar');
@@ -27,6 +28,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
describe('visual builder', function describeIndexTests() {
before(async () => {
+ await security.testUser.setRoles([
+ 'kibana_admin',
+ 'long_window_logstash',
+ 'test_logstash_reader',
+ ]);
await visualize.initTests();
});
beforeEach(async () => {
@@ -433,6 +439,49 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
after(async () => await visualBuilder.toggleNewChartsLibraryWithDebug(false));
});
+
+ describe('index pattern selection mode', () => {
+ it('should disable switch for Kibana index patterns mode by default', async () => {
+ await visualBuilder.clickPanelOptions('timeSeries');
+ const isEnabled = await visualBuilder.checkIndexPatternSelectionModeSwitchIsEnabled();
+ expect(isEnabled).to.be(false);
+ });
+
+ describe('metrics:allowStringIndices = true', () => {
+ before(async () => {
+ await kibanaServer.uiSettings.update({ 'metrics:allowStringIndices': true });
+ await browser.refresh();
+ });
+
+ beforeEach(async () => await visualBuilder.clickPanelOptions('timeSeries'));
+
+ it('should not disable switch for Kibana index patterns mode', async () => {
+ await visualBuilder.switchIndexPatternSelectionMode(true);
+
+ const isEnabled = await visualBuilder.checkIndexPatternSelectionModeSwitchIsEnabled();
+ expect(isEnabled).to.be(true);
+ });
+
+ it('should disable switch after selecting Kibana index patterns mode and metrics:allowStringIndices = false', async () => {
+ await visualBuilder.switchIndexPatternSelectionMode(false);
+ await kibanaServer.uiSettings.update({ 'metrics:allowStringIndices': false });
+ await browser.refresh();
+ await visualBuilder.clickPanelOptions('timeSeries');
+
+ let isEnabled = await visualBuilder.checkIndexPatternSelectionModeSwitchIsEnabled();
+ expect(isEnabled).to.be(true);
+
+ await visualBuilder.switchIndexPatternSelectionMode(true);
+ isEnabled = await visualBuilder.checkIndexPatternSelectionModeSwitchIsEnabled();
+ expect(isEnabled).to.be(false);
+ });
+
+ after(
+ async () =>
+ await kibanaServer.uiSettings.update({ 'metrics:allowStringIndices': false })
+ );
+ });
+ });
});
});
}
diff --git a/test/functional/apps/visualize/_vega_chart.ts b/test/functional/apps/visualize/_vega_chart.ts
index c52b0e0f8451f..b2692c2a00d78 100644
--- a/test/functional/apps/visualize/_vega_chart.ts
+++ b/test/functional/apps/visualize/_vega_chart.ts
@@ -41,8 +41,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
const retry = getService('retry');
const browser = getService('browser');
- // SKIPPED: https://github.com/elastic/kibana/issues/106352
- describe.skip('vega chart in visualize app', () => {
+ describe('vega chart in visualize app', () => {
before(async () => {
await PageObjects.visualize.initTests();
log.debug('navigateToApp visualize');
diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts
index 878c7b88341af..3bc4da0163909 100644
--- a/test/functional/apps/visualize/index.ts
+++ b/test/functional/apps/visualize/index.ts
@@ -85,11 +85,16 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./_add_to_dashboard.ts'));
});
+ describe('visualize ciGroup8', function () {
+ this.tags('ciGroup8');
+
+ loadTestFile(require.resolve('./_tsvb_chart'));
+ });
+
describe('visualize ciGroup11', function () {
this.tags('ciGroup11');
loadTestFile(require.resolve('./_tag_cloud'));
- loadTestFile(require.resolve('./_tsvb_chart'));
loadTestFile(require.resolve('./_tsvb_time_series'));
loadTestFile(require.resolve('./_tsvb_markdown'));
loadTestFile(require.resolve('./_tsvb_table'));
diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts
index f230dae1d394a..f8af0ef8f883a 100644
--- a/test/functional/page_objects/discover_page.ts
+++ b/test/functional/page_objects/discover_page.ts
@@ -123,6 +123,11 @@ export class DiscoverPageObject extends FtrService {
return await searchLink.isDisplayed();
}
+ public async getSavedSearchTitle() {
+ const breadcrumb = await this.find.byCssSelector('[data-test-subj="breadcrumb last"]');
+ return await breadcrumb.getVisibleText();
+ }
+
public async loadSavedSearch(searchName: string) {
await this.openLoadSavedSearchPanel();
await this.testSubjects.click(`savedObjectTitle${searchName.split(' ').join('-')}`);
diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts
index c324de1231b7d..d793b87f6ea96 100644
--- a/test/functional/page_objects/visual_builder_page.ts
+++ b/test/functional/page_objects/visual_builder_page.ts
@@ -284,7 +284,8 @@ export class VisualBuilderPageObject extends FtrService {
const drilldownEl = await this.testSubjects.find('drilldownUrl');
await drilldownEl.clearValue();
- await drilldownEl.type(value);
+ await drilldownEl.type(value, { charByChar: true });
+ await this.header.waitUntilLoadingHasFinished();
}
/**
@@ -502,12 +503,32 @@ export class VisualBuilderPageObject extends FtrService {
return await annotationTooltipDetails.getVisibleText();
}
+ public async toggleIndexPatternSelectionModePopover(shouldOpen: boolean) {
+ const isPopoverOpened = await this.testSubjects.exists(
+ 'switchIndexPatternSelectionModePopoverContent'
+ );
+ if ((shouldOpen && !isPopoverOpened) || (!shouldOpen && isPopoverOpened)) {
+ await this.testSubjects.click('switchIndexPatternSelectionModePopoverButton');
+ }
+ }
+
public async switchIndexPatternSelectionMode(useKibanaIndices: boolean) {
- await this.testSubjects.click('switchIndexPatternSelectionModePopover');
+ await this.toggleIndexPatternSelectionModePopover(true);
await this.testSubjects.setEuiSwitch(
'switchIndexPatternSelectionMode',
useKibanaIndices ? 'check' : 'uncheck'
);
+ await this.toggleIndexPatternSelectionModePopover(false);
+ }
+
+ public async checkIndexPatternSelectionModeSwitchIsEnabled() {
+ await this.toggleIndexPatternSelectionModePopover(true);
+ let isEnabled;
+ await this.testSubjects.retry.tryForTime(2000, async () => {
+ isEnabled = await this.testSubjects.isEnabled('switchIndexPatternSelectionMode');
+ });
+ await this.toggleIndexPatternSelectionModePopover(false);
+ return isEnabled;
}
public async setIndexPatternValue(value: string, useKibanaIndices?: boolean) {
diff --git a/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts b/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts
index 0bc32672d41b9..244d07d2cfc82 100644
--- a/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts
+++ b/test/interpreter_functional/test_suites/run_pipeline/esaggs_timeshift.ts
@@ -37,7 +37,8 @@ export default function ({
}: FtrProviderContext & { updateBaselines: boolean }) {
let expectExpression: ExpectExpression;
- describe('esaggs timeshift tests', () => {
+ // FLAKY https://github.com/elastic/kibana/issues/107028
+ describe.skip('esaggs timeshift tests', () => {
before(() => {
expectExpression = expectExpressionProvider({ getService, updateBaselines });
});
diff --git a/test/scripts/jenkins_storybook.sh b/test/scripts/jenkins_storybook.sh
index 00cc0d78599dd..17ca46b0097b1 100755
--- a/test/scripts/jenkins_storybook.sh
+++ b/test/scripts/jenkins_storybook.sh
@@ -20,6 +20,7 @@ yarn storybook --site expression_repeat_image
yarn storybook --site expression_reveal_image
yarn storybook --site expression_shape
yarn storybook --site expression_tagcloud
+yarn storybook --site fleet
yarn storybook --site infra
yarn storybook --site security_solution
yarn storybook --site ui_actions_enhanced
diff --git a/x-pack/plugins/actions/common/index.ts b/x-pack/plugins/actions/common/index.ts
index cff876b5995a1..1e51adf3e9d09 100644
--- a/x-pack/plugins/actions/common/index.ts
+++ b/x-pack/plugins/actions/common/index.ts
@@ -15,3 +15,10 @@ export * from './rewrite_request_case';
export const BASE_ACTION_API_PATH = '/api/actions';
export const INTERNAL_BASE_ACTION_API_PATH = '/internal/actions';
export const ACTIONS_FEATURE_ID = 'actions';
+
+// supported values for `service` in addition to nodemailer's list of well-known services
+export enum AdditionalEmailServices {
+ ELASTIC_CLOUD = 'elastic_cloud',
+ EXCHANGE = 'exchange_server',
+ OTHER = 'other',
+}
diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts
index a341cdf58b9e2..7549d2ecaab77 100644
--- a/x-pack/plugins/actions/server/actions_client.test.ts
+++ b/x-pack/plugins/actions/server/actions_client.test.ts
@@ -20,6 +20,7 @@ import { licenseStateMock } from './lib/license_state.mock';
import { licensingMock } from '../../licensing/server/mocks';
import { httpServerMock } from '../../../../src/core/server/mocks';
import { auditServiceMock } from '../../security/server/audit/index.mock';
+import { usageCountersServiceMock } from 'src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock';
import {
elasticsearchServiceMock,
@@ -28,7 +29,12 @@ import {
import { actionExecutorMock } from './lib/action_executor.mock';
import uuid from 'uuid';
import { ActionsAuthorization } from './authorization/actions_authorization';
+import {
+ getAuthorizationModeBySource,
+ AuthorizationMode,
+} from './authorization/get_authorization_mode_by_source';
import { actionsAuthorizationMock } from './authorization/actions_authorization.mock';
+import { trackLegacyRBACExemption } from './lib/track_legacy_rbac_exemption';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { elasticsearchClientMock } from '../../../../src/core/server/elasticsearch/client/mocks';
@@ -38,6 +44,22 @@ jest.mock('../../../../src/core/server/saved_objects/service/lib/utils', () => (
},
}));
+jest.mock('./lib/track_legacy_rbac_exemption', () => ({
+ trackLegacyRBACExemption: jest.fn(),
+}));
+
+jest.mock('./authorization/get_authorization_mode_by_source', () => {
+ return {
+ getAuthorizationModeBySource: jest.fn(() => {
+ return 1;
+ }),
+ AuthorizationMode: {
+ Legacy: 0,
+ RBAC: 1,
+ },
+ };
+});
+
const defaultKibanaIndex = '.kibana';
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
@@ -47,6 +69,8 @@ const executionEnqueuer = jest.fn();
const ephemeralExecutionEnqueuer = jest.fn();
const request = httpServerMock.createKibanaRequest();
const auditLogger = auditServiceMock.create().asScoped(request);
+const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract();
+const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test');
const mockTaskManager = taskManagerMock.createSetup();
@@ -82,6 +106,7 @@ beforeEach(() => {
request,
authorization: authorization as unknown as ActionsAuthorization,
auditLogger,
+ usageCounter: mockUsageCounter,
});
});
@@ -1640,6 +1665,9 @@ describe('update()', () => {
describe('execute()', () => {
describe('authorization', () => {
test('ensures user is authorised to excecute actions', async () => {
+ (getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
+ return AuthorizationMode.RBAC;
+ });
await actionsClient.execute({
actionId: 'action-id',
params: {
@@ -1650,6 +1678,9 @@ describe('execute()', () => {
});
test('throws when user is not authorised to create the type of action', async () => {
+ (getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
+ return AuthorizationMode.RBAC;
+ });
authorization.ensureAuthorized.mockRejectedValue(
new Error(`Unauthorized to execute all actions`)
);
@@ -1665,6 +1696,21 @@ describe('execute()', () => {
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute');
});
+
+ test('tracks legacy RBAC', async () => {
+ (getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
+ return AuthorizationMode.Legacy;
+ });
+
+ await actionsClient.execute({
+ actionId: 'action-id',
+ params: {
+ name: 'my name',
+ },
+ });
+
+ expect(trackLegacyRBACExemption as jest.Mock).toBeCalledWith('execute', mockUsageCounter);
+ });
});
test('calls the actionExecutor with the appropriate parameters', async () => {
@@ -1756,6 +1802,9 @@ describe('execute()', () => {
describe('enqueueExecution()', () => {
describe('authorization', () => {
test('ensures user is authorised to excecute actions', async () => {
+ (getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
+ return AuthorizationMode.RBAC;
+ });
await actionsClient.enqueueExecution({
id: uuid.v4(),
params: {},
@@ -1766,6 +1815,9 @@ describe('enqueueExecution()', () => {
});
test('throws when user is not authorised to create the type of action', async () => {
+ (getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
+ return AuthorizationMode.RBAC;
+ });
authorization.ensureAuthorized.mockRejectedValue(
new Error(`Unauthorized to execute all actions`)
);
@@ -1781,6 +1833,24 @@ describe('enqueueExecution()', () => {
expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute');
});
+
+ test('tracks legacy RBAC', async () => {
+ (getAuthorizationModeBySource as jest.Mock).mockImplementationOnce(() => {
+ return AuthorizationMode.Legacy;
+ });
+
+ await actionsClient.enqueueExecution({
+ id: uuid.v4(),
+ params: {},
+ spaceId: 'default',
+ apiKey: null,
+ });
+
+ expect(trackLegacyRBACExemption as jest.Mock).toBeCalledWith(
+ 'enqueueExecution',
+ mockUsageCounter
+ );
+ });
});
test('calls the executionEnqueuer with the appropriate parameters', async () => {
diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts
index d6f6037ecd8b8..b391e50283ad1 100644
--- a/x-pack/plugins/actions/server/actions_client.ts
+++ b/x-pack/plugins/actions/server/actions_client.ts
@@ -7,6 +7,7 @@
import Boom from '@hapi/boom';
import type { estypes } from '@elastic/elasticsearch';
+import { UsageCounter } from 'src/plugins/usage_collection/server';
import { i18n } from '@kbn/i18n';
import { omitBy, isUndefined } from 'lodash';
@@ -42,6 +43,7 @@ import {
} from './authorization/get_authorization_mode_by_source';
import { connectorAuditEvent, ConnectorAuditAction } from './lib/audit_events';
import { RunNowResult } from '../../task_manager/server';
+import { trackLegacyRBACExemption } from './lib/track_legacy_rbac_exemption';
// We are assuming there won't be many actions. This is why we will load
// all the actions in advance and assume the total count to not go over 10000.
@@ -74,6 +76,7 @@ interface ConstructorOptions {
request: KibanaRequest;
authorization: ActionsAuthorization;
auditLogger?: AuditLogger;
+ usageCounter?: UsageCounter;
}
export interface UpdateOptions {
@@ -93,6 +96,7 @@ export class ActionsClient {
private readonly executionEnqueuer: ExecutionEnqueuer;
private readonly ephemeralExecutionEnqueuer: ExecutionEnqueuer;
private readonly auditLogger?: AuditLogger;
+ private readonly usageCounter?: UsageCounter;
constructor({
actionTypeRegistry,
@@ -106,6 +110,7 @@ export class ActionsClient {
request,
authorization,
auditLogger,
+ usageCounter,
}: ConstructorOptions) {
this.actionTypeRegistry = actionTypeRegistry;
this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient;
@@ -118,6 +123,7 @@ export class ActionsClient {
this.request = request;
this.authorization = authorization;
this.auditLogger = auditLogger;
+ this.usageCounter = usageCounter;
}
/**
@@ -478,6 +484,8 @@ export class ActionsClient {
AuthorizationMode.RBAC
) {
await this.authorization.ensureAuthorized('execute');
+ } else {
+ trackLegacyRBACExemption('execute', this.usageCounter);
}
return this.actionExecutor.execute({
actionId,
@@ -495,6 +503,8 @@ export class ActionsClient {
AuthorizationMode.RBAC
) {
await this.authorization.ensureAuthorized('execute');
+ } else {
+ trackLegacyRBACExemption('enqueueExecution', this.usageCounter);
}
return this.executionEnqueuer(this.unsecuredSavedObjectsClient, options);
}
@@ -506,6 +516,8 @@ export class ActionsClient {
AuthorizationMode.RBAC
) {
await this.authorization.ensureAuthorized('execute');
+ } else {
+ trackLegacyRBACExemption('ephemeralEnqueuedExecution', this.usageCounter);
}
return this.ephemeralExecutionEnqueuer(this.unsecuredSavedObjectsClient, options);
}
diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts
index d10046341b268..fcd003286d5bb 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/email.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts
@@ -17,6 +17,7 @@ import { Logger } from '../../../../../src/core/server';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
import { ActionsConfigurationUtilities } from '../actions_config';
import { renderMustacheString, renderMustacheObject } from '../lib/mustache_renderer';
+import { AdditionalEmailServices } from '../../common';
export type EmailActionType = ActionType<
ActionTypeConfigType,
@@ -33,13 +34,6 @@ export type EmailActionTypeExecutorOptions = ActionTypeExecutorOptions<
// config definition
export type ActionTypeConfigType = TypeOf;
-// supported values for `service` in addition to nodemailer's list of well-known services
-export enum AdditionalEmailServices {
- ELASTIC_CLOUD = 'elastic_cloud',
- EXCHANGE = 'exchange_server',
- OTHER = 'other',
-}
-
// these values for `service` require users to fill in host/port/secure
export const CUSTOM_HOST_PORT_SERVICES: string[] = [AdditionalEmailServices.OTHER];
diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/request_oauth_client_credentials_token.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/request_oauth_client_credentials_token.ts
index 09080ee0c0063..b632cdf5f5219 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/lib/request_oauth_client_credentials_token.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/lib/request_oauth_client_credentials_token.ts
@@ -7,6 +7,7 @@
import qs from 'query-string';
import axios from 'axios';
+import stringify from 'json-stable-stringify';
import { Logger } from '../../../../../../src/core/server';
import { request } from './axios_utils';
import { ActionsConfigurationUtilities } from '../../actions_config';
@@ -59,7 +60,7 @@ export async function requestOAuthClientCredentialsToken(
expiresIn: res.data.expires_in,
};
} else {
- const errString = JSON.stringify(res.data);
+ const errString = stringify(res.data);
logger.warn(
`error thrown getting the access token from ${tokenUrl} for clientID: ${clientId}: ${errString}`
);
diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts
index ea3c0f91b6a5c..53c70fddc5a09 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts
@@ -13,10 +13,10 @@ import { Logger } from '../../../../../../src/core/server';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { CustomHostSettings } from '../../config';
import { getNodeSSLOptions, getSSLSettingsFromConfig } from './get_node_ssl_options';
-import { AdditionalEmailServices } from '../email';
import { sendEmailGraphApi } from './send_email_graph_api';
import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token';
import { ProxySettings } from '../../types';
+import { AdditionalEmailServices } from '../../../common';
// an email "service" which doesn't actually send, just returns what it would send
export const JSON_TRANSPORT_SERVICE = '__json';
diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts
index 10e9a3bc8d27c..ea1579095bb97 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts
@@ -5,6 +5,8 @@
* 2.0.
*/
+// @ts-expect-error missing type def
+import stringify from 'json-stringify-safe';
import axios, { AxiosResponse } from 'axios';
import { Logger } from '../../../../../../src/core/server';
import { request } from './axios_utils';
@@ -41,9 +43,9 @@ export async function sendEmailGraphApi(
validateStatus: () => true,
});
if (res.status === 202) {
- return res;
+ return res.data;
}
- const errString = JSON.stringify(res.data);
+ const errString = stringify(res.data);
logger.warn(
`error thrown sending Microsoft Exchange email for clientID: ${sendEmailOptions.options.transport.clientId}: ${errString}`
);
diff --git a/x-pack/plugins/actions/server/lib/track_legacy_rbac_exemption.test.ts b/x-pack/plugins/actions/server/lib/track_legacy_rbac_exemption.test.ts
new file mode 100644
index 0000000000000..ffd8e7f17c11f
--- /dev/null
+++ b/x-pack/plugins/actions/server/lib/track_legacy_rbac_exemption.test.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { usageCountersServiceMock } from 'src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock';
+import { trackLegacyRBACExemption } from './track_legacy_rbac_exemption';
+
+describe('trackLegacyRBACExemption', () => {
+ it('should call `usageCounter.incrementCounter`', () => {
+ const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract();
+ const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test');
+
+ trackLegacyRBACExemption('test', mockUsageCounter);
+ expect(mockUsageCounter.incrementCounter).toHaveBeenCalledWith({
+ counterName: `source_test`,
+ counterType: 'legacyRBACExemption',
+ incrementBy: 1,
+ });
+ });
+
+ it('should do nothing if no usage counter is provided', () => {
+ let err;
+ try {
+ trackLegacyRBACExemption('test', undefined);
+ } catch (e) {
+ err = e;
+ }
+ expect(err).toBeUndefined();
+ });
+});
diff --git a/x-pack/plugins/actions/server/lib/track_legacy_rbac_exemption.ts b/x-pack/plugins/actions/server/lib/track_legacy_rbac_exemption.ts
new file mode 100644
index 0000000000000..73c859c4cd21e
--- /dev/null
+++ b/x-pack/plugins/actions/server/lib/track_legacy_rbac_exemption.ts
@@ -0,0 +1,18 @@
+/*
+ * 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 { UsageCounter } from 'src/plugins/usage_collection/server';
+
+export function trackLegacyRBACExemption(source: string, usageCounter?: UsageCounter) {
+ if (usageCounter) {
+ usageCounter.incrementCounter({
+ counterName: `source_${source}`,
+ counterType: 'legacyRBACExemption',
+ incrementBy: 1,
+ });
+ }
+}
diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts
index fe133ddb6f0ac..d0404a253c0d9 100644
--- a/x-pack/plugins/actions/server/plugin.ts
+++ b/x-pack/plugins/actions/server/plugin.ts
@@ -6,7 +6,7 @@
*/
import type { PublicMethodsOf } from '@kbn/utility-types';
-import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
+import { UsageCollectionSetup, UsageCounter } from 'src/plugins/usage_collection/server';
import {
PluginInitializerContext,
Plugin,
@@ -151,6 +151,7 @@ export class ActionsPlugin implements Plugin(),
this.licenseState,
- usageCounter
+ this.usageCounter
);
// Cleanup failed execution task definition
@@ -367,6 +369,7 @@ export class ActionsPlugin implements Plugin {
+ const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
+ mockEsClient.search.mockReturnValue(
+ // @ts-expect-error not full search response
+ elasticsearchClientMock.createSuccessTransportRequestPromise({
+ aggregations: {
+ byActionTypeId: {
+ value: {
+ types: { '.index': 1, '.server-log': 1, 'some.type': 1, 'another.type.': 1 },
+ },
+ },
+ },
+ hits: {
+ hits: [
+ {
+ _id: 'action:541efb3d-f82a-4d2c-a5c3-636d1ce49b53',
+ _index: '.kibana_1',
+ _score: 0,
+ _source: {
+ action: {
+ actionTypeId: '.index',
+ config: {
+ index: 'kibana_sample_data_ecommerce',
+ refresh: true,
+ executionTimeField: null,
+ },
+ name: 'test',
+ secrets:
+ 'UPyn6cit6zBTPMmldfKh/8S2JWypwaLhhEQWBXp+OyTc6TtLHOnW92wehCqTq1FhIY3vA8hwVsggj+tbIoCcfPArpzP5SO7hh8vd6pY13x5TkiM083UgjjaAxbPvKQ==',
+ },
+ references: [],
+ type: 'action',
+ updated_at: '2020-03-26T18:46:44.449Z',
+ },
+ },
+ {
+ _id: 'action:00000000-f82a-4d2c-a5c3-636d1ce49b53',
+ _index: '.kibana_1',
+ _score: 0,
+ _source: {
+ action: {
+ actionTypeId: '.server-log',
+ config: {},
+ name: 'test server log',
+ secrets: '',
+ },
+ references: [],
+ type: 'action',
+ updated_at: '2020-03-26T18:46:44.449Z',
+ },
+ },
+ {
+ _id: 'action:00000000-1',
+ _index: '.kibana_1',
+ _score: 0,
+ _source: {
+ action: {
+ actionTypeId: 'some.type',
+ config: {},
+ name: 'test type',
+ secrets: {},
+ },
+ references: [],
+ type: 'action',
+ updated_at: '2020-03-26T18:46:44.449Z',
+ },
+ },
+ {
+ _id: 'action:00000000-2',
+ _index: '.kibana_1',
+ _score: 0,
+ _source: {
+ action: {
+ actionTypeId: 'another.type.',
+ config: {},
+ name: 'test another type',
+ secrets: {},
+ },
+ references: [],
+ type: 'action',
+ updated_at: '2020-03-26T18:46:44.449Z',
+ },
+ },
+ ],
+ },
+ })
+ );
+ const telemetry = await getTotalCount(mockEsClient, 'test', [
+ {
+ id: 'test',
+ actionTypeId: '.test',
+ name: 'test',
+ isPreconfigured: true,
+ secrets: {},
+ },
+ {
+ id: 'anotherServerLog',
+ actionTypeId: '.server-log',
+ name: 'test',
+ isPreconfigured: true,
+ secrets: {},
+ },
+ ]);
+
+ expect(mockEsClient.search).toHaveBeenCalledTimes(1);
+
+ expect(telemetry).toMatchInlineSnapshot(`
+Object {
+ "countByType": Object {
+ "__index": 1,
+ "__server-log": 2,
+ "__test": 1,
+ "another.type__": 1,
+ "some.type": 1,
+ },
+ "countTotal": 6,
+}
+`);
+ });
+
+ test('getInUseTotalCount() accounts for preconfigured connectors', async () => {
+ const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
+ mockEsClient.search.mockReturnValue(
+ // @ts-expect-error not full search response
+ elasticsearchClientMock.createSuccessTransportRequestPromise({
+ aggregations: {
+ refs: {
+ actionRefIds: {
+ value: {
+ connectorIds: {
+ '1': 'action-0',
+ '123': 'action-1',
+ '456': 'action-2',
+ },
+ total: 3,
+ },
+ },
+ },
+ preconfigured_actions: {
+ preconfiguredActionRefIds: {
+ value: {
+ total: 3,
+ actionRefs: {
+ 'preconfigured:preconfigured-alert-history-es-index': {
+ actionRef: 'preconfigured:preconfigured-alert-history-es-index',
+ actionTypeId: '.index',
+ },
+ 'preconfigured:cloud_email': {
+ actionRef: 'preconfigured:cloud_email',
+ actionTypeId: '.email',
+ },
+ 'preconfigured:cloud_email2': {
+ actionRef: 'preconfigured:cloud_email2',
+ actionTypeId: '.email',
+ },
+ },
+ },
+ },
},
},
})
@@ -208,11 +415,9 @@ Object {
},
},
{
- id: 'preconfigured-alert-history-es-index',
- error: {
- statusCode: 404,
- error: 'Not Found',
- message: 'Saved object [action/preconfigured-alert-history-es-index] not found',
+ id: '456',
+ attributes: {
+ actionTypeId: '.email',
},
},
],
@@ -226,10 +431,12 @@ Object {
Object {
"countByAlertHistoryConnectorType": 1,
"countByType": Object {
+ "__email": 3,
+ "__index": 1,
"__server-log": 1,
"__slack": 1,
},
- "countTotal": 3,
+ "countTotal": 6,
}
`);
});
diff --git a/x-pack/plugins/actions/server/usage/actions_telemetry.ts b/x-pack/plugins/actions/server/usage/actions_telemetry.ts
index 71516cb4918e7..76e038fb77e7f 100644
--- a/x-pack/plugins/actions/server/usage/actions_telemetry.ts
+++ b/x-pack/plugins/actions/server/usage/actions_telemetry.ts
@@ -12,9 +12,13 @@ import {
SavedObjectsBulkResponse,
} from 'kibana/server';
import { AlertHistoryEsIndexConnectorId } from '../../common';
-import { ActionResult } from '../types';
+import { ActionResult, PreConfiguredAction } from '../types';
-export async function getTotalCount(esClient: ElasticsearchClient, kibanaIndex: string) {
+export async function getTotalCount(
+ esClient: ElasticsearchClient,
+ kibanaIndex: string,
+ preconfiguredActions?: PreConfiguredAction[]
+) {
const scriptedMetric = {
scripted_metric: {
init_script: 'state.types = [:]',
@@ -56,20 +60,27 @@ export async function getTotalCount(esClient: ElasticsearchClient, kibanaIndex:
});
// @ts-expect-error aggegation type is not specified
const aggs = searchResult.aggregations?.byActionTypeId.value?.types;
+ const countByType = Object.keys(aggs).reduce(
+ // ES DSL aggregations are returned as `any` by esClient.search
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (obj: any, key: string) => ({
+ ...obj,
+ [replaceFirstAndLastDotSymbols(key)]: aggs[key],
+ }),
+ {}
+ );
+ if (preconfiguredActions && preconfiguredActions.length) {
+ for (const preconfiguredAction of preconfiguredActions) {
+ const actionTypeId = replaceFirstAndLastDotSymbols(preconfiguredAction.actionTypeId);
+ countByType[actionTypeId] = countByType[actionTypeId] || 0;
+ countByType[actionTypeId]++;
+ }
+ }
return {
- countTotal: Object.keys(aggs).reduce(
- (total: number, key: string) => parseInt(aggs[key], 0) + total,
- 0
- ),
- countByType: Object.keys(aggs).reduce(
- // ES DSL aggregations are returned as `any` by esClient.search
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (obj: any, key: string) => ({
- ...obj,
- [replaceFirstAndLastDotSymbols(key)]: aggs[key],
- }),
- {}
- ),
+ countTotal:
+ Object.keys(aggs).reduce((total: number, key: string) => parseInt(aggs[key], 0) + total, 0) +
+ (preconfiguredActions?.length ?? 0),
+ countByType,
};
}
@@ -120,6 +131,44 @@ export async function getInUseTotalCount(
},
};
+ const preconfiguredActionsScriptedMetric = {
+ scripted_metric: {
+ init_script: 'state.actionRefs = new HashMap(); state.total = 0;',
+ map_script: `
+ String actionRef = doc['alert.actions.actionRef'].value;
+ String actionTypeId = doc['alert.actions.actionTypeId'].value;
+ if (actionRef.startsWith('preconfigured:') && state.actionRefs[actionRef] === null) {
+ HashMap map = new HashMap();
+ map.actionRef = actionRef;
+ map.actionTypeId = actionTypeId;
+ state.actionRefs[actionRef] = map;
+ state.total++;
+ }
+ `,
+ // Combine script is executed per cluster, but we already have a key-value pair per cluster.
+ // Despite docs that say this is optional, this script can't be blank.
+ combine_script: 'return state',
+ // Reduce script is executed across all clusters, so we need to add up all the total from each cluster
+ // This also needs to account for having no data
+ reduce_script: `
+ Map actionRefs = [:];
+ long total = 0;
+ for (state in states) {
+ if (state !== null) {
+ total += state.total;
+ for (String k : state.actionRefs.keySet()) {
+ actionRefs.put(k, state.actionRefs.get(k));
+ }
+ }
+ }
+ Map result = new HashMap();
+ result.total = total;
+ result.actionRefs = actionRefs;
+ return result;
+ `,
+ },
+ };
+
const { body: actionResults } = await esClient.search({
index: kibanaIndex,
body: {
@@ -127,24 +176,57 @@ export async function getInUseTotalCount(
bool: {
filter: {
bool: {
+ must_not: {
+ term: {
+ type: 'action_task_params',
+ },
+ },
must: {
- nested: {
- path: 'references',
- query: {
- bool: {
- filter: {
- bool: {
- must: [
- {
- term: {
- 'references.type': 'action',
+ bool: {
+ should: [
+ {
+ nested: {
+ path: 'references',
+ query: {
+ bool: {
+ filter: {
+ bool: {
+ must: [
+ {
+ term: {
+ 'references.type': 'action',
+ },
+ },
+ ],
},
},
- ],
+ },
},
},
},
- },
+ {
+ nested: {
+ path: 'alert.actions',
+ query: {
+ bool: {
+ filter: {
+ bool: {
+ must: [
+ {
+ prefix: {
+ 'alert.actions.actionRef': {
+ value: 'preconfigured:',
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
},
},
},
@@ -160,25 +242,30 @@ export async function getInUseTotalCount(
actionRefIds: scriptedMetric,
},
},
+ preconfigured_actions: {
+ nested: {
+ path: 'alert.actions',
+ },
+ aggs: {
+ preconfiguredActionRefIds: preconfiguredActionsScriptedMetric,
+ },
+ },
},
},
});
// @ts-expect-error aggegation type is not specified
const aggs = actionResults.aggregations.refs.actionRefIds.value;
+ const preconfiguredActionsAggs =
+ // @ts-expect-error aggegation type is not specified
+ actionResults.aggregations.preconfigured_actions?.preconfiguredActionRefIds.value;
const bulkFilter = Object.entries(aggs.connectorIds).map(([key]) => ({
id: key,
type: 'action',
fields: ['id', 'actionTypeId'],
}));
const actions = await actionsBulkGet(bulkFilter);
-
- // filter out preconfigured connectors, which are not saved objects and return
- // an error in the bulk response
- const actionsWithActionTypeId = actions.saved_objects.filter(
- (action) => action?.attributes?.actionTypeId != null
- );
- const countByActionTypeId = actionsWithActionTypeId.reduce(
+ const countByActionTypeId = actions.saved_objects.reduce(
(actionTypeCount: Record, action) => {
const alertTypeId = replaceFirstAndLastDotSymbols(action.attributes.actionTypeId);
const currentCount =
@@ -189,14 +276,24 @@ export async function getInUseTotalCount(
{}
);
- const preconfiguredAlertHistoryConnector = actions.saved_objects.filter(
- (action) => action.id === AlertHistoryEsIndexConnectorId
- );
+ let preconfiguredAlertHistoryConnectors = 0;
+ const preconfiguredActionsRefs: Array<{
+ actionTypeId: string;
+ actionRef: string;
+ }> = preconfiguredActionsAggs ? Object.values(preconfiguredActionsAggs?.actionRefs) : [];
+ for (const { actionRef, actionTypeId: rawActionTypeId } of preconfiguredActionsRefs) {
+ const actionTypeId = replaceFirstAndLastDotSymbols(rawActionTypeId);
+ countByActionTypeId[actionTypeId] = countByActionTypeId[actionTypeId] || 0;
+ countByActionTypeId[actionTypeId]++;
+ if (actionRef === `preconfigured:${AlertHistoryEsIndexConnectorId}`) {
+ preconfiguredAlertHistoryConnectors++;
+ }
+ }
return {
- countTotal: aggs.total,
+ countTotal: aggs.total + (preconfiguredActionsAggs?.total ?? 0),
countByType: countByActionTypeId,
- countByAlertHistoryConnectorType: preconfiguredAlertHistoryConnector.length,
+ countByAlertHistoryConnectorType: preconfiguredAlertHistoryConnectors,
};
}
diff --git a/x-pack/plugins/actions/server/usage/task.ts b/x-pack/plugins/actions/server/usage/task.ts
index 3ba40d92abd7a..f37f830697eb5 100644
--- a/x-pack/plugins/actions/server/usage/task.ts
+++ b/x-pack/plugins/actions/server/usage/task.ts
@@ -17,7 +17,7 @@ import {
TaskManagerSetupContract,
TaskManagerStartContract,
} from '../../../task_manager/server';
-import { ActionResult } from '../types';
+import { ActionResult, PreConfiguredAction } from '../types';
import { getTotalCount, getInUseTotalCount } from './actions_telemetry';
export const TELEMETRY_TASK_TYPE = 'actions_telemetry';
@@ -28,9 +28,10 @@ export function initializeActionsTelemetry(
logger: Logger,
taskManager: TaskManagerSetupContract,
core: CoreSetup,
- kibanaIndex: string
+ kibanaIndex: string,
+ preconfiguredActions: PreConfiguredAction[]
) {
- registerActionsTelemetryTask(logger, taskManager, core, kibanaIndex);
+ registerActionsTelemetryTask(logger, taskManager, core, kibanaIndex, preconfiguredActions);
}
export function scheduleActionsTelemetry(logger: Logger, taskManager: TaskManagerStartContract) {
@@ -41,13 +42,14 @@ function registerActionsTelemetryTask(
logger: Logger,
taskManager: TaskManagerSetupContract,
core: CoreSetup,
- kibanaIndex: string
+ kibanaIndex: string,
+ preconfiguredActions: PreConfiguredAction[]
) {
taskManager.registerTaskDefinitions({
[TELEMETRY_TASK_TYPE]: {
title: 'Actions usage fetch task',
timeout: '5m',
- createTaskRunner: telemetryTaskRunner(logger, core, kibanaIndex),
+ createTaskRunner: telemetryTaskRunner(logger, core, kibanaIndex, preconfiguredActions),
},
});
}
@@ -65,7 +67,12 @@ async function scheduleTasks(logger: Logger, taskManager: TaskManagerStartContra
}
}
-export function telemetryTaskRunner(logger: Logger, core: CoreSetup, kibanaIndex: string) {
+export function telemetryTaskRunner(
+ logger: Logger,
+ core: CoreSetup,
+ kibanaIndex: string,
+ preconfiguredActions: PreConfiguredAction[]
+) {
return ({ taskInstance }: RunContext) => {
const { state } = taskInstance;
const getEsClient = () =>
@@ -90,7 +97,7 @@ export function telemetryTaskRunner(logger: Logger, core: CoreSetup, kibanaIndex
async run() {
const esClient = await getEsClient();
return Promise.all([
- getTotalCount(esClient, kibanaIndex),
+ getTotalCount(esClient, kibanaIndex, preconfiguredActions),
getInUseTotalCount(esClient, actionsBulkGet, kibanaIndex),
])
.then(([totalAggegations, totalInUse]) => {
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx
index 505fd5c1020b3..bbc77bcabca52 100644
--- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import React from 'react';
+import React, { Fragment } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiTitle, EuiFlexItem } from '@elastic/eui';
import { RumOverview } from '../RumDashboard';
@@ -18,6 +18,7 @@ import { UserPercentile } from './UserPercentile';
import { useBreakpoints } from '../../../hooks/use_breakpoints';
import { KibanaPageTemplateProps } from '../../../../../../../src/plugins/kibana_react/public';
import { useHasRumData } from './hooks/useHasRumData';
+import { EmptyStateLoading } from './empty_state_loading';
export const UX_LABEL = i18n.translate('xpack.apm.ux.title', {
defaultMessage: 'Dashboard',
@@ -29,7 +30,7 @@ export function RumHome() {
const { isSmall, isXXL } = useBreakpoints();
- const { data: rumHasData } = useHasRumData();
+ const { data: rumHasData, status } = useHasRumData();
const envStyle = isSmall ? {} : { maxWidth: 500 };
@@ -58,31 +59,38 @@ export function RumHome() {
}
: undefined;
+ const isLoading = status === 'loading';
+
return (
-
- ,
-
-
-
,
- ,
- ,
- ],
- }
- : { children: }
- }
- >
-
-
-
+
+
+ ,
+
+
+
,
+ ,
+ ,
+ ],
+ }
+ : { children: }
+ }
+ >
+ {isLoading && }
+
+
+
+
+
+
);
}
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useLayerList.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useLayerList.ts
index f1b5b67da21f1..ce42b530b80f5 100644
--- a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useLayerList.ts
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useLayerList.ts
@@ -15,6 +15,7 @@ import {
COLOR_MAP_TYPE,
FIELD_ORIGIN,
LABEL_BORDER_SIZES,
+ LAYER_TYPE,
SOURCE_TYPES,
STYLE_TYPE,
SYMBOLIZE_AS_TYPES,
@@ -154,7 +155,7 @@ export function useLayerList() {
maxZoom: 24,
alpha: 0.75,
visible: true,
- type: 'VECTOR',
+ type: LAYER_TYPE.VECTOR,
};
ES_TERM_SOURCE_REGION.whereQuery = getWhereQuery(serviceName!);
@@ -178,7 +179,7 @@ export function useLayerList() {
maxZoom: 24,
alpha: 0.75,
visible: true,
- type: 'VECTOR',
+ type: LAYER_TYPE.VECTOR,
};
return [
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/empty_state_loading.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/empty_state_loading.tsx
new file mode 100644
index 0000000000000..b02672721ce8e
--- /dev/null
+++ b/x-pack/plugins/apm/public/components/app/RumDashboard/empty_state_loading.tsx
@@ -0,0 +1,35 @@
+/*
+ * 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 {
+ EuiEmptyPrompt,
+ EuiLoadingSpinner,
+ EuiSpacer,
+ EuiTitle,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import React, { Fragment } from 'react';
+
+export function EmptyStateLoading() {
+ return (
+
+
+
+
+
+ {i18n.translate('xpack.apm.emptyState.loadingMessage', {
+ defaultMessage: 'Loading…',
+ })}
+
+
+
+ }
+ />
+ );
+}
diff --git a/x-pack/plugins/apm/public/components/app/correlations/custom_fields.tsx b/x-pack/plugins/apm/public/components/app/correlations/custom_fields.tsx
deleted file mode 100644
index 9a13c44602c2d..0000000000000
--- a/x-pack/plugins/apm/public/components/app/correlations/custom_fields.tsx
+++ /dev/null
@@ -1,166 +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 {
- EuiFlexGroup,
- EuiFlexItem,
- EuiAccordion,
- EuiComboBox,
- EuiFormRow,
- EuiLink,
- EuiSelect,
- EuiSpacer,
-} from '@elastic/eui';
-import { i18n } from '@kbn/i18n';
-import { FormattedMessage } from '@kbn/i18n/react';
-import React, { useEffect, useState } from 'react';
-import { useFieldNames } from './use_field_names';
-import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink';
-import { useUiTracker } from '../../../../../observability/public';
-
-interface Props {
- fieldNames: string[];
- setFieldNames: (fieldNames: string[]) => void;
- setDurationPercentile?: (value: PercentileOption) => void;
- showThreshold?: boolean;
- durationPercentile?: PercentileOption;
-}
-
-export type PercentileOption = 50 | 75 | 99;
-const percentilOptions: PercentileOption[] = [50, 75, 99];
-
-export function CustomFields({
- fieldNames,
- setFieldNames,
- setDurationPercentile = () => {},
- showThreshold = false,
- durationPercentile = 75,
-}: Props) {
- const trackApmEvent = useUiTracker({ app: 'apm' });
- const { defaultFieldNames, getSuggestions } = useFieldNames();
- const [suggestedFieldNames, setSuggestedFieldNames] = useState(
- getSuggestions('')
- );
-
- useEffect(() => {
- if (suggestedFieldNames.length) {
- return;
- }
- setSuggestedFieldNames(getSuggestions(''));
- }, [getSuggestions, suggestedFieldNames]);
-
- return (
-
-
-
- {showThreshold && (
-
-
- ({
- value: percentile,
- text: i18n.translate(
- 'xpack.apm.correlations.customize.thresholdPercentile',
- {
- defaultMessage: '{percentile}th percentile',
- values: { percentile },
- }
- ),
- }))}
- onChange={(e) => {
- setDurationPercentile(
- parseInt(e.target.value, 10) as PercentileOption
- );
- }}
- />
-
-
- )}
-
- {
- setFieldNames(defaultFieldNames);
- }}
- >
- {i18n.translate(
- 'xpack.apm.correlations.customize.fieldHelpTextReset',
- { defaultMessage: 'reset' }
- )}
-
- ),
- docsLink: (
-
- {i18n.translate(
- 'xpack.apm.correlations.customize.fieldHelpTextDocsLink',
- {
- defaultMessage:
- 'Learn more about the default fields.',
- }
- )}
-
- ),
- }}
- />
- }
- >
- ({ label }))}
- onChange={(options) => {
- const nextFieldNames = options.map((option) => option.label);
- setFieldNames(nextFieldNames);
- trackApmEvent({ metric: 'customize_correlations_fields' });
- }}
- onCreateOption={(term) => {
- const nextFieldNames = [...fieldNames, term];
- setFieldNames(nextFieldNames);
- }}
- onSearchChange={(searchValue) => {
- setSuggestedFieldNames(getSuggestions(searchValue));
- }}
- options={suggestedFieldNames.map((label) => ({ label }))}
- />
-
-
-
-
- );
-}
diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_field_names.ts b/x-pack/plugins/apm/public/components/app/correlations/use_field_names.ts
deleted file mode 100644
index ff88808c51d15..0000000000000
--- a/x-pack/plugins/apm/public/components/app/correlations/use_field_names.ts
+++ /dev/null
@@ -1,74 +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 { memoize } from 'lodash';
-import { useEffect, useMemo, useState } from 'react';
-import { isRumAgentName } from '../../../../common/agent_name';
-import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context';
-import { useDynamicIndexPatternFetcher } from '../../../hooks/use_dynamic_index_pattern';
-
-interface IndexPattern {
- fields: Array<{ name: string; esTypes: string[] }>;
-}
-
-export function useFieldNames() {
- const { agentName } = useApmServiceContext();
- const isRumAgent = isRumAgentName(agentName);
- const { indexPattern } = useDynamicIndexPatternFetcher();
-
- const [defaultFieldNames, setDefaultFieldNames] = useState(
- getDefaultFieldNames(indexPattern, isRumAgent)
- );
-
- const getSuggestions = useMemo(
- () =>
- memoize((searchValue: string) =>
- getMatchingFieldNames(indexPattern, searchValue)
- ),
- [indexPattern]
- );
-
- useEffect(() => {
- setDefaultFieldNames(getDefaultFieldNames(indexPattern, isRumAgent));
- }, [indexPattern, isRumAgent]);
-
- return { defaultFieldNames, getSuggestions };
-}
-
-function getMatchingFieldNames(
- indexPattern: IndexPattern | undefined,
- inputValue: string
-) {
- if (!indexPattern) {
- return [];
- }
- return indexPattern.fields
- .filter(
- ({ name, esTypes }) =>
- name.startsWith(inputValue) && esTypes[0] === 'keyword' // only show fields of type 'keyword'
- )
- .map(({ name }) => name);
-}
-
-function getDefaultFieldNames(
- indexPattern: IndexPattern | undefined,
- isRumAgent: boolean
-) {
- const labelFields = getMatchingFieldNames(indexPattern, 'labels.').slice(
- 0,
- 6
- );
- return isRumAgent
- ? [
- ...labelFields,
- 'user_agent.name',
- 'user_agent.os.name',
- 'url.original',
- ...getMatchingFieldNames(indexPattern, 'user.').slice(0, 6),
- ]
- : [...labelFields, 'service.version', 'service.node.name', 'host.ip'];
-}
diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_distribution_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_distribution_chart/index.tsx
index e8a159f23ee3d..535fb777166bb 100644
--- a/x-pack/plugins/apm/public/components/shared/charts/transaction_distribution_chart/index.tsx
+++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_distribution_chart/index.tsx
@@ -256,6 +256,7 @@ export function TransactionDistributionChart({
/>
{data.map((d, i) => (
= {
+ deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')],
exposeToBrowser: {
serviceMapEnabled: true,
ui: true,
profilingEnabled: true,
},
- schema: schema.object({
- enabled: schema.boolean({ defaultValue: true }),
- serviceMapEnabled: schema.boolean({ defaultValue: true }),
- serviceMapFingerprintBucketSize: schema.number({ defaultValue: 100 }),
- serviceMapTraceIdBucketSize: schema.number({ defaultValue: 65 }),
- serviceMapFingerprintGlobalBucketSize: schema.number({
- defaultValue: 1000,
- }),
- serviceMapTraceIdGlobalBucketSize: schema.number({ defaultValue: 6 }),
- serviceMapMaxTracesPerRequest: schema.number({ defaultValue: 50 }),
- autocreateApmIndexPattern: schema.boolean({ defaultValue: true }),
- ui: schema.object({
- enabled: schema.boolean({ defaultValue: true }),
- transactionGroupBucketSize: schema.number({ defaultValue: 1000 }),
- maxTraceItems: schema.number({ defaultValue: 1000 }),
- }),
- searchAggregatedTransactions: schema.oneOf(
- [
- schema.literal(SearchAggregatedTransactionSetting.auto),
- schema.literal(SearchAggregatedTransactionSetting.always),
- schema.literal(SearchAggregatedTransactionSetting.never),
- ],
- { defaultValue: SearchAggregatedTransactionSetting.auto }
- ),
- telemetryCollectionEnabled: schema.boolean({ defaultValue: true }),
- metricsInterval: schema.number({ defaultValue: 30 }),
- maxServiceEnvironments: schema.number({ defaultValue: 100 }),
- maxServiceSelection: schema.number({ defaultValue: 50 }),
- profilingEnabled: schema.boolean({ defaultValue: false }),
- agent: schema.object({
- migrations: schema.object({
- enabled: schema.boolean({ defaultValue: false }),
- }),
- }),
- }),
+ schema: configSchema,
};
-export type APMXPackConfig = TypeOf;
+export type APMXPackConfig = TypeOf;
export type APMConfig = ReturnType;
// plugin config and ui indices settings
diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_interval.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_interval.test.ts
deleted file mode 100644
index e6cf926d20bd7..0000000000000
--- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_interval.test.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0; you may not use this file except in compliance with the Elastic License
- * 2.0.
- */
-
-import type { estypes } from '@elastic/elasticsearch';
-
-import type { ElasticsearchClient } from 'src/core/server';
-import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
-
-import {
- fetchTransactionDurationHistogramInterval,
- getHistogramIntervalRequest,
-} from './query_histogram_interval';
-
-const params = {
- index: 'apm-*',
- start: '2020',
- end: '2021',
- includeFrozen: false,
- environment: ENVIRONMENT_ALL.value,
- kuery: '',
-};
-
-describe('query_histogram_interval', () => {
- describe('getHistogramIntervalRequest', () => {
- it('returns the request body for the transaction duration ranges aggregation', () => {
- const req = getHistogramIntervalRequest(params);
-
- expect(req).toEqual({
- body: {
- aggs: {
- transaction_duration_max: {
- max: {
- field: 'transaction.duration.us',
- },
- },
- transaction_duration_min: {
- min: {
- field: 'transaction.duration.us',
- },
- },
- },
- query: {
- bool: {
- filter: [
- {
- term: {
- 'processor.event': 'transaction',
- },
- },
- {
- range: {
- '@timestamp': {
- format: 'epoch_millis',
- gte: 1577836800000,
- lte: 1609459200000,
- },
- },
- },
- ],
- },
- },
- size: 0,
- },
- index: params.index,
- ignore_throttled: !params.includeFrozen,
- ignore_unavailable: true,
- });
- });
- });
-
- describe('fetchTransactionDurationHistogramInterval', () => {
- it('fetches the interval duration for histograms', async () => {
- const esClientSearchMock = jest.fn(
- (
- req: estypes.SearchRequest
- ): {
- body: estypes.SearchResponse;
- } => {
- return {
- body: {
- aggregations: {
- transaction_duration_max: {
- value: 10000,
- },
- transaction_duration_min: {
- value: 10,
- },
- },
- } as unknown as estypes.SearchResponse,
- };
- }
- );
-
- const esClientMock = {
- search: esClientSearchMock,
- } as unknown as ElasticsearchClient;
-
- const resp = await fetchTransactionDurationHistogramInterval(
- esClientMock,
- params
- );
-
- expect(resp).toEqual(10);
- expect(esClientSearchMock).toHaveBeenCalledTimes(1);
- });
- });
-});
diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_interval.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_interval.ts
deleted file mode 100644
index 906105003b716..0000000000000
--- a/x-pack/plugins/apm/server/lib/search_strategies/queries/query_histogram_interval.ts
+++ /dev/null
@@ -1,57 +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 type { estypes } from '@elastic/elasticsearch';
-
-import type { ElasticsearchClient } from 'src/core/server';
-
-import { TRANSACTION_DURATION } from '../../../../common/elasticsearch_fieldnames';
-import type { SearchStrategyParams } from '../../../../common/search_strategies/types';
-
-import { getQueryWithParams } from './get_query_with_params';
-import { getRequestBase } from './get_request_base';
-
-const HISTOGRAM_INTERVALS = 1000;
-
-export const getHistogramIntervalRequest = (
- params: SearchStrategyParams
-): estypes.SearchRequest => ({
- ...getRequestBase(params),
- body: {
- query: getQueryWithParams({ params }),
- size: 0,
- aggs: {
- transaction_duration_min: { min: { field: TRANSACTION_DURATION } },
- transaction_duration_max: { max: { field: TRANSACTION_DURATION } },
- },
- },
-});
-
-export const fetchTransactionDurationHistogramInterval = async (
- esClient: ElasticsearchClient,
- params: SearchStrategyParams
-): Promise => {
- const resp = await esClient.search(getHistogramIntervalRequest(params));
-
- if (resp.body.aggregations === undefined) {
- throw new Error(
- 'fetchTransactionDurationHistogramInterval failed, did not return aggregations.'
- );
- }
-
- const transactionDurationDelta =
- (
- resp.body.aggregations
- .transaction_duration_max as estypes.AggregationsValueAggregate
- ).value -
- (
- resp.body.aggregations
- .transaction_duration_min as estypes.AggregationsValueAggregate
- ).value;
-
- return transactionDurationDelta / (HISTOGRAM_INTERVALS - 1);
-};
diff --git a/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.test.ts b/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.test.ts
index b56ab83f547ff..6e03c879f9b97 100644
--- a/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.test.ts
+++ b/x-pack/plugins/apm/server/lib/search_strategies/search_strategy_provider.test.ts
@@ -57,17 +57,6 @@ const clientSearchMock = (
aggregations = { transaction_duration_percentiles: { values: {} } };
}
- // fetchTransactionDurationHistogramInterval
- if (
- aggs.transaction_duration_min !== undefined &&
- aggs.transaction_duration_max !== undefined
- ) {
- aggregations = {
- transaction_duration_min: { value: 0 },
- transaction_duration_max: { value: 1234 },
- };
- }
-
// fetchTransactionDurationCorrelation
if (aggs.logspace_ranges !== undefined) {
aggregations = { logspace_ranges: { buckets: [] } };
diff --git a/x-pack/plugins/apm/server/routes/correlations.ts b/x-pack/plugins/apm/server/routes/correlations.ts
deleted file mode 100644
index 4728aa2e8d3f6..0000000000000
--- a/x-pack/plugins/apm/server/routes/correlations.ts
+++ /dev/null
@@ -1,214 +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 Boom from '@hapi/boom';
-import { i18n } from '@kbn/i18n';
-import * as t from 'io-ts';
-import { isActivePlatinumLicense } from '../../common/license_check';
-import { getCorrelationsForFailedTransactions } from '../lib/correlations/errors/get_correlations_for_failed_transactions';
-import { getOverallErrorTimeseries } from '../lib/correlations/errors/get_overall_error_timeseries';
-import { getCorrelationsForSlowTransactions } from '../lib/correlations/latency/get_correlations_for_slow_transactions';
-import { getOverallLatencyDistribution } from '../lib/correlations/latency/get_overall_latency_distribution';
-import { setupRequest } from '../lib/helpers/setup_request';
-import { createApmServerRoute } from './create_apm_server_route';
-import { createApmServerRouteRepository } from './create_apm_server_route_repository';
-import { environmentRt, kueryRt, rangeRt } from './default_api_types';
-
-const INVALID_LICENSE = i18n.translate(
- 'xpack.apm.significanTerms.license.text',
- {
- defaultMessage:
- 'To use the correlations API, you must be subscribed to an Elastic Platinum license.',
- }
-);
-
-const correlationsLatencyDistributionRoute = createApmServerRoute({
- endpoint: 'GET /api/apm/correlations/latency/overall_distribution',
- params: t.type({
- query: t.intersection([
- t.partial({
- serviceName: t.string,
- transactionName: t.string,
- transactionType: t.string,
- }),
- environmentRt,
- kueryRt,
- rangeRt,
- ]),
- }),
- options: { tags: ['access:apm'] },
- handler: async (resources) => {
- const { context, params } = resources;
- if (!isActivePlatinumLicense(context.licensing.license)) {
- throw Boom.forbidden(INVALID_LICENSE);
- }
- const setup = await setupRequest(resources);
- const {
- environment,
- kuery,
- serviceName,
- transactionType,
- transactionName,
- } = params.query;
-
- return getOverallLatencyDistribution({
- environment,
- kuery,
- serviceName,
- transactionType,
- transactionName,
- setup,
- });
- },
-});
-
-const correlationsForSlowTransactionsRoute = createApmServerRoute({
- endpoint: 'GET /api/apm/correlations/latency/slow_transactions',
- params: t.type({
- query: t.intersection([
- t.partial({
- serviceName: t.string,
- transactionName: t.string,
- transactionType: t.string,
- }),
- t.type({
- durationPercentile: t.string,
- fieldNames: t.string,
- maxLatency: t.string,
- distributionInterval: t.string,
- }),
- environmentRt,
- kueryRt,
- rangeRt,
- ]),
- }),
- options: { tags: ['access:apm'] },
- handler: async (resources) => {
- const { context, params } = resources;
-
- if (!isActivePlatinumLicense(context.licensing.license)) {
- throw Boom.forbidden(INVALID_LICENSE);
- }
- const setup = await setupRequest(resources);
- const {
- environment,
- kuery,
- serviceName,
- transactionType,
- transactionName,
- durationPercentile,
- fieldNames,
- maxLatency,
- distributionInterval,
- } = params.query;
-
- return getCorrelationsForSlowTransactions({
- environment,
- kuery,
- serviceName,
- transactionType,
- transactionName,
- durationPercentile: parseInt(durationPercentile, 10),
- fieldNames: fieldNames.split(','),
- setup,
- maxLatency: parseInt(maxLatency, 10),
- distributionInterval: parseInt(distributionInterval, 10),
- });
- },
-});
-
-const correlationsErrorDistributionRoute = createApmServerRoute({
- endpoint: 'GET /api/apm/correlations/errors/overall_timeseries',
- params: t.type({
- query: t.intersection([
- t.partial({
- serviceName: t.string,
- transactionName: t.string,
- transactionType: t.string,
- }),
- environmentRt,
- kueryRt,
- rangeRt,
- ]),
- }),
- options: { tags: ['access:apm'] },
- handler: async (resources) => {
- const { params, context } = resources;
-
- if (!isActivePlatinumLicense(context.licensing.license)) {
- throw Boom.forbidden(INVALID_LICENSE);
- }
- const setup = await setupRequest(resources);
- const {
- environment,
- kuery,
- serviceName,
- transactionType,
- transactionName,
- } = params.query;
-
- return getOverallErrorTimeseries({
- environment,
- kuery,
- serviceName,
- transactionType,
- transactionName,
- setup,
- });
- },
-});
-
-const correlationsForFailedTransactionsRoute = createApmServerRoute({
- endpoint: 'GET /api/apm/correlations/errors/failed_transactions',
- params: t.type({
- query: t.intersection([
- t.partial({
- serviceName: t.string,
- transactionName: t.string,
- transactionType: t.string,
- }),
- t.type({
- fieldNames: t.string,
- }),
- environmentRt,
- kueryRt,
- rangeRt,
- ]),
- }),
- options: { tags: ['access:apm'] },
- handler: async (resources) => {
- const { context, params } = resources;
- if (!isActivePlatinumLicense(context.licensing.license)) {
- throw Boom.forbidden(INVALID_LICENSE);
- }
- const setup = await setupRequest(resources);
- const {
- environment,
- kuery,
- serviceName,
- transactionType,
- transactionName,
- fieldNames,
- } = params.query;
-
- return getCorrelationsForFailedTransactions({
- environment,
- kuery,
- serviceName,
- transactionType,
- transactionName,
- fieldNames: fieldNames.split(','),
- setup,
- });
- },
-});
-
-export const correlationsRouteRepository = createApmServerRouteRepository()
- .add(correlationsLatencyDistributionRoute)
- .add(correlationsForSlowTransactionsRoute)
- .add(correlationsErrorDistributionRoute)
- .add(correlationsForFailedTransactionsRoute);
diff --git a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts
index 9bc9108da9055..09756e30d9682 100644
--- a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts
+++ b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts
@@ -12,7 +12,6 @@ import type {
import { PickByValue } from 'utility-types';
import { alertsChartPreviewRouteRepository } from './alerts/chart_preview';
import { backendsRouteRepository } from './backends';
-import { correlationsRouteRepository } from './correlations';
import { createApmServerRouteRepository } from './create_apm_server_route_repository';
import { environmentsRouteRepository } from './environments';
import { errorsRouteRepository } from './errors';
@@ -49,7 +48,6 @@ const getTypedGlobalApmServerRouteRepository = () => {
.merge(traceRouteRepository)
.merge(transactionRouteRepository)
.merge(alertsChartPreviewRouteRepository)
- .merge(correlationsRouteRepository)
.merge(agentConfigurationRouteRepository)
.merge(anomalyDetectionRouteRepository)
.merge(apmIndicesRouteRepository)
diff --git a/x-pack/plugins/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx
index 30b2d78a6b1fe..f2fe944bfd45d 100644
--- a/x-pack/plugins/canvas/public/application.tsx
+++ b/x-pack/plugins/canvas/public/application.tsx
@@ -98,11 +98,10 @@ export const initializeCanvas = async (
setupPlugins: CanvasSetupDeps,
startPlugins: CanvasStartDeps,
registries: SetupRegistries,
- appUpdater: BehaviorSubject,
- pluginServices: PluginServices
+ appUpdater: BehaviorSubject
) => {
await startLegacyServices(coreSetup, coreStart, setupPlugins, startPlugins, appUpdater);
- const { expressions } = pluginServices.getServices();
+ const { expressions } = setupPlugins;
// Adding these functions here instead of in plugin.ts.
// Some of these functions have deep dependencies into Canvas, which was bulking up the size
diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx
index 555cedb6b16a1..bd5d884f1485c 100644
--- a/x-pack/plugins/canvas/public/plugin.tsx
+++ b/x-pack/plugins/canvas/public/plugin.tsx
@@ -132,8 +132,7 @@ export class CanvasPlugin
setupPlugins,
startPlugins,
registries,
- this.appUpdater,
- pluginServices
+ this.appUpdater
);
const unmount = renderApp({ coreStart, startPlugins, params, canvasStore, pluginServices });
diff --git a/x-pack/plugins/canvas/public/services/expressions.ts b/x-pack/plugins/canvas/public/services/expressions.ts
index a1af0fba50a5c..01bb0adb17711 100644
--- a/x-pack/plugins/canvas/public/services/expressions.ts
+++ b/x-pack/plugins/canvas/public/services/expressions.ts
@@ -5,6 +5,6 @@
* 2.0.
*/
-import { ExpressionsService } from '../../../../../src/plugins/expressions/public';
+import { ExpressionsServiceStart } from '../../../../../src/plugins/expressions/public';
-export type CanvasExpressionsService = ExpressionsService;
+export type CanvasExpressionsService = ExpressionsServiceStart;
diff --git a/x-pack/plugins/canvas/public/services/kibana/expressions.ts b/x-pack/plugins/canvas/public/services/kibana/expressions.ts
index 4e3bb52a5d449..780de5309d97e 100644
--- a/x-pack/plugins/canvas/public/services/kibana/expressions.ts
+++ b/x-pack/plugins/canvas/public/services/kibana/expressions.ts
@@ -16,4 +16,4 @@ export type CanvasExpressionsServiceFactory = KibanaPluginServiceFactory<
>;
export const expressionsServiceFactory: CanvasExpressionsServiceFactory = ({ startPlugins }) =>
- startPlugins.expressions.fork();
+ startPlugins.expressions;
diff --git a/x-pack/plugins/canvas/server/saved_objects/workpad_references.ts b/x-pack/plugins/canvas/server/saved_objects/workpad_references.ts
index b0d20add2f79a..e9eefa1bdb3f4 100644
--- a/x-pack/plugins/canvas/server/saved_objects/workpad_references.ts
+++ b/x-pack/plugins/canvas/server/saved_objects/workpad_references.ts
@@ -6,14 +6,15 @@
*/
import { fromExpression, toExpression } from '@kbn/interpreter/common';
+import { PersistableStateService } from '../../../../../src/plugins/kibana_utils/common';
import { SavedObjectReference } from '../../../../../src/core/server';
import { WorkpadAttributes } from '../routes/workpad/workpad_attributes';
-import { ExpressionsServerSetup } from '../../../../../src/plugins/expressions/server';
+import type { ExpressionAstExpression } from '../../../../../src/plugins/expressions';
export const extractReferences = (
workpad: WorkpadAttributes,
- expressions: ExpressionsServerSetup
+ expressions: PersistableStateService
): { workpad: WorkpadAttributes; references: SavedObjectReference[] } => {
// We need to find every element in the workpad and extract references
const references: SavedObjectReference[] = [];
@@ -42,7 +43,7 @@ export const extractReferences = (
export const injectReferences = (
workpad: WorkpadAttributes,
references: SavedObjectReference[],
- expressions: ExpressionsServerSetup
+ expressions: PersistableStateService
) => {
const pages = workpad.pages.map((page) => {
const elements = page.elements.map((element) => {
diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts
index 37a491cdad4c0..cd26ca0bab977 100644
--- a/x-pack/plugins/cases/common/api/cases/case.ts
+++ b/x-pack/plugins/cases/common/api/cases/case.ts
@@ -87,8 +87,11 @@ const CaseBasicRt = rt.type({
owner: rt.string,
});
-export const CaseExternalServiceBasicRt = rt.type({
- connector_id: rt.union([rt.string, rt.null]),
+/**
+ * This represents the push to service UserAction. It lacks the connector_id because that is stored in a different field
+ * within the user action object in the API response.
+ */
+export const CaseUserActionExternalServiceRt = rt.type({
connector_name: rt.string,
external_id: rt.string,
external_title: rt.string,
@@ -97,7 +100,14 @@ export const CaseExternalServiceBasicRt = rt.type({
pushed_by: UserRT,
});
-const CaseFullExternalServiceRt = rt.union([CaseExternalServiceBasicRt, rt.null]);
+export const CaseExternalServiceBasicRt = rt.intersection([
+ rt.type({
+ connector_id: rt.union([rt.string, rt.null]),
+ }),
+ CaseUserActionExternalServiceRt,
+]);
+
+export const CaseFullExternalServiceRt = rt.union([CaseExternalServiceBasicRt, rt.null]);
export const CaseAttributesRt = rt.intersection([
CaseBasicRt,
@@ -244,6 +254,16 @@ export const CaseResponseRt = rt.intersection([
}),
]);
+export const CaseResolveResponseRt = rt.intersection([
+ rt.type({
+ case: CaseResponseRt,
+ outcome: rt.union([rt.literal('exactMatch'), rt.literal('aliasMatch'), rt.literal('conflict')]),
+ }),
+ rt.partial({
+ alias_target_id: rt.string,
+ }),
+]);
+
export const CasesFindResponseRt = rt.intersection([
rt.type({
cases: rt.array(CaseResponseRt),
@@ -309,6 +329,7 @@ export type CaseAttributes = rt.TypeOf;
export type CasesClientPostRequest = rt.TypeOf;
export type CasePostRequest = rt.TypeOf;
export type CaseResponse = rt.TypeOf;
+export type CaseResolveResponse = rt.TypeOf;
export type CasesResponse = rt.TypeOf;
export type CasesFindRequest = rt.TypeOf;
export type CasesByAlertIDRequest = rt.TypeOf;
diff --git a/x-pack/plugins/cases/common/api/cases/user_actions.ts b/x-pack/plugins/cases/common/api/cases/user_actions.ts
index 03912c550d77a..e86ce5248a6f9 100644
--- a/x-pack/plugins/cases/common/api/cases/user_actions.ts
+++ b/x-pack/plugins/cases/common/api/cases/user_actions.ts
@@ -34,7 +34,6 @@ const UserActionRt = rt.union([
rt.literal('push-to-service'),
]);
-// TO DO change state to status
const CaseUserActionBasicRT = rt.type({
action_field: UserActionFieldRt,
action: UserActionRt,
@@ -51,6 +50,8 @@ const CaseUserActionResponseRT = rt.intersection([
action_id: rt.string,
case_id: rt.string,
comment_id: rt.union([rt.string, rt.null]),
+ new_val_connector_id: rt.union([rt.string, rt.null]),
+ old_val_connector_id: rt.union([rt.string, rt.null]),
}),
rt.partial({ sub_case_id: rt.string }),
]);
diff --git a/x-pack/plugins/cases/common/api/connectors/index.ts b/x-pack/plugins/cases/common/api/connectors/index.ts
index 77af90b5d08cb..2b3483b4f6184 100644
--- a/x-pack/plugins/cases/common/api/connectors/index.ts
+++ b/x-pack/plugins/cases/common/api/connectors/index.ts
@@ -84,14 +84,22 @@ export const ConnectorTypeFieldsRt = rt.union([
ConnectorSwimlaneTypeFieldsRt,
]);
+/**
+ * This type represents the connector's format when it is encoded within a user action.
+ */
+export const CaseUserActionConnectorRt = rt.intersection([
+ rt.type({ name: rt.string }),
+ ConnectorTypeFieldsRt,
+]);
+
export const CaseConnectorRt = rt.intersection([
rt.type({
id: rt.string,
- name: rt.string,
}),
- ConnectorTypeFieldsRt,
+ CaseUserActionConnectorRt,
]);
+export type CaseUserActionConnector = rt.TypeOf;
export type CaseConnector = rt.TypeOf;
export type ConnectorTypeFields = rt.TypeOf;
export type ConnectorJiraTypeFields = rt.TypeOf;
diff --git a/x-pack/plugins/cases/common/index.ts b/x-pack/plugins/cases/common/index.ts
index 5305318cc9aa6..d38b1a779981c 100644
--- a/x-pack/plugins/cases/common/index.ts
+++ b/x-pack/plugins/cases/common/index.ts
@@ -12,3 +12,4 @@ export * from './constants';
export * from './api';
export * from './ui/types';
export * from './utils/connectors_api';
+export * from './utils/user_actions';
diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts
index bf4ec0da6ee56..948b203af14a8 100644
--- a/x-pack/plugins/cases/common/ui/types.ts
+++ b/x-pack/plugins/cases/common/ui/types.ts
@@ -66,7 +66,9 @@ export interface CaseUserActions {
caseId: string;
commentId: string | null;
newValue: string | null;
+ newValConnectorId: string | null;
oldValue: string | null;
+ oldValConnectorId: string | null;
}
export interface CaseExternalService {
@@ -112,6 +114,12 @@ export interface Case extends BasicCase {
type: CaseType;
}
+export interface ResolvedCase {
+ case: Case;
+ outcome: 'exactMatch' | 'aliasMatch' | 'conflict';
+ aliasTargetId?: string;
+}
+
export interface QueryParams {
page: number;
perPage: number;
diff --git a/x-pack/plugins/cases/common/utils/user_actions.ts b/x-pack/plugins/cases/common/utils/user_actions.ts
new file mode 100644
index 0000000000000..7de0d7066eaed
--- /dev/null
+++ b/x-pack/plugins/cases/common/utils/user_actions.ts
@@ -0,0 +1,18 @@
+/*
+ * 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 function isCreateConnector(action?: string, actionFields?: string[]): boolean {
+ return action === 'create' && actionFields != null && actionFields.includes('connector');
+}
+
+export function isUpdateConnector(action?: string, actionFields?: string[]): boolean {
+ return action === 'update' && actionFields != null && actionFields.includes('connector');
+}
+
+export function isPush(action?: string, actionFields?: string[]): boolean {
+ return action === 'push-to-service' && actionFields != null && actionFields.includes('pushed');
+}
diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts
index 18370be61bdf1..09f0215f5629f 100644
--- a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts
+++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.ts
@@ -13,6 +13,7 @@ import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_r
import { StartServices } from '../../../types';
import { EuiTheme } from '../../../../../../../src/plugins/kibana_react/common';
import { securityMock } from '../../../../../security/public/mocks';
+import { spacesPluginMock } from '../../../../../spaces/public/mocks';
import { triggersActionsUiMock } from '../../../../../triggers_actions_ui/public/mocks';
export const createStartServicesMock = (): StartServices =>
@@ -25,6 +26,7 @@ export const createStartServicesMock = (): StartServices =>
},
security: securityMock.createStart(),
triggersActionsUi: triggersActionsUiMock.createStart(),
+ spaces: spacesPluginMock.createStartContract(),
} as unknown as StartServices);
export const createWithKibanaMock = () => {
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/user_icon/index.ts b/x-pack/plugins/cases/public/common/user_actions/index.ts
similarity index 86%
rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/user_icon/index.ts
rename to x-pack/plugins/cases/public/common/user_actions/index.ts
index ce1039f6ad0fd..507455f7102a7 100644
--- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/user_icon/index.ts
+++ b/x-pack/plugins/cases/public/common/user_actions/index.ts
@@ -5,4 +5,4 @@
* 2.0.
*/
-export { UserIcon } from './user_icon';
+export * from './parsers';
diff --git a/x-pack/plugins/cases/public/common/user_actions/parsers.test.ts b/x-pack/plugins/cases/public/common/user_actions/parsers.test.ts
new file mode 100644
index 0000000000000..c6d13cc41686c
--- /dev/null
+++ b/x-pack/plugins/cases/public/common/user_actions/parsers.test.ts
@@ -0,0 +1,86 @@
+/*
+ * 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 { ConnectorTypes, noneConnectorId } from '../../../common';
+import { parseStringAsConnector, parseStringAsExternalService } from './parsers';
+
+describe('user actions utility functions', () => {
+ describe('parseStringAsConnector', () => {
+ it('return null if the data is null', () => {
+ expect(parseStringAsConnector('', null)).toBeNull();
+ });
+
+ it('return null if the data is not a json object', () => {
+ expect(parseStringAsConnector('', 'blah')).toBeNull();
+ });
+
+ it('return null if the data is not a valid connector', () => {
+ expect(parseStringAsConnector('', JSON.stringify({ a: '1' }))).toBeNull();
+ });
+
+ it('return null if id is null but the data is a connector other than none', () => {
+ expect(
+ parseStringAsConnector(
+ null,
+ JSON.stringify({ type: ConnectorTypes.jira, name: '', fields: null })
+ )
+ ).toBeNull();
+ });
+
+ it('return the id as the none connector if the data is the none connector', () => {
+ expect(
+ parseStringAsConnector(
+ null,
+ JSON.stringify({ type: ConnectorTypes.none, name: '', fields: null })
+ )
+ ).toEqual({ id: noneConnectorId, type: ConnectorTypes.none, name: '', fields: null });
+ });
+
+ it('returns a decoded connector with the specified id', () => {
+ expect(
+ parseStringAsConnector(
+ 'a',
+ JSON.stringify({ type: ConnectorTypes.jira, name: 'hi', fields: null })
+ )
+ ).toEqual({ id: 'a', type: ConnectorTypes.jira, name: 'hi', fields: null });
+ });
+ });
+
+ describe('parseStringAsExternalService', () => {
+ it('returns null when the data is null', () => {
+ expect(parseStringAsExternalService('', null)).toBeNull();
+ });
+
+ it('returns null when the data is not valid json', () => {
+ expect(parseStringAsExternalService('', 'blah')).toBeNull();
+ });
+
+ it('returns null when the data is not a valid external service object', () => {
+ expect(parseStringAsExternalService('', JSON.stringify({ a: '1' }))).toBeNull();
+ });
+
+ it('returns the decoded external service with the connector_id field added', () => {
+ const externalServiceInfo = {
+ connector_name: 'name',
+ external_id: '1',
+ external_title: 'title',
+ external_url: 'abc',
+ pushed_at: '1',
+ pushed_by: {
+ username: 'a',
+ email: 'a@a.com',
+ full_name: 'a',
+ },
+ };
+
+ expect(parseStringAsExternalService('500', JSON.stringify(externalServiceInfo))).toEqual({
+ ...externalServiceInfo,
+ connector_id: '500',
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/cases/public/common/user_actions/parsers.ts b/x-pack/plugins/cases/public/common/user_actions/parsers.ts
new file mode 100644
index 0000000000000..dfea22443aa51
--- /dev/null
+++ b/x-pack/plugins/cases/public/common/user_actions/parsers.ts
@@ -0,0 +1,77 @@
+/*
+ * 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 {
+ CaseUserActionConnectorRt,
+ CaseConnector,
+ ConnectorTypes,
+ noneConnectorId,
+ CaseFullExternalService,
+ CaseUserActionExternalServiceRt,
+} from '../../../common';
+
+export const parseStringAsConnector = (
+ id: string | null,
+ encodedData: string | null
+): CaseConnector | null => {
+ if (encodedData == null) {
+ return null;
+ }
+
+ const decodedConnector = parseString(encodedData);
+
+ if (!CaseUserActionConnectorRt.is(decodedConnector)) {
+ return null;
+ }
+
+ if (id == null && decodedConnector.type === ConnectorTypes.none) {
+ return {
+ ...decodedConnector,
+ id: noneConnectorId,
+ };
+ } else if (id == null) {
+ return null;
+ } else {
+ // id does not equal null or undefined and the connector type does not equal none
+ // so return the connector with its id
+ return {
+ ...decodedConnector,
+ id,
+ };
+ }
+};
+
+const parseString = (params: string | null): unknown | null => {
+ if (params == null) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(params);
+ } catch {
+ return null;
+ }
+};
+
+export const parseStringAsExternalService = (
+ id: string | null,
+ encodedData: string | null
+): CaseFullExternalService => {
+ if (encodedData == null) {
+ return null;
+ }
+
+ const decodedExternalService = parseString(encodedData);
+ if (!CaseUserActionExternalServiceRt.is(decodedExternalService)) {
+ return null;
+ }
+
+ return {
+ ...decodedExternalService,
+ connector_id: id,
+ };
+};
diff --git a/x-pack/plugins/cases/public/components/case_view/index.test.tsx b/x-pack/plugins/cases/public/components/case_view/index.test.tsx
index f12c8ba098d43..6fc9e1719e1cf 100644
--- a/x-pack/plugins/cases/public/components/case_view/index.test.tsx
+++ b/x-pack/plugins/cases/public/components/case_view/index.test.tsx
@@ -18,6 +18,7 @@ import {
getAlertUserAction,
} from '../../containers/mock';
import { TestProviders } from '../../common/mock';
+import { SpacesApi } from '../../../../spaces/public';
import { useUpdateCase } from '../../containers/use_update_case';
import { useGetCase } from '../../containers/use_get_case';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
@@ -47,6 +48,13 @@ const useConnectorsMock = useConnectors as jest.Mock;
const usePostPushToServiceMock = usePostPushToService as jest.Mock;
const useKibanaMock = useKibana as jest.Mocked;
+const spacesUiApiMock = {
+ redirectLegacyUrl: jest.fn().mockResolvedValue(undefined),
+ components: {
+ getLegacyUrlConflict: jest.fn().mockReturnValue(),
+ },
+};
+
const alertsHit = [
{
_id: 'alert-id-1',
@@ -138,6 +146,7 @@ describe('CaseView ', () => {
isLoading: false,
isError: false,
data,
+ resolveOutcome: 'exactMatch',
updateCase,
fetchCase,
};
@@ -174,6 +183,7 @@ describe('CaseView ', () => {
actionTypeTitle: '.servicenow',
iconClass: 'logoSecurity',
});
+ useKibanaMock().services.spaces = { ui: spacesUiApiMock } as unknown as SpacesApi;
});
it('should render CaseComponent', async () => {
@@ -395,36 +405,7 @@ describe('CaseView ', () => {
}));
const wrapper = mount(
-
+
);
await waitFor(() => {
@@ -439,36 +420,7 @@ describe('CaseView ', () => {
}));
const wrapper = mount(
-
+
);
await waitFor(() => {
@@ -477,43 +429,66 @@ describe('CaseView ', () => {
});
it('should return case view when data is there', async () => {
- (useGetCase as jest.Mock).mockImplementation(() => defaultGetCase);
+ (useGetCase as jest.Mock).mockImplementation(() => ({
+ ...defaultGetCase,
+ resolveOutcome: 'exactMatch',
+ }));
const wrapper = mount(
-
+
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="case-view-title"]').exists()).toBeTruthy();
+ expect(spacesUiApiMock.components.getLegacyUrlConflict).not.toHaveBeenCalled();
+ expect(spacesUiApiMock.redirectLegacyUrl).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should redirect case view when resolves to alias match', async () => {
+ const resolveAliasId = `${defaultGetCase.data.id}_2`;
+ (useGetCase as jest.Mock).mockImplementation(() => ({
+ ...defaultGetCase,
+ resolveOutcome: 'aliasMatch',
+ resolveAliasId,
+ }));
+ const wrapper = mount(
+
+
+
+ );
+ await waitFor(() => {
+ expect(wrapper.find('[data-test-subj="case-view-title"]').exists()).toBeTruthy();
+ expect(spacesUiApiMock.components.getLegacyUrlConflict).not.toHaveBeenCalled();
+ expect(spacesUiApiMock.redirectLegacyUrl).toHaveBeenCalledWith(
+ `cases/${resolveAliasId}`,
+ 'case'
+ );
+ });
+ });
+
+ it('should redirect case view when resolves to conflict', async () => {
+ const resolveAliasId = `${defaultGetCase.data.id}_2`;
+ (useGetCase as jest.Mock).mockImplementation(() => ({
+ ...defaultGetCase,
+ resolveOutcome: 'conflict',
+ resolveAliasId,
+ }));
+ const wrapper = mount(
+
+
+
+ );
+ await waitFor(() => {
+ expect(wrapper.find('[data-test-subj="case-view-title"]').exists()).toBeTruthy();
+ expect(wrapper.find('[data-test-subj="conflict-component"]').exists()).toBeTruthy();
+ expect(spacesUiApiMock.redirectLegacyUrl).not.toHaveBeenCalled();
+ expect(spacesUiApiMock.components.getLegacyUrlConflict).toHaveBeenCalledWith({
+ objectNoun: 'case',
+ currentObjectId: defaultGetCase.data.id,
+ otherObjectId: resolveAliasId,
+ otherObjectPath: `cases/${resolveAliasId}`,
+ });
});
});
@@ -521,41 +496,12 @@ describe('CaseView ', () => {
(useGetCase as jest.Mock).mockImplementation(() => defaultGetCase);
const wrapper = mount(
-
+
);
wrapper.find('[data-test-subj="case-refresh"]').first().simulate('click');
await waitFor(() => {
- expect(fetchCaseUserActions).toBeCalledWith('1234', 'resilient-2', undefined);
+ expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id, 'resilient-2', undefined);
expect(fetchCase).toBeCalled();
});
});
diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx
index bb0b894238b9d..81e7607c9011f 100644
--- a/x-pack/plugins/cases/public/components/case_view/index.tsx
+++ b/x-pack/plugins/cases/public/components/case_view/index.tsx
@@ -40,6 +40,7 @@ import { CasesNavigation } from '../links';
import { OwnerProvider } from '../owner_context';
import { getConnectorById } from '../utils';
import { DoesNotExist } from './does_not_exist';
+import { useKibana } from '../../common/lib/kibana';
export interface CaseViewComponentProps {
allCasesNavigation: CasesNavigation;
@@ -499,6 +500,14 @@ export const CaseComponent = React.memo(
}
);
+export const CaseViewLoading = () => (
+
+
+
+
+
+);
+
export const CaseView = React.memo(
({
allCasesNavigation,
@@ -518,27 +527,59 @@ export const CaseView = React.memo(
refreshRef,
hideSyncAlerts,
}: CaseViewProps) => {
- const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId);
- if (isError) {
- return ;
- }
- if (isLoading) {
- return (
-
-
-
-
-
- );
- }
- if (onCaseDataSuccess && data) {
- onCaseDataSuccess(data);
- }
+ const { data, resolveOutcome, resolveAliasId, isLoading, isError, fetchCase, updateCase } =
+ useGetCase(caseId, subCaseId);
+ const { spaces: spacesApi, http } = useKibana().services;
- return (
+ useEffect(() => {
+ if (onCaseDataSuccess && data) {
+ onCaseDataSuccess(data);
+ }
+ }, [data, onCaseDataSuccess]);
+
+ useEffect(() => {
+ if (spacesApi && resolveOutcome === 'aliasMatch' && resolveAliasId != null) {
+ // CAUTION: the path /cases/:detailName is working in both Observability (/app/observability/cases/:detailName) and
+ // Security Solutions (/app/security/cases/:detailName) plugins. This will need to be changed if this component is loaded
+ // under any another path, passing a path builder function by props from every parent plugin.
+ const newPath = http.basePath.prepend(
+ `cases/${resolveAliasId}${window.location.search}${window.location.hash}`
+ );
+ spacesApi.ui.redirectLegacyUrl(newPath, i18n.CASE);
+ }
+ }, [resolveOutcome, resolveAliasId, spacesApi, http]);
+
+ const getLegacyUrlConflictCallout = useCallback(() => {
+ // This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario
+ if (data && spacesApi && resolveOutcome === 'conflict' && resolveAliasId != null) {
+ // We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a
+ // callout with a warning for the user, and provide a way for them to navigate to the other object.
+ const otherObjectId = resolveAliasId; // This is always defined if outcome === 'conflict'
+ // CAUTION: the path /cases/:detailName is working in both Observability (/app/observability/cases/:detailName) and
+ // Security Solutions (/app/security/cases/:detailName) plugins. This will need to be changed if this component is loaded
+ // under any another path, passing a path builder function by props from every parent plugin.
+ const otherObjectPath = http.basePath.prepend(
+ `cases/${otherObjectId}${window.location.search}${window.location.hash}`
+ );
+ return spacesApi.ui.components.getLegacyUrlConflict({
+ objectNoun: i18n.CASE,
+ currentObjectId: data.id,
+ otherObjectId,
+ otherObjectPath,
+ });
+ }
+ return null;
+ }, [data, resolveAliasId, resolveOutcome, spacesApi, http.basePath]);
+
+ return isError ? (
+
+ ) : isLoading ? (
+
+ ) : (
data && (
+ {getLegacyUrlConflictCallout()}
{
+ describe('getConnectorFieldsFromUserActions', () => {
+ it('returns null when it cannot find the connector id', () => {
+ expect(getConnectorFieldsFromUserActions('a', [])).toBeNull();
+ });
+
+ it('returns null when the value fields are not valid encoded fields', () => {
+ expect(
+ getConnectorFieldsFromUserActions('a', [createUserAction({ newValue: 'a', oldValue: 'a' })])
+ ).toBeNull();
+ });
+
+ it('returns null when it cannot find the connector id in a non empty array', () => {
+ expect(
+ getConnectorFieldsFromUserActions('a', [
+ createUserAction({
+ newValue: JSON.stringify({ a: '1' }),
+ oldValue: JSON.stringify({ a: '1' }),
+ }),
+ ])
+ ).toBeNull();
+ });
+
+ it('returns the fields when it finds the connector id in the new value', () => {
+ expect(
+ getConnectorFieldsFromUserActions('a', [
+ createUserAction({
+ newValue: createEncodedJiraConnector(),
+ oldValue: JSON.stringify({ a: '1' }),
+ newValConnectorId: 'a',
+ }),
+ ])
+ ).toEqual(defaultJiraFields);
+ });
+
+ it('returns the fields when it finds the connector id in the new value and the old value is null', () => {
+ expect(
+ getConnectorFieldsFromUserActions('a', [
+ createUserAction({
+ newValue: createEncodedJiraConnector(),
+ newValConnectorId: 'a',
+ }),
+ ])
+ ).toEqual(defaultJiraFields);
+ });
+
+ it('returns the fields when it finds the connector id in the old value', () => {
+ const expectedFields = { ...defaultJiraFields, issueType: '5' };
+
+ expect(
+ getConnectorFieldsFromUserActions('id-to-find', [
+ createUserAction({
+ newValue: createEncodedJiraConnector(),
+ oldValue: createEncodedJiraConnector({
+ fields: expectedFields,
+ }),
+ newValConnectorId: 'b',
+ oldValConnectorId: 'id-to-find',
+ }),
+ ])
+ ).toEqual(expectedFields);
+ });
+
+ it('returns the fields when it finds the connector id in the second user action', () => {
+ const expectedFields = { ...defaultJiraFields, issueType: '5' };
+
+ expect(
+ getConnectorFieldsFromUserActions('id-to-find', [
+ createUserAction({
+ newValue: createEncodedJiraConnector(),
+ oldValue: createEncodedJiraConnector(),
+ newValConnectorId: 'b',
+ oldValConnectorId: 'a',
+ }),
+ createUserAction({
+ newValue: createEncodedJiraConnector(),
+ oldValue: createEncodedJiraConnector({ fields: expectedFields }),
+ newValConnectorId: 'b',
+ oldValConnectorId: 'id-to-find',
+ }),
+ ])
+ ).toEqual(expectedFields);
+ });
+
+ it('ignores a parse failure and finds the right user action', () => {
+ expect(
+ getConnectorFieldsFromUserActions('none', [
+ createUserAction({
+ newValue: 'b',
+ newValConnectorId: null,
+ }),
+ createUserAction({
+ newValue: createEncodedJiraConnector({
+ type: ConnectorTypes.none,
+ name: '',
+ fields: null,
+ }),
+ newValConnectorId: null,
+ }),
+ ])
+ ).toBeNull();
+ });
+
+ it('returns null when the id matches but the encoded value is null', () => {
+ expect(
+ getConnectorFieldsFromUserActions('b', [
+ createUserAction({
+ newValue: null,
+ newValConnectorId: 'b',
+ }),
+ ])
+ ).toBeNull();
+ });
+
+ it('returns null when the action fields is not of length 1', () => {
+ expect(
+ getConnectorFieldsFromUserActions('id-to-find', [
+ createUserAction({
+ newValue: JSON.stringify({ a: '1', fields: { hello: '1' } }),
+ oldValue: JSON.stringify({ a: '1', fields: { hi: '2' } }),
+ newValConnectorId: 'b',
+ oldValConnectorId: 'id-to-find',
+ actionField: ['connector', 'connector'],
+ }),
+ ])
+ ).toBeNull();
+ });
+
+ it('matches the none connector the searched for id is none', () => {
+ expect(
+ getConnectorFieldsFromUserActions('none', [
+ createUserAction({
+ newValue: createEncodedJiraConnector({
+ type: ConnectorTypes.none,
+ name: '',
+ fields: null,
+ }),
+ newValConnectorId: null,
+ }),
+ ])
+ ).toBeNull();
+ });
+ });
+});
+
+function createUserAction(fields: Partial): CaseUserActions {
+ return {
+ action: 'update',
+ actionAt: '',
+ actionBy: {},
+ actionField: ['connector'],
+ actionId: '',
+ caseId: '',
+ commentId: '',
+ newValConnectorId: null,
+ oldValConnectorId: null,
+ newValue: null,
+ oldValue: null,
+ ...fields,
+ };
+}
+
+function createEncodedJiraConnector(fields?: Partial): string {
+ return JSON.stringify({
+ type: ConnectorTypes.jira,
+ name: 'name',
+ fields: defaultJiraFields,
+ ...fields,
+ });
+}
+
+const defaultJiraFields = {
+ issueType: '1',
+ parent: null,
+ priority: null,
+};
diff --git a/x-pack/plugins/cases/public/components/edit_connector/helpers.ts b/x-pack/plugins/cases/public/components/edit_connector/helpers.ts
index 36eb3f58c8aaf..b97035c458aca 100644
--- a/x-pack/plugins/cases/public/components/edit_connector/helpers.ts
+++ b/x-pack/plugins/cases/public/components/edit_connector/helpers.ts
@@ -5,23 +5,33 @@
* 2.0.
*/
+import { ConnectorTypeFields } from '../../../common';
import { CaseUserActions } from '../../containers/types';
+import { parseStringAsConnector } from '../../common/user_actions';
-export const getConnectorFieldsFromUserActions = (id: string, userActions: CaseUserActions[]) => {
+export const getConnectorFieldsFromUserActions = (
+ id: string,
+ userActions: CaseUserActions[]
+): ConnectorTypeFields['fields'] => {
try {
for (const action of [...userActions].reverse()) {
if (action.actionField.length === 1 && action.actionField[0] === 'connector') {
- if (action.oldValue && action.newValue) {
- const oldValue = JSON.parse(action.oldValue);
- const newValue = JSON.parse(action.newValue);
+ const parsedNewConnector = parseStringAsConnector(
+ action.newValConnectorId,
+ action.newValue
+ );
- if (newValue.id === id) {
- return newValue.fields;
- }
+ if (parsedNewConnector && id === parsedNewConnector.id) {
+ return parsedNewConnector.fields;
+ }
+
+ const parsedOldConnector = parseStringAsConnector(
+ action.oldValConnectorId,
+ action.oldValue
+ );
- if (oldValue.id === id) {
- return oldValue.fields;
- }
+ if (parsedOldConnector && id === parsedOldConnector.id) {
+ return parsedOldConnector.fields;
}
}
}
diff --git a/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx
index b49a010cff38f..841f0d36bbf17 100644
--- a/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx
+++ b/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx
@@ -8,7 +8,7 @@
import React from 'react';
import { mount } from 'enzyme';
-import { CaseStatuses } from '../../../common';
+import { CaseStatuses, ConnectorTypes } from '../../../common';
import { basicPush, getUserAction } from '../../containers/mock';
import {
getLabelTitle,
@@ -129,7 +129,7 @@ describe('User action tree helpers', () => {
`${i18n.PUSHED_NEW_INCIDENT} ${basicPush.connectorName}`
);
expect(wrapper.find(`[data-test-subj="pushed-value"]`).first().prop('href')).toEqual(
- JSON.parse(action.newValue).external_url
+ JSON.parse(action.newValue!).external_url
);
});
@@ -142,50 +142,74 @@ describe('User action tree helpers', () => {
`${i18n.UPDATE_INCIDENT} ${basicPush.connectorName}`
);
expect(wrapper.find(`[data-test-subj="pushed-value"]`).first().prop('href')).toEqual(
- JSON.parse(action.newValue).external_url
+ JSON.parse(action.newValue!).external_url
);
});
- it('label title generated for update connector - change connector', () => {
- const action = {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: 'servicenow-1' }),
- newValue: JSON.stringify({ id: 'resilient-2' }),
- };
- const result: string | JSX.Element = getConnectorLabelTitle({
- action,
- connectors,
- });
-
- expect(result).toEqual('selected My Connector 2 as incident management system');
- });
-
- it('label title generated for update connector - change connector to none', () => {
- const action = {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: 'servicenow-1' }),
- newValue: JSON.stringify({ id: 'none' }),
- };
- const result: string | JSX.Element = getConnectorLabelTitle({
- action,
- connectors,
+ describe('getConnectorLabelTitle', () => {
+ it('returns an empty string when the encoded old value is null', () => {
+ const result = getConnectorLabelTitle({
+ action: getUserAction(['connector'], 'update', { oldValue: null }),
+ connectors,
+ });
+
+ expect(result).toEqual('');
+ });
+
+ it('returns an empty string when the encoded new value is null', () => {
+ const result = getConnectorLabelTitle({
+ action: getUserAction(['connector'], 'update', { newValue: null }),
+ connectors,
+ });
+
+ expect(result).toEqual('');
+ });
+
+ it('returns the change connector label', () => {
+ const result: string | JSX.Element = getConnectorLabelTitle({
+ action: getUserAction(['connector'], 'update', {
+ oldValue: JSON.stringify({
+ type: ConnectorTypes.serviceNowITSM,
+ name: 'a',
+ fields: null,
+ }),
+ oldValConnectorId: 'servicenow-1',
+ newValue: JSON.stringify({ type: ConnectorTypes.resilient, name: 'a', fields: null }),
+ newValConnectorId: 'resilient-2',
+ }),
+ connectors,
+ });
+
+ expect(result).toEqual('selected My Connector 2 as incident management system');
+ });
+
+ it('returns the removed connector label', () => {
+ const result: string | JSX.Element = getConnectorLabelTitle({
+ action: getUserAction(['connector'], 'update', {
+ oldValue: JSON.stringify({ type: ConnectorTypes.serviceNowITSM, name: '', fields: null }),
+ oldValConnectorId: 'servicenow-1',
+ newValue: JSON.stringify({ type: ConnectorTypes.none, name: '', fields: null }),
+ newValConnectorId: 'none',
+ }),
+ connectors,
+ });
+
+ expect(result).toEqual('removed external incident management system');
+ });
+
+ it('returns the connector fields changed label', () => {
+ const result: string | JSX.Element = getConnectorLabelTitle({
+ action: getUserAction(['connector'], 'update', {
+ oldValue: JSON.stringify({ type: ConnectorTypes.serviceNowITSM, name: '', fields: null }),
+ oldValConnectorId: 'servicenow-1',
+ newValue: JSON.stringify({ type: ConnectorTypes.serviceNowITSM, name: '', fields: null }),
+ newValConnectorId: 'servicenow-1',
+ }),
+ connectors,
+ });
+
+ expect(result).toEqual('changed connector field');
});
-
- expect(result).toEqual('removed external incident management system');
- });
-
- it('label title generated for update connector - field change', () => {
- const action = {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: 'servicenow-1' }),
- newValue: JSON.stringify({ id: 'servicenow-1' }),
- };
- const result: string | JSX.Element = getConnectorLabelTitle({
- action,
- connectors,
- });
-
- expect(result).toEqual('changed connector field');
});
describe('toStringArray', () => {
diff --git a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx
index 744b14926b358..2eb44f91190c6 100644
--- a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx
+++ b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx
@@ -23,10 +23,11 @@ import {
CommentType,
Comment,
CommentRequestActionsType,
+ noneConnectorId,
} from '../../../common';
import { CaseUserActions } from '../../containers/types';
import { CaseServices } from '../../containers/use_get_case_user_actions';
-import { parseString } from '../../containers/utils';
+import { parseStringAsConnector, parseStringAsExternalService } from '../../common/user_actions';
import { Tags } from '../tag_list/tags';
import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar';
import { UserActionTimestamp } from './user_action_timestamp';
@@ -97,23 +98,27 @@ export const getConnectorLabelTitle = ({
action: CaseUserActions;
connectors: ActionConnector[];
}) => {
- const oldValue = parseString(`${action.oldValue}`);
- const newValue = parseString(`${action.newValue}`);
+ const oldConnector = parseStringAsConnector(action.oldValConnectorId, action.oldValue);
+ const newConnector = parseStringAsConnector(action.newValConnectorId, action.newValue);
- if (oldValue === null || newValue === null) {
+ if (!oldConnector || !newConnector) {
return '';
}
- // Connector changed
- if (oldValue.id !== newValue.id) {
- const newConnector = connectors.find((c) => c.id === newValue.id);
- return newValue.id != null && newValue.id !== 'none' && newConnector != null
- ? i18n.SELECTED_THIRD_PARTY(newConnector.name)
- : i18n.REMOVED_THIRD_PARTY;
- } else {
- // Field changed
+ // if the ids are the same, assume we just changed the fields
+ if (oldConnector.id === newConnector.id) {
return i18n.CHANGED_CONNECTOR_FIELD;
}
+
+ // ids are not the same so check and see if the id is a valid connector and then return its name
+ // if the connector id is the none connector value then it must have been removed
+ const newConnectorActionInfo = connectors.find((c) => c.id === newConnector.id);
+ if (newConnector.id !== noneConnectorId && newConnectorActionInfo != null) {
+ return i18n.SELECTED_THIRD_PARTY(newConnectorActionInfo.name);
+ }
+
+ // it wasn't a valid connector or it was the none connector, so it must have been removed
+ return i18n.REMOVED_THIRD_PARTY;
};
const getTagsLabelTitle = (action: CaseUserActions) => {
@@ -133,7 +138,8 @@ const getTagsLabelTitle = (action: CaseUserActions) => {
};
export const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => {
- const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService;
+ const externalService = parseStringAsExternalService(action.newValConnectorId, action.newValue);
+
return (
{`${firstPush ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} ${
- pushedVal?.connector_name
+ externalService?.connector_name
}`}
-
- {pushedVal?.external_title}
+
+ {externalService?.external_title}
@@ -157,20 +163,19 @@ export const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: b
export const getPushInfo = (
caseServices: CaseServices,
- // a JSON parse failure will result in null for parsedValue
- parsedValue: { connector_id: string | null; connector_name: string } | null,
+ externalService: CaseFullExternalService | undefined,
index: number
) =>
- parsedValue != null && parsedValue.connector_id != null
+ externalService != null && externalService.connector_id != null
? {
- firstPush: caseServices[parsedValue.connector_id]?.firstPushIndex === index,
- parsedConnectorId: parsedValue.connector_id,
- parsedConnectorName: parsedValue.connector_name,
+ firstPush: caseServices[externalService.connector_id]?.firstPushIndex === index,
+ parsedConnectorId: externalService.connector_id,
+ parsedConnectorName: externalService.connector_name,
}
: {
firstPush: false,
- parsedConnectorId: 'none',
- parsedConnectorName: 'none',
+ parsedConnectorId: noneConnectorId,
+ parsedConnectorName: noneConnectorId,
};
const getUpdateActionIcon = (actionField: string): string => {
diff --git a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx
index 784817229caf9..7ea415324194c 100644
--- a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx
+++ b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx
@@ -35,7 +35,7 @@ import {
Ecs,
} from '../../../common';
import { CaseServices } from '../../containers/use_get_case_user_actions';
-import { parseString } from '../../containers/utils';
+import { parseStringAsExternalService } from '../../common/user_actions';
import { OnUpdateFields } from '../case_view';
import {
getConnectorLabelTitle,
@@ -512,10 +512,14 @@ export const UserActionTree = React.memo(
// Pushed information
if (action.actionField.length === 1 && action.actionField[0] === 'pushed') {
- const parsedValue = parseString(`${action.newValue}`);
+ const parsedExternalService = parseStringAsExternalService(
+ action.newValConnectorId,
+ action.newValue
+ );
+
const { firstPush, parsedConnectorId, parsedConnectorName } = getPushInfo(
caseServices,
- parsedValue,
+ parsedExternalService,
index
);
diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts
index 6db92829bb8d6..96e75a96ca115 100644
--- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts
+++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts
@@ -21,6 +21,7 @@ import {
basicCase,
basicCaseCommentPatch,
basicCasePost,
+ basicResolvedCase,
casesStatus,
caseUserActions,
pushedCase,
@@ -33,6 +34,7 @@ import {
CommentRequest,
User,
CaseStatuses,
+ ResolvedCase,
} from '../../../common';
export const getCase = async (
@@ -41,6 +43,12 @@ export const getCase = async (
signal: AbortSignal
): Promise => Promise.resolve(basicCase);
+export const resolveCase = async (
+ caseId: string,
+ includeComments: boolean = true,
+ signal: AbortSignal
+): Promise => Promise.resolve(basicResolvedCase);
+
export const getCasesStatus = async (signal: AbortSignal): Promise =>
Promise.resolve(casesStatus);
diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx
index e47930e81fe6b..654ade308ed44 100644
--- a/x-pack/plugins/cases/public/containers/api.test.tsx
+++ b/x-pack/plugins/cases/public/containers/api.test.tsx
@@ -30,6 +30,7 @@ import {
postCase,
postComment,
pushCase,
+ resolveCase,
} from './api';
import {
@@ -68,7 +69,7 @@ describe('Case Configuration API', () => {
});
const data = ['1', '2'];
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await deleteCases(data, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, {
method: 'DELETE',
@@ -77,7 +78,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await deleteCases(data, abortCtrl.signal);
expect(resp).toEqual('');
});
@@ -89,7 +90,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(actionLicenses);
});
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await getActionLicense(abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`/api/actions/connector_types`, {
method: 'GET',
@@ -97,7 +98,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await getActionLicense(abortCtrl.signal);
expect(resp).toEqual(actionLicenses);
});
@@ -110,7 +111,7 @@ describe('Case Configuration API', () => {
});
const data = basicCase.id;
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await getCase(data, true, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}`, {
method: 'GET',
@@ -119,18 +120,46 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await getCase(data, true, abortCtrl.signal);
expect(resp).toEqual(basicCase);
});
});
+ describe('resolveCase', () => {
+ const targetAliasId = '12345';
+ const basicResolveCase = {
+ outcome: 'aliasMatch',
+ case: basicCaseSnake,
+ };
+ const caseId = basicCase.id;
+
+ beforeEach(() => {
+ fetchMock.mockClear();
+ fetchMock.mockResolvedValue({ ...basicResolveCase, target_alias_id: targetAliasId });
+ });
+
+ test('should be called with correct check url, method, signal', async () => {
+ await resolveCase(caseId, true, abortCtrl.signal);
+ expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${caseId}/resolve`, {
+ method: 'GET',
+ query: { includeComments: true },
+ signal: abortCtrl.signal,
+ });
+ });
+
+ test('should return correct response', async () => {
+ const resp = await resolveCase(caseId, true, abortCtrl.signal);
+ expect(resp).toEqual({ ...basicResolveCase, case: basicCase, targetAliasId });
+ });
+ });
+
describe('getCases', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(allCasesSnake);
});
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await getCases({
filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] },
queryParams: DEFAULT_QUERY_PARAMS,
@@ -148,7 +177,7 @@ describe('Case Configuration API', () => {
});
});
- test('correctly applies filters', async () => {
+ test('should applies correct filters', async () => {
await getCases({
filterOptions: {
...DEFAULT_FILTER_OPTIONS,
@@ -175,7 +204,7 @@ describe('Case Configuration API', () => {
});
});
- test('tags with weird chars get handled gracefully', async () => {
+ test('should handle tags with weird chars', async () => {
const weirdTags: string[] = ['(', '"double"'];
await getCases({
@@ -204,7 +233,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await getCases({
filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] },
queryParams: DEFAULT_QUERY_PARAMS,
@@ -219,7 +248,7 @@ describe('Case Configuration API', () => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(casesStatusSnake);
});
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await getCasesStatus(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/status`, {
method: 'GET',
@@ -228,7 +257,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await getCasesStatus(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(resp).toEqual(casesStatus);
});
@@ -240,7 +269,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(caseUserActionsSnake);
});
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await getCaseUserActions(basicCase.id, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/user_actions`, {
method: 'GET',
@@ -248,7 +277,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await getCaseUserActions(basicCase.id, abortCtrl.signal);
expect(resp).toEqual(caseUserActions);
});
@@ -260,7 +289,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(respReporters);
});
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await getReporters(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/reporters`, {
method: 'GET',
@@ -271,7 +300,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await getReporters(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(resp).toEqual(respReporters);
});
@@ -283,7 +312,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(tags);
});
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await getTags(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/tags`, {
method: 'GET',
@@ -294,7 +323,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await getTags(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]);
expect(resp).toEqual(tags);
});
@@ -306,7 +335,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue([basicCaseSnake]);
});
const data = { description: 'updated description' };
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await patchCase(basicCase.id, data, basicCase.version, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, {
method: 'PATCH',
@@ -317,7 +346,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await patchCase(
basicCase.id,
{ description: 'updated description' },
@@ -341,7 +370,7 @@ describe('Case Configuration API', () => {
},
];
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await patchCasesStatus(data, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, {
method: 'PATCH',
@@ -350,7 +379,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await patchCasesStatus(data, abortCtrl.signal);
expect(resp).toEqual({ ...cases });
});
@@ -362,7 +391,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(basicCaseSnake);
});
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await patchComment({
caseId: basicCase.id,
commentId: basicCase.comments[0].id,
@@ -384,7 +413,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await patchComment({
caseId: basicCase.id,
commentId: basicCase.comments[0].id,
@@ -418,7 +447,7 @@ describe('Case Configuration API', () => {
owner: SECURITY_SOLUTION_OWNER,
};
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await postCase(data, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}`, {
method: 'POST',
@@ -427,7 +456,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await postCase(data, abortCtrl.signal);
expect(resp).toEqual(basicCase);
});
@@ -444,7 +473,7 @@ describe('Case Configuration API', () => {
type: CommentType.user as const,
};
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await postComment(data, basicCase.id, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/comments`, {
method: 'POST',
@@ -453,7 +482,7 @@ describe('Case Configuration API', () => {
});
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await postComment(data, basicCase.id, abortCtrl.signal);
expect(resp).toEqual(basicCase);
});
@@ -467,7 +496,7 @@ describe('Case Configuration API', () => {
fetchMock.mockResolvedValue(pushedCaseSnake);
});
- test('check url, method, signal', async () => {
+ test('should be called with correct check url, method, signal', async () => {
await pushCase(basicCase.id, connectorId, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(
`${CASES_URL}/${basicCase.id}/connector/${connectorId}/_push`,
@@ -479,7 +508,7 @@ describe('Case Configuration API', () => {
);
});
- test('happy path', async () => {
+ test('should return correct response', async () => {
const resp = await pushCase(basicCase.id, connectorId, abortCtrl.signal);
expect(resp).toEqual(pushedCase);
});
diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts
index 51a68376936af..75e8c8f58705d 100644
--- a/x-pack/plugins/cases/public/containers/api.ts
+++ b/x-pack/plugins/cases/public/containers/api.ts
@@ -14,6 +14,7 @@ import {
CasePatchRequest,
CasePostRequest,
CaseResponse,
+ CaseResolveResponse,
CASES_URL,
CasesFindResponse,
CasesResponse,
@@ -35,6 +36,7 @@ import {
SubCaseResponse,
SubCasesResponse,
User,
+ ResolvedCase,
} from '../../common';
import { getAllConnectorTypesUrl } from '../../common/utils/connectors_api';
@@ -61,6 +63,7 @@ import {
decodeCasesFindResponse,
decodeCasesStatusResponse,
decodeCaseUserActionsResponse,
+ decodeCaseResolveResponse,
} from './utils';
export const getCase = async (
@@ -78,6 +81,24 @@ export const getCase = async (
return convertToCamelCase(decodeCaseResponse(response));
};
+export const resolveCase = async (
+ caseId: string,
+ includeComments: boolean = true,
+ signal: AbortSignal
+): Promise => {
+ const response = await KibanaServices.get().http.fetch(
+ getCaseDetailsUrl(caseId) + '/resolve',
+ {
+ method: 'GET',
+ query: {
+ includeComments,
+ },
+ signal,
+ }
+ );
+ return convertToCamelCase(decodeCaseResolveResponse(response));
+};
+
export const getSubCase = async (
caseId: string,
subCaseId: string,
diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts
index c955bb34240e2..f7d1daabd60ea 100644
--- a/x-pack/plugins/cases/public/containers/mock.ts
+++ b/x-pack/plugins/cases/public/containers/mock.ts
@@ -9,6 +9,7 @@ import { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment }
import {
AssociationType,
+ CaseUserActionConnector,
CaseResponse,
CasesFindResponse,
CasesResponse,
@@ -19,6 +20,10 @@ import {
CommentResponse,
CommentType,
ConnectorTypes,
+ ResolvedCase,
+ isCreateConnector,
+ isPush,
+ isUpdateConnector,
SECURITY_SOLUTION_OWNER,
UserAction,
UserActionField,
@@ -159,6 +164,12 @@ export const basicCase: Case = {
subCaseIds: [],
};
+export const basicResolvedCase: ResolvedCase = {
+ case: basicCase,
+ outcome: 'aliasMatch',
+ aliasTargetId: `${basicCase.id}_2`,
+};
+
export const collectionCase: Case = {
type: CaseType.collection,
owner: SECURITY_SOLUTION_OWNER,
@@ -240,7 +251,9 @@ export const pushedCase: Case = {
const basicAction = {
actionAt: basicCreatedAt,
actionBy: elasticUser,
+ oldValConnectorId: null,
oldValue: null,
+ newValConnectorId: null,
newValue: 'what a cool value',
caseId: basicCaseId,
commentId: null,
@@ -308,12 +321,7 @@ export const basicCaseSnake: CaseResponse = {
closed_at: null,
closed_by: null,
comments: [basicCommentSnake],
- connector: {
- id: 'none',
- name: 'My Connector',
- type: ConnectorTypes.none,
- fields: null,
- },
+ connector: { id: 'none', name: 'My Connector', type: ConnectorTypes.none, fields: null },
created_at: basicCreatedAt,
created_by: elasticUserSnake,
external_service: null,
@@ -328,8 +336,8 @@ export const casesStatusSnake: CasesStatusResponse = {
count_open_cases: 20,
};
+export const pushConnectorId = '123';
export const pushSnake = {
- connector_id: '123',
connector_name: 'connector name',
external_id: 'external_id',
external_title: 'external title',
@@ -350,7 +358,7 @@ export const pushedCaseSnake = {
type: ConnectorTypes.jira,
fields: null,
},
- external_service: basicPushSnake,
+ external_service: { ...basicPushSnake, connector_id: pushConnectorId },
};
export const reporters: string[] = ['alexis', 'kim', 'maria', 'steph'];
@@ -385,17 +393,20 @@ const basicActionSnake = {
comment_id: null,
owner: SECURITY_SOLUTION_OWNER,
};
-export const getUserActionSnake = (af: UserActionField, a: UserAction) => ({
- ...basicActionSnake,
- action_id: `${af[0]}-${a}`,
- action_field: af,
- action: a,
- comment_id: af[0] === 'comment' ? basicCommentId : null,
- new_value:
- a === 'push-to-service' && af[0] === 'pushed'
- ? JSON.stringify(basicPushSnake)
- : basicAction.newValue,
-});
+export const getUserActionSnake = (af: UserActionField, a: UserAction) => {
+ const isPushToService = a === 'push-to-service' && af[0] === 'pushed';
+
+ return {
+ ...basicActionSnake,
+ action_id: `${af[0]}-${a}`,
+ action_field: af,
+ action: a,
+ comment_id: af[0] === 'comment' ? basicCommentId : null,
+ new_value: isPushToService ? JSON.stringify(basicPushSnake) : basicAction.newValue,
+ new_val_connector_id: isPushToService ? pushConnectorId : null,
+ old_val_connector_id: null,
+ };
+};
export const caseUserActionsSnake: CaseUserActionsResponse = [
getUserActionSnake(['description'], 'create'),
@@ -405,17 +416,76 @@ export const caseUserActionsSnake: CaseUserActionsResponse = [
// user actions
-export const getUserAction = (af: UserActionField, a: UserAction) => ({
- ...basicAction,
- actionId: `${af[0]}-${a}`,
- actionField: af,
- action: a,
- commentId: af[0] === 'comment' ? basicCommentId : null,
- newValue:
- a === 'push-to-service' && af[0] === 'pushed'
- ? JSON.stringify(basicPushSnake)
- : basicAction.newValue,
-});
+export const getUserAction = (
+ af: UserActionField,
+ a: UserAction,
+ overrides?: Partial
+): CaseUserActions => {
+ return {
+ ...basicAction,
+ actionId: `${af[0]}-${a}`,
+ actionField: af,
+ action: a,
+ commentId: af[0] === 'comment' ? basicCommentId : null,
+ ...getValues(a, af, overrides),
+ };
+};
+
+const getValues = (
+ userAction: UserAction,
+ actionFields: UserActionField,
+ overrides?: Partial
+): Partial => {
+ if (isCreateConnector(userAction, actionFields)) {
+ return {
+ newValue:
+ overrides?.newValue === undefined ? JSON.stringify(basicCaseSnake) : overrides.newValue,
+ newValConnectorId: overrides?.newValConnectorId ?? null,
+ oldValue: null,
+ oldValConnectorId: null,
+ };
+ } else if (isUpdateConnector(userAction, actionFields)) {
+ return {
+ newValue:
+ overrides?.newValue === undefined
+ ? JSON.stringify({ name: 'My Connector', type: ConnectorTypes.none, fields: null })
+ : overrides.newValue,
+ newValConnectorId: overrides?.newValConnectorId ?? null,
+ oldValue:
+ overrides?.oldValue === undefined
+ ? JSON.stringify({ name: 'My Connector2', type: ConnectorTypes.none, fields: null })
+ : overrides.oldValue,
+ oldValConnectorId: overrides?.oldValConnectorId ?? null,
+ };
+ } else if (isPush(userAction, actionFields)) {
+ return {
+ newValue:
+ overrides?.newValue === undefined ? JSON.stringify(basicPushSnake) : overrides?.newValue,
+ newValConnectorId:
+ overrides?.newValConnectorId === undefined ? pushConnectorId : overrides.newValConnectorId,
+ oldValue: overrides?.oldValue ?? null,
+ oldValConnectorId: overrides?.oldValConnectorId ?? null,
+ };
+ } else {
+ return {
+ newValue: overrides?.newValue === undefined ? basicAction.newValue : overrides.newValue,
+ newValConnectorId: overrides?.newValConnectorId ?? null,
+ oldValue: overrides?.oldValue ?? null,
+ oldValConnectorId: overrides?.oldValConnectorId ?? null,
+ };
+ }
+};
+
+export const getJiraConnectorWithoutId = (overrides?: Partial) => {
+ return JSON.stringify({
+ name: 'jira1',
+ type: ConnectorTypes.jira,
+ ...jiraFields,
+ ...overrides,
+ });
+};
+
+export const jiraFields = { fields: { issueType: '10006', priority: null, parent: null } };
export const getAlertUserAction = () => ({
...basicAction,
diff --git a/x-pack/plugins/cases/public/containers/use_get_case.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case.test.tsx
index c88f530709c8a..e825e232aebdc 100644
--- a/x-pack/plugins/cases/public/containers/use_get_case.test.tsx
+++ b/x-pack/plugins/cases/public/containers/use_get_case.test.tsx
@@ -7,7 +7,7 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useGetCase, UseGetCase } from './use_get_case';
-import { basicCase } from './mock';
+import { basicCase, basicResolvedCase } from './mock';
import * as api from './api';
jest.mock('./api');
@@ -28,6 +28,7 @@ describe('useGetCase', () => {
await waitForNextUpdate();
expect(result.current).toEqual({
data: null,
+ resolveOutcome: null,
isLoading: false,
isError: false,
fetchCase: result.current.fetchCase,
@@ -36,13 +37,13 @@ describe('useGetCase', () => {
});
});
- it('calls getCase with correct arguments', async () => {
- const spyOnGetCase = jest.spyOn(api, 'getCase');
+ it('calls resolveCase with correct arguments', async () => {
+ const spyOnResolveCase = jest.spyOn(api, 'resolveCase');
await act(async () => {
const { waitForNextUpdate } = renderHook(() => useGetCase(basicCase.id));
await waitForNextUpdate();
await waitForNextUpdate();
- expect(spyOnGetCase).toBeCalledWith(basicCase.id, true, abortCtrl.signal);
+ expect(spyOnResolveCase).toBeCalledWith(basicCase.id, true, abortCtrl.signal);
});
});
@@ -55,6 +56,8 @@ describe('useGetCase', () => {
await waitForNextUpdate();
expect(result.current).toEqual({
data: basicCase,
+ resolveOutcome: basicResolvedCase.outcome,
+ resolveAliasId: basicResolvedCase.aliasTargetId,
isLoading: false,
isError: false,
fetchCase: result.current.fetchCase,
@@ -64,7 +67,7 @@ describe('useGetCase', () => {
});
it('refetch case', async () => {
- const spyOnGetCase = jest.spyOn(api, 'getCase');
+ const spyOnResolveCase = jest.spyOn(api, 'resolveCase');
await act(async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useGetCase(basicCase.id)
@@ -72,7 +75,7 @@ describe('useGetCase', () => {
await waitForNextUpdate();
await waitForNextUpdate();
result.current.fetchCase();
- expect(spyOnGetCase).toHaveBeenCalledTimes(2);
+ expect(spyOnResolveCase).toHaveBeenCalledTimes(2);
});
});
@@ -103,8 +106,8 @@ describe('useGetCase', () => {
});
it('unhappy path', async () => {
- const spyOnGetCase = jest.spyOn(api, 'getCase');
- spyOnGetCase.mockImplementation(() => {
+ const spyOnResolveCase = jest.spyOn(api, 'resolveCase');
+ spyOnResolveCase.mockImplementation(() => {
throw new Error('Something went wrong');
});
@@ -117,6 +120,7 @@ describe('useGetCase', () => {
expect(result.current).toEqual({
data: null,
+ resolveOutcome: null,
isLoading: false,
isError: true,
fetchCase: result.current.fetchCase,
diff --git a/x-pack/plugins/cases/public/containers/use_get_case.tsx b/x-pack/plugins/cases/public/containers/use_get_case.tsx
index b9326ad057c9e..52610981a227c 100644
--- a/x-pack/plugins/cases/public/containers/use_get_case.tsx
+++ b/x-pack/plugins/cases/public/containers/use_get_case.tsx
@@ -7,20 +7,22 @@
import { useEffect, useReducer, useCallback, useRef } from 'react';
-import { Case } from './types';
+import { Case, ResolvedCase } from './types';
import * as i18n from './translations';
import { useToasts } from '../common/lib/kibana';
-import { getCase, getSubCase } from './api';
+import { resolveCase, getSubCase } from './api';
interface CaseState {
data: Case | null;
+ resolveOutcome: ResolvedCase['outcome'] | null;
+ resolveAliasId?: string;
isLoading: boolean;
isError: boolean;
}
type Action =
| { type: 'FETCH_INIT'; payload: { silent: boolean } }
- | { type: 'FETCH_SUCCESS'; payload: Case }
+ | { type: 'FETCH_SUCCESS'; payload: ResolvedCase }
| { type: 'FETCH_FAILURE' }
| { type: 'UPDATE_CASE'; payload: Case };
@@ -40,7 +42,9 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => {
...state,
isLoading: false,
isError: false,
- data: action.payload,
+ data: action.payload.case,
+ resolveOutcome: action.payload.outcome,
+ resolveAliasId: action.payload.aliasTargetId,
};
case 'FETCH_FAILURE':
return {
@@ -72,6 +76,7 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => {
isLoading: false,
isError: false,
data: null,
+ resolveOutcome: null,
});
const toasts = useToasts();
const isCancelledRef = useRef(false);
@@ -89,9 +94,12 @@ export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => {
abortCtrlRef.current = new AbortController();
dispatch({ type: 'FETCH_INIT', payload: { silent } });
- const response = await (subCaseId
- ? getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal)
- : getCase(caseId, true, abortCtrlRef.current.signal));
+ const response: ResolvedCase = subCaseId
+ ? {
+ case: await getSubCase(caseId, subCaseId, true, abortCtrlRef.current.signal),
+ outcome: 'exactMatch', // sub-cases are not resolved, forced to exactMatch always
+ }
+ : await resolveCase(caseId, true, abortCtrlRef.current.signal);
if (!isCancelledRef.current) {
dispatch({ type: 'FETCH_SUCCESS', payload: response });
diff --git a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx
index 62b4cf92434cd..e7e46fa46c7cc 100644
--- a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx
+++ b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx
@@ -18,7 +18,9 @@ import {
basicPushSnake,
caseUserActions,
elasticUser,
+ getJiraConnectorWithoutId,
getUserAction,
+ jiraFields,
} from './mock';
import * as api from './api';
@@ -299,15 +301,14 @@ describe('useGetCaseUserActions', () => {
const pushAction123 = getUserAction(['pushed'], 'push-to-service');
const push456 = {
...basicPushSnake,
- connector_id: '456',
connector_name: 'other connector name',
external_id: 'other_external_id',
};
- const pushAction456 = {
- ...getUserAction(['pushed'], 'push-to-service'),
+ const pushAction456 = getUserAction(['pushed'], 'push-to-service', {
newValue: JSON.stringify(push456),
- };
+ newValConnectorId: '456',
+ });
const userActions = [
...caseUserActions,
@@ -346,15 +347,14 @@ describe('useGetCaseUserActions', () => {
const pushAction123 = getUserAction(['pushed'], 'push-to-service');
const push456 = {
...basicPushSnake,
- connector_id: '456',
connector_name: 'other connector name',
external_id: 'other_external_id',
};
- const pushAction456 = {
- ...getUserAction(['pushed'], 'push-to-service'),
+ const pushAction456 = getUserAction(['pushed'], 'push-to-service', {
newValue: JSON.stringify(push456),
- };
+ newValConnectorId: '456',
+ });
const userActions = [
...caseUserActions,
@@ -392,11 +392,7 @@ describe('useGetCaseUserActions', () => {
const userActions = [
...caseUserActions,
getUserAction(['pushed'], 'push-to-service'),
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- },
+ createUpdateConnectorFields123HighPriorityUserAction(),
];
const result = getPushedInfo(userActions, '123');
@@ -418,11 +414,7 @@ describe('useGetCaseUserActions', () => {
const userActions = [
...caseUserActions,
getUserAction(['pushed'], 'push-to-service'),
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }),
- newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- },
+ createChangeConnector123To456UserAction(),
];
const result = getPushedInfo(userActions, '123');
@@ -444,16 +436,8 @@ describe('useGetCaseUserActions', () => {
const userActions = [
...caseUserActions,
getUserAction(['pushed'], 'push-to-service'),
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }),
- newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- },
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }),
- },
+ createChangeConnector123To456UserAction(),
+ createChangeConnector456To123UserAction(),
];
const result = getPushedInfo(userActions, '123');
@@ -474,22 +458,10 @@ describe('useGetCaseUserActions', () => {
it('Change fields and connector after push - hasDataToPush: true', () => {
const userActions = [
...caseUserActions,
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- },
+ createUpdateConnectorFields123HighPriorityUserAction(),
getUserAction(['pushed'], 'push-to-service'),
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- },
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'Low' } }),
- },
+ createChangeConnector123HighPriorityTo456UserAction(),
+ createChangeConnector456To123PriorityLowUserAction(),
];
const result = getPushedInfo(userActions, '123');
@@ -510,22 +482,10 @@ describe('useGetCaseUserActions', () => {
it('Change only connector after push - hasDataToPush: false', () => {
const userActions = [
...caseUserActions,
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- },
+ createUpdateConnectorFields123HighPriorityUserAction(),
getUserAction(['pushed'], 'push-to-service'),
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- },
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- },
+ createChangeConnector123HighPriorityTo456UserAction(),
+ createChangeConnector456To123HighPriorityUserAction(),
];
const result = getPushedInfo(userActions, '123');
@@ -547,45 +507,24 @@ describe('useGetCaseUserActions', () => {
const pushAction123 = getUserAction(['pushed'], 'push-to-service');
const push456 = {
...basicPushSnake,
- connector_id: '456',
connector_name: 'other connector name',
external_id: 'other_external_id',
};
- const pushAction456 = {
- ...getUserAction(['pushed'], 'push-to-service'),
+ const pushAction456 = getUserAction(['pushed'], 'push-to-service', {
newValue: JSON.stringify(push456),
- };
+ newValConnectorId: '456',
+ });
const userActions = [
...caseUserActions,
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- },
+ createUpdateConnectorFields123HighPriorityUserAction(),
pushAction123,
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- },
+ createChangeConnector123HighPriorityTo456UserAction(),
pushAction456,
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'Low' } }),
- },
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'Low' } }),
- newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- },
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'Low' } }),
- },
+ createChangeConnector456To123PriorityLowUserAction(),
+ createChangeConnector123LowPriorityTo456UserAction(),
+ createChangeConnector456To123PriorityLowUserAction(),
];
const result = getPushedInfo(userActions, '123');
@@ -617,34 +556,22 @@ describe('useGetCaseUserActions', () => {
const pushAction123 = getUserAction(['pushed'], 'push-to-service');
const push456 = {
...basicPushSnake,
- connector_id: '456',
connector_name: 'other connector name',
external_id: 'other_external_id',
};
- const pushAction456 = {
- ...getUserAction(['pushed'], 'push-to-service'),
+ const pushAction456 = getUserAction(['pushed'], 'push-to-service', {
+ newValConnectorId: '456',
newValue: JSON.stringify(push456),
- };
+ });
+
const userActions = [
...caseUserActions,
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- },
+ createUpdateConnectorFields123HighPriorityUserAction(),
pushAction123,
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- },
+ createChangeConnector123HighPriorityTo456UserAction(),
pushAction456,
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- },
+ createChangeConnector456To123HighPriorityUserAction(),
];
const result = getPushedInfo(userActions, '123');
@@ -675,22 +602,10 @@ describe('useGetCaseUserActions', () => {
it('Changing other connectors fields does not count as an update', () => {
const userActions = [
...caseUserActions,
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: null } }),
- newValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- },
+ createUpdateConnectorFields123HighPriorityUserAction(),
getUserAction(['pushed'], 'push-to-service'),
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '123', fields: { issueType: '10006', priority: 'High' } }),
- newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- },
- {
- ...getUserAction(['connector'], 'update'),
- oldValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '6' } }),
- newValue: JSON.stringify({ id: '456', fields: { issueTypes: ['10'], severity: '3' } }),
- },
+ createChangeConnector123HighPriorityTo456UserAction(),
+ createUpdateConnectorFields456HighPriorityUserAction(),
];
const result = getPushedInfo(userActions, '123');
@@ -709,3 +624,83 @@ describe('useGetCaseUserActions', () => {
});
});
});
+
+const jira123HighPriorityFields = {
+ fields: { ...jiraFields.fields, priority: 'High' },
+};
+
+const jira123LowPriorityFields = {
+ fields: { ...jiraFields.fields, priority: 'Low' },
+};
+
+const jira456Fields = {
+ fields: { issueType: '10', parent: null, priority: null },
+};
+
+const jira456HighPriorityFields = {
+ fields: { ...jira456Fields.fields, priority: 'High' },
+};
+
+const createUpdateConnectorFields123HighPriorityUserAction = () =>
+ getUserAction(['connector'], 'update', {
+ oldValue: getJiraConnectorWithoutId(),
+ newValue: getJiraConnectorWithoutId(jira123HighPriorityFields),
+ oldValConnectorId: '123',
+ newValConnectorId: '123',
+ });
+
+const createUpdateConnectorFields456HighPriorityUserAction = () =>
+ getUserAction(['connector'], 'update', {
+ oldValue: getJiraConnectorWithoutId(jira456Fields),
+ newValue: getJiraConnectorWithoutId(jira456HighPriorityFields),
+ oldValConnectorId: '456',
+ newValConnectorId: '456',
+ });
+
+const createChangeConnector123HighPriorityTo456UserAction = () =>
+ getUserAction(['connector'], 'update', {
+ oldValue: getJiraConnectorWithoutId(jira123HighPriorityFields),
+ oldValConnectorId: '123',
+ newValue: getJiraConnectorWithoutId(jira456Fields),
+ newValConnectorId: '456',
+ });
+
+const createChangeConnector123To456UserAction = () =>
+ getUserAction(['connector'], 'update', {
+ oldValue: getJiraConnectorWithoutId(),
+ oldValConnectorId: '123',
+ newValue: getJiraConnectorWithoutId(jira456Fields),
+ newValConnectorId: '456',
+ });
+
+const createChangeConnector123LowPriorityTo456UserAction = () =>
+ getUserAction(['connector'], 'update', {
+ oldValue: getJiraConnectorWithoutId(jira123LowPriorityFields),
+ oldValConnectorId: '123',
+ newValue: getJiraConnectorWithoutId(jira456Fields),
+ newValConnectorId: '456',
+ });
+
+const createChangeConnector456To123UserAction = () =>
+ getUserAction(['connector'], 'update', {
+ oldValue: getJiraConnectorWithoutId(jira456Fields),
+ oldValConnectorId: '456',
+ newValue: getJiraConnectorWithoutId(),
+ newValConnectorId: '123',
+ });
+
+const createChangeConnector456To123HighPriorityUserAction = () =>
+ getUserAction(['connector'], 'update', {
+ oldValue: getJiraConnectorWithoutId(jira456Fields),
+ oldValConnectorId: '456',
+ newValue: getJiraConnectorWithoutId(jira123HighPriorityFields),
+ newValConnectorId: '123',
+ });
+
+const createChangeConnector456To123PriorityLowUserAction = () =>
+ getUserAction(['connector'], 'update', {
+ oldValue: getJiraConnectorWithoutId(jira456Fields),
+ oldValConnectorId: '456',
+ newValue: getJiraConnectorWithoutId(jira123LowPriorityFields),
+ newValConnectorId: '123',
+ });
diff --git a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx
index e481519ba19a3..36d600c3f1c9d 100644
--- a/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx
+++ b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.tsx
@@ -18,7 +18,8 @@ import {
} from '../../common';
import { getCaseUserActions, getSubCaseUserActions } from './api';
import * as i18n from './translations';
-import { convertToCamelCase, parseString } from './utils';
+import { convertToCamelCase } from './utils';
+import { parseStringAsConnector, parseStringAsExternalService } from '../common/user_actions';
import { useToasts } from '../common/lib/kibana';
export interface CaseService extends CaseExternalService {
@@ -58,8 +59,24 @@ export interface UseGetCaseUserActions extends CaseUserActionsState {
) => Promise;
}
-const getExternalService = (value: string): CaseExternalService | null =>
- convertToCamelCase(parseString(`${value}`));
+const unknownExternalServiceConnectorId = 'unknown';
+
+const getExternalService = (
+ connectorId: string | null,
+ encodedValue: string | null
+): CaseExternalService | null => {
+ const decodedValue = parseStringAsExternalService(connectorId, encodedValue);
+
+ if (decodedValue == null) {
+ return null;
+ }
+ return {
+ ...convertToCamelCase(decodedValue),
+ // if in the rare case that the connector id is null we'll set it to unknown if we need to reference it in the UI
+ // anywhere. The id would only ever be null if a migration failed or some logic error within the backend occurred
+ connectorId: connectorId ?? unknownExternalServiceConnectorId,
+ };
+};
const groupConnectorFields = (
userActions: CaseUserActions[]
@@ -69,22 +86,26 @@ const groupConnectorFields = (
return acc;
}
- const oldValue = parseString(`${mua.oldValue}`);
- const newValue = parseString(`${mua.newValue}`);
+ const oldConnector = parseStringAsConnector(mua.oldValConnectorId, mua.oldValue);
+ const newConnector = parseStringAsConnector(mua.newValConnectorId, mua.newValue);
- if (oldValue == null || newValue == null) {
+ if (!oldConnector || !newConnector) {
return acc;
}
return {
...acc,
- [oldValue.id]: [
- ...(acc[oldValue.id] || []),
- ...(oldValue.id === newValue.id ? [oldValue.fields, newValue.fields] : [oldValue.fields]),
+ [oldConnector.id]: [
+ ...(acc[oldConnector.id] || []),
+ ...(oldConnector.id === newConnector.id
+ ? [oldConnector.fields, newConnector.fields]
+ : [oldConnector.fields]),
],
- [newValue.id]: [
- ...(acc[newValue.id] || []),
- ...(oldValue.id === newValue.id ? [oldValue.fields, newValue.fields] : [newValue.fields]),
+ [newConnector.id]: [
+ ...(acc[newConnector.id] || []),
+ ...(oldConnector.id === newConnector.id
+ ? [oldConnector.fields, newConnector.fields]
+ : [newConnector.fields]),
],
};
}, {} as Record>);
@@ -137,9 +158,7 @@ export const getPushedInfo = (
const hasDataToPushForConnector = (connectorId: string): boolean => {
const caseUserActionsReversed = [...caseUserActions].reverse();
const lastPushOfConnectorReversedIndex = caseUserActionsReversed.findIndex(
- (mua) =>
- mua.action === 'push-to-service' &&
- getExternalService(`${mua.newValue}`)?.connectorId === connectorId
+ (mua) => mua.action === 'push-to-service' && mua.newValConnectorId === connectorId
);
if (lastPushOfConnectorReversedIndex === -1) {
@@ -190,7 +209,7 @@ export const getPushedInfo = (
return acc;
}
- const externalService = getExternalService(`${cua.newValue}`);
+ const externalService = getExternalService(cua.newValConnectorId, cua.newValue);
if (externalService === null) {
return acc;
}
diff --git a/x-pack/plugins/cases/public/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts
index de67b1cfbd6fa..458899e5f53c9 100644
--- a/x-pack/plugins/cases/public/containers/utils.ts
+++ b/x-pack/plugins/cases/public/containers/utils.ts
@@ -30,20 +30,14 @@ import {
CaseUserActionsResponseRt,
CommentType,
CasePatchRequest,
+ CaseResolveResponse,
+ CaseResolveResponseRt,
} from '../../common';
import { AllCases, Case, UpdateByKey } from './types';
import * as i18n from './translations';
export const getTypedPayload = (a: unknown): T => a as T;
-export const parseString = (params: string) => {
- try {
- return JSON.parse(params);
- } catch {
- return null;
- }
-};
-
export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] =>
arrayOfSnakes.reduce((acc: unknown[], value) => {
if (isArray(value)) {
@@ -88,6 +82,12 @@ export const createToasterPlainError = (message: string) => new ToasterError([me
export const decodeCaseResponse = (respCase?: CaseResponse) =>
pipe(CaseResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity));
+export const decodeCaseResolveResponse = (respCase?: CaseResolveResponse) =>
+ pipe(
+ CaseResolveResponseRt.decode(respCase),
+ fold(throwErrors(createToasterPlainError), identity)
+ );
+
export const decodeCasesResponse = (respCase?: CasesResponse) =>
pipe(CasesResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity));
diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts
index db2e5d6ab6bff..5b19bcfa8ac46 100644
--- a/x-pack/plugins/cases/public/types.ts
+++ b/x-pack/plugins/cases/public/types.ts
@@ -16,6 +16,7 @@ import type {
} from '../../triggers_actions_ui/public';
import type { DataPublicPluginStart } from '../../../../src/plugins/data/public';
import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public';
+import type { SpacesPluginStart } from '../../spaces/public';
import type { Storage } from '../../../../src/plugins/kibana_utils/public';
import { AllCasesProps } from './components/all_cases';
@@ -36,6 +37,7 @@ export interface StartPlugins {
lens: LensPublicStart;
storage: Storage;
triggersActionsUi: TriggersActionsStart;
+ spaces?: SpacesPluginStart;
}
/**
diff --git a/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap b/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap
index 3ca77944776b3..50c085b7f22a8 100644
--- a/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap
+++ b/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap
@@ -1596,6 +1596,90 @@ Object {
}
`;
+exports[`audit_logger log function event structure creates the correct audit event for operation: "resolveCase" with an error and entity 1`] = `
+Object {
+ "error": Object {
+ "code": "Error",
+ "message": "an error",
+ },
+ "event": Object {
+ "action": "case_resolve",
+ "category": Array [
+ "database",
+ ],
+ "outcome": "failure",
+ "type": Array [
+ "access",
+ ],
+ },
+ "kibana": Object {
+ "saved_object": Object {
+ "id": "1",
+ "type": "cases",
+ },
+ },
+ "message": "Failed attempt to access cases [id=1] as owner \\"awesome\\"",
+}
+`;
+
+exports[`audit_logger log function event structure creates the correct audit event for operation: "resolveCase" with an error but no entity 1`] = `
+Object {
+ "error": Object {
+ "code": "Error",
+ "message": "an error",
+ },
+ "event": Object {
+ "action": "case_resolve",
+ "category": Array [
+ "database",
+ ],
+ "outcome": "failure",
+ "type": Array [
+ "access",
+ ],
+ },
+ "message": "Failed attempt to access a case as any owners",
+}
+`;
+
+exports[`audit_logger log function event structure creates the correct audit event for operation: "resolveCase" without an error but with an entity 1`] = `
+Object {
+ "event": Object {
+ "action": "case_resolve",
+ "category": Array [
+ "database",
+ ],
+ "outcome": "success",
+ "type": Array [
+ "access",
+ ],
+ },
+ "kibana": Object {
+ "saved_object": Object {
+ "id": "5",
+ "type": "cases",
+ },
+ },
+ "message": "User has accessed cases [id=5] as owner \\"super\\"",
+}
+`;
+
+exports[`audit_logger log function event structure creates the correct audit event for operation: "resolveCase" without an error or entity 1`] = `
+Object {
+ "event": Object {
+ "action": "case_resolve",
+ "category": Array [
+ "database",
+ ],
+ "outcome": "success",
+ "type": Array [
+ "access",
+ ],
+ },
+ "message": "User has accessed a case as any owners",
+}
+`;
+
exports[`audit_logger log function event structure creates the correct audit event for operation: "updateCase" with an error and entity 1`] = `
Object {
"error": Object {
diff --git a/x-pack/plugins/cases/server/authorization/index.ts b/x-pack/plugins/cases/server/authorization/index.ts
index 90b89c7f75766..1a74640515173 100644
--- a/x-pack/plugins/cases/server/authorization/index.ts
+++ b/x-pack/plugins/cases/server/authorization/index.ts
@@ -152,6 +152,14 @@ export const Operations: Record Promise;
*/
export enum ReadOperations {
GetCase = 'getCase',
+ ResolveCase = 'resolveCase',
FindCases = 'findCases',
GetCaseIDsByAlertID = 'getCaseIDsByAlertID',
GetCaseStatuses = 'getCaseStatuses',
diff --git a/x-pack/plugins/cases/server/client/attachments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts
index 507405d58cef1..b84a6bd84c43b 100644
--- a/x-pack/plugins/cases/server/client/attachments/add.ts
+++ b/x-pack/plugins/cases/server/client/attachments/add.ts
@@ -106,7 +106,7 @@ async function getSubCase({
caseId,
subCaseId: newSubCase.id,
fields: ['status', 'sub_case'],
- newValue: JSON.stringify({ status: newSubCase.attributes.status }),
+ newValue: { status: newSubCase.attributes.status },
owner: newSubCase.attributes.owner,
}),
],
@@ -220,7 +220,7 @@ const addGeneratedAlerts = async (
subCaseId: updatedCase.subCaseId,
commentId: newComment.id,
fields: ['comment'],
- newValue: JSON.stringify(query),
+ newValue: query,
owner: newComment.attributes.owner,
}),
],
@@ -408,7 +408,7 @@ export const addComment = async (
subCaseId: updatedCase.subCaseId,
commentId: newComment.id,
fields: ['comment'],
- newValue: JSON.stringify(query),
+ newValue: query,
owner: newComment.attributes.owner,
}),
],
diff --git a/x-pack/plugins/cases/server/client/attachments/update.ts b/x-pack/plugins/cases/server/client/attachments/update.ts
index 9816efd9a8452..b5e9e6c372355 100644
--- a/x-pack/plugins/cases/server/client/attachments/update.ts
+++ b/x-pack/plugins/cases/server/client/attachments/update.ts
@@ -17,6 +17,7 @@ import {
SUB_CASE_SAVED_OBJECT,
CaseResponse,
CommentPatchRequest,
+ CommentRequest,
} from '../../../common';
import { AttachmentService, CasesService } from '../../services';
import { CasesClientArgs } from '..';
@@ -193,12 +194,12 @@ export async function update(
subCaseId: subCaseID,
commentId: updatedComment.id,
fields: ['comment'],
- newValue: JSON.stringify(queryRestAttributes),
- oldValue: JSON.stringify(
+ // casting because typescript is complaining that it's not a Record even though it is
+ newValue: queryRestAttributes as CommentRequest,
+ oldValue:
// We are interested only in ContextBasicRt attributes
// myComment.attribute contains also CommentAttributesBasicRt attributes
- pick(Object.keys(queryRestAttributes), myComment.attributes)
- ),
+ pick(Object.keys(queryRestAttributes), myComment.attributes),
owner: myComment.attributes.owner,
}),
],
diff --git a/x-pack/plugins/cases/server/client/cases/client.ts b/x-pack/plugins/cases/server/client/cases/client.ts
index 0932308c2e269..fd9bd489f31b2 100644
--- a/x-pack/plugins/cases/server/client/cases/client.ts
+++ b/x-pack/plugins/cases/server/client/cases/client.ts
@@ -18,6 +18,7 @@ import { CasesClient } from '../client';
import { CasesClientInternal } from '../client_internal';
import {
ICasePostRequest,
+ ICaseResolveResponse,
ICaseResponse,
ICasesFindRequest,
ICasesFindResponse,
@@ -31,6 +32,7 @@ import { find } from './find';
import {
CasesByAlertIDParams,
get,
+ resolve,
getCasesByAlertID,
GetParams,
getReporters,
@@ -57,6 +59,11 @@ export interface CasesSubClient {
* Retrieves a single case with the specified ID.
*/
get(params: GetParams): Promise;
+ /**
+ * @experimental
+ * Retrieves a single case resolving the specified ID.
+ */
+ resolve(params: GetParams): Promise;
/**
* Pushes a specific case to an external system.
*/
@@ -99,6 +106,7 @@ export const createCasesSubClient = (
create: (data: CasePostRequest) => create(data, clientArgs),
find: (params: CasesFindRequest) => find(params, clientArgs),
get: (params: GetParams) => get(params, clientArgs),
+ resolve: (params: GetParams) => resolve(params, clientArgs),
push: (params: PushParams) => push(params, clientArgs, casesClient, casesClientInternal),
update: (cases: CasesPatchRequest) => update(cases, clientArgs, casesClientInternal),
delete: (ids: string[]) => deleteCases(ids, clientArgs),
diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts
index 887990fef8938..488bc523f7796 100644
--- a/x-pack/plugins/cases/server/client/cases/create.ts
+++ b/x-pack/plugins/cases/server/client/cases/create.ts
@@ -106,7 +106,7 @@ export const create = async (
actionBy: { username, full_name, email },
caseId: newCase.id,
fields: ['description', 'status', 'tags', 'title', 'connector', 'settings', OWNER_FIELD],
- newValue: JSON.stringify(query),
+ newValue: query,
owner: newCase.attributes.owner,
}),
],
diff --git a/x-pack/plugins/cases/server/client/cases/delete.ts b/x-pack/plugins/cases/server/client/cases/delete.ts
index 80a687a0e72f8..4333535f17a24 100644
--- a/x-pack/plugins/cases/server/client/cases/delete.ts
+++ b/x-pack/plugins/cases/server/client/cases/delete.ts
@@ -168,7 +168,7 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P
'settings',
OWNER_FIELD,
'comment',
- ...(ENABLE_CASE_CONNECTOR ? ['sub_case'] : []),
+ ...(ENABLE_CASE_CONNECTOR ? ['sub_case' as const] : []),
],
owner: caseInfo.attributes.owner,
})
diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts
index 6b0015d4ffb14..c6ab033c2a848 100644
--- a/x-pack/plugins/cases/server/client/cases/get.ts
+++ b/x-pack/plugins/cases/server/client/cases/get.ts
@@ -9,10 +9,12 @@ import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
-import { SavedObject } from 'kibana/server';
+import { SavedObject, SavedObjectsResolveResponse } from 'kibana/server';
import {
CaseResponseRt,
CaseResponse,
+ CaseResolveResponseRt,
+ CaseResolveResponse,
User,
UsersRt,
AllTagsFindRequest,
@@ -230,6 +232,86 @@ export const get = async (
}
};
+/**
+ * Retrieves a case resolving its ID and optionally loading its comments and sub case comments.
+ *
+ * @experimental
+ */
+export const resolve = async (
+ { id, includeComments, includeSubCaseComments }: GetParams,
+ clientArgs: CasesClientArgs
+): Promise => {
+ const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs;
+
+ try {
+ if (!ENABLE_CASE_CONNECTOR && includeSubCaseComments) {
+ throw Boom.badRequest(
+ 'The `includeSubCaseComments` is not supported when the case connector feature is disabled'
+ );
+ }
+
+ const {
+ saved_object: savedObject,
+ ...resolveData
+ }: SavedObjectsResolveResponse = await caseService.getResolveCase({
+ unsecuredSavedObjectsClient,
+ id,
+ });
+
+ await authorization.ensureAuthorized({
+ operation: Operations.resolveCase,
+ entities: [
+ {
+ id: savedObject.id,
+ owner: savedObject.attributes.owner,
+ },
+ ],
+ });
+
+ let subCaseIds: string[] = [];
+ if (ENABLE_CASE_CONNECTOR) {
+ const subCasesForCaseId = await caseService.findSubCasesByCaseId({
+ unsecuredSavedObjectsClient,
+ ids: [id],
+ });
+ subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id);
+ }
+
+ if (!includeComments) {
+ return CaseResolveResponseRt.encode({
+ ...resolveData,
+ case: flattenCaseSavedObject({
+ savedObject,
+ subCaseIds,
+ }),
+ });
+ }
+
+ const theComments = await caseService.getAllCaseComments({
+ unsecuredSavedObjectsClient,
+ id,
+ options: {
+ sortField: 'created_at',
+ sortOrder: 'asc',
+ },
+ includeSubCaseComments: ENABLE_CASE_CONNECTOR && includeSubCaseComments,
+ });
+
+ return CaseResolveResponseRt.encode({
+ ...resolveData,
+ case: flattenCaseSavedObject({
+ savedObject,
+ subCaseIds,
+ comments: theComments.saved_objects,
+ totalComment: theComments.total,
+ totalAlerts: countAlertsForID({ comments: theComments, id }),
+ }),
+ });
+ } catch (error) {
+ throw createCaseError({ message: `Failed to resolve case id: ${id}: ${error}`, error, logger });
+ }
+};
+
/**
* Retrieves the tags from all the cases.
*/
diff --git a/x-pack/plugins/cases/server/client/cases/mock.ts b/x-pack/plugins/cases/server/client/cases/mock.ts
index 313d6cd12a6db..22520cea11014 100644
--- a/x-pack/plugins/cases/server/client/cases/mock.ts
+++ b/x-pack/plugins/cases/server/client/cases/mock.ts
@@ -231,8 +231,10 @@ export const userActions: CaseUserActionsResponse = [
username: 'elastic',
},
new_value:
- '{"title":"Case SIR","tags":["sir"],"description":"testing sir","connector":{"id":"456","name":"ServiceNow SN","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}',
+ '{"title":"Case SIR","tags":["sir"],"description":"testing sir","connector":{"name":"ServiceNow SN","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}',
+ new_val_connector_id: '456',
old_value: null,
+ old_val_connector_id: null,
action_id: 'fd830c60-6646-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
comment_id: null,
@@ -248,7 +250,9 @@ export const userActions: CaseUserActionsResponse = [
username: 'elastic',
},
new_value:
- '{"pushed_at":"2021-02-03T17:41:26.108Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"456","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}',
+ '{"pushed_at":"2021-02-03T17:41:26.108Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}',
+ new_val_connector_id: '456',
+ old_val_connector_id: null,
old_value: null,
action_id: '0a801750-6647-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
@@ -265,6 +269,8 @@ export const userActions: CaseUserActionsResponse = [
username: 'elastic',
},
new_value: '{"type":"alert","alertId":"alert-id-1","index":".siem-signals-default-000008"}',
+ new_val_connector_id: null,
+ old_val_connector_id: null,
old_value: null,
action_id: '7373eb60-6647-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
@@ -282,6 +288,8 @@ export const userActions: CaseUserActionsResponse = [
},
new_value: '{"type":"alert","alertId":"alert-id-2","index":".siem-signals-default-000008"}',
old_value: null,
+ new_val_connector_id: null,
+ old_val_connector_id: null,
action_id: '7abc6410-6647-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
comment_id: 'comment-alert-2',
@@ -297,8 +305,10 @@ export const userActions: CaseUserActionsResponse = [
username: 'elastic',
},
new_value:
- '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"456","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}',
+ '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}',
+ new_val_connector_id: '456',
old_value: null,
+ old_val_connector_id: null,
action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
comment_id: null,
@@ -315,6 +325,8 @@ export const userActions: CaseUserActionsResponse = [
},
new_value: '{"comment":"a comment!","type":"user"}',
old_value: null,
+ new_val_connector_id: null,
+ old_val_connector_id: null,
action_id: '0818e5e0-6648-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
comment_id: 'comment-user-1',
diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts
index 3048cf01bb3ba..1b090a653546d 100644
--- a/x-pack/plugins/cases/server/client/cases/push.ts
+++ b/x-pack/plugins/cases/server/client/cases/push.ts
@@ -241,7 +241,7 @@ export const push = async (
actionBy: { username, full_name, email },
caseId,
fields: ['pushed'],
- newValue: JSON.stringify(externalService),
+ newValue: externalService,
owner: myCase.attributes.owner,
}),
],
diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts
index d7c45d3e1e9ae..315e9966d347b 100644
--- a/x-pack/plugins/cases/server/client/cases/utils.test.ts
+++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts
@@ -799,8 +799,10 @@ describe('utils', () => {
username: 'elastic',
},
new_value:
- // The connector id is 123
- '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"123","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}',
+ // The connector id is 123
+ '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}',
+ new_val_connector_id: '123',
+ old_val_connector_id: null,
old_value: null,
action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53',
case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53',
diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts
index 359ad4b41ead0..f5cf2fe4b3f51 100644
--- a/x-pack/plugins/cases/server/client/cases/utils.ts
+++ b/x-pack/plugins/cases/server/client/cases/utils.ts
@@ -20,6 +20,8 @@ import {
CommentRequestUserType,
CommentRequestAlertType,
CommentRequestActionsType,
+ CaseUserActionResponse,
+ isPush,
} from '../../../common';
import { ActionsClient } from '../../../../actions/server';
import { CasesClientGetAlertsResponse } from '../../client/alerts/types';
@@ -55,22 +57,36 @@ export const getLatestPushInfo = (
userActions: CaseUserActionsResponse
): { index: number; pushedInfo: CaseFullExternalService } | null => {
for (const [index, action] of [...userActions].reverse().entries()) {
- if (action.action === 'push-to-service' && action.new_value)
+ if (
+ isPush(action.action, action.action_field) &&
+ isValidNewValue(action) &&
+ connectorId === action.new_val_connector_id
+ ) {
try {
const pushedInfo = JSON.parse(action.new_value);
- if (pushedInfo.connector_id === connectorId) {
- // We returned the index of the element in the userActions array.
- // As we traverse the userActions in reverse we need to calculate the index of a normal traversal
- return { index: userActions.length - index - 1, pushedInfo };
- }
+ // We returned the index of the element in the userActions array.
+ // As we traverse the userActions in reverse we need to calculate the index of a normal traversal
+ return {
+ index: userActions.length - index - 1,
+ pushedInfo: { ...pushedInfo, connector_id: connectorId },
+ };
} catch (e) {
- // Silence JSON parse errors
+ // ignore parse failures and check the next user action
}
+ }
}
return null;
};
+type NonNullNewValueAction = Omit & {
+ new_value: string;
+ new_val_connector_id: string;
+};
+
+const isValidNewValue = (userAction: CaseUserActionResponse): userAction is NonNullNewValueAction =>
+ userAction.new_val_connector_id != null && userAction.new_value != null;
+
const getCommentContent = (comment: CommentResponse): string => {
if (comment.type === CommentType.user) {
return comment.comment;
diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts
index 05b7d055656b1..f0ca7ae9eaf71 100644
--- a/x-pack/plugins/cases/server/client/mocks.ts
+++ b/x-pack/plugins/cases/server/client/mocks.ts
@@ -22,6 +22,7 @@ const createCasesSubClientMock = (): CasesSubClientMock => {
return {
create: jest.fn(),
find: jest.fn(),
+ resolve: jest.fn(),
get: jest.fn(),
push: jest.fn(),
update: jest.fn(),
diff --git a/x-pack/plugins/cases/server/client/typedoc_interfaces.ts b/x-pack/plugins/cases/server/client/typedoc_interfaces.ts
index bf444ee9420ed..feeaa6b6dcb58 100644
--- a/x-pack/plugins/cases/server/client/typedoc_interfaces.ts
+++ b/x-pack/plugins/cases/server/client/typedoc_interfaces.ts
@@ -16,6 +16,7 @@
import {
AllCommentsResponse,
CasePostRequest,
+ CaseResolveResponse,
CaseResponse,
CasesConfigurePatch,
CasesConfigureRequest,
@@ -40,6 +41,7 @@ export interface ICasePostRequest extends CasePostRequest {}
export interface ICasesFindRequest extends CasesFindRequest {}
export interface ICasesPatchRequest extends CasesPatchRequest {}
export interface ICaseResponse extends CaseResponse {}
+export interface ICaseResolveResponse extends CaseResolveResponse {}
export interface ICasesResponse extends CasesResponse {}
export interface ICasesFindResponse extends CasesFindResponse {}
diff --git a/x-pack/plugins/cases/server/client/user_actions/get.test.ts b/x-pack/plugins/cases/server/client/user_actions/get.test.ts
new file mode 100644
index 0000000000000..302e069cde4d1
--- /dev/null
+++ b/x-pack/plugins/cases/server/client/user_actions/get.test.ts
@@ -0,0 +1,106 @@
+/*
+ * 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 { CaseUserActionResponse, SUB_CASE_SAVED_OBJECT } from '../../../common';
+import { SUB_CASE_REF_NAME } from '../../common';
+import { extractAttributesWithoutSubCases } from './get';
+
+describe('get', () => {
+ describe('extractAttributesWithoutSubCases', () => {
+ it('returns an empty array when given an empty array', () => {
+ expect(
+ extractAttributesWithoutSubCases({ ...getFindResponseFields(), saved_objects: [] })
+ ).toEqual([]);
+ });
+
+ it('filters out saved objects with a sub case reference', () => {
+ expect(
+ extractAttributesWithoutSubCases({
+ ...getFindResponseFields(),
+ saved_objects: [
+ {
+ type: 'a',
+ references: [{ name: SUB_CASE_REF_NAME, type: SUB_CASE_SAVED_OBJECT, id: '1' }],
+ id: 'b',
+ score: 0,
+ attributes: {} as CaseUserActionResponse,
+ },
+ ],
+ })
+ ).toEqual([]);
+ });
+
+ it('filters out saved objects with a sub case reference with other references', () => {
+ expect(
+ extractAttributesWithoutSubCases({
+ ...getFindResponseFields(),
+ saved_objects: [
+ {
+ type: 'a',
+ references: [
+ { name: SUB_CASE_REF_NAME, type: SUB_CASE_SAVED_OBJECT, id: '1' },
+ { name: 'a', type: 'b', id: '5' },
+ ],
+ id: 'b',
+ score: 0,
+ attributes: {} as CaseUserActionResponse,
+ },
+ ],
+ })
+ ).toEqual([]);
+ });
+
+ it('keeps saved objects that do not have a sub case reference', () => {
+ expect(
+ extractAttributesWithoutSubCases({
+ ...getFindResponseFields(),
+ saved_objects: [
+ {
+ type: 'a',
+ references: [
+ { name: SUB_CASE_REF_NAME, type: 'awesome', id: '1' },
+ { name: 'a', type: 'b', id: '5' },
+ ],
+ id: 'b',
+ score: 0,
+ attributes: { field: '1' } as unknown as CaseUserActionResponse,
+ },
+ ],
+ })
+ ).toEqual([{ field: '1' }]);
+ });
+
+ it('filters multiple saved objects correctly', () => {
+ expect(
+ extractAttributesWithoutSubCases({
+ ...getFindResponseFields(),
+ saved_objects: [
+ {
+ type: 'a',
+ references: [
+ { name: SUB_CASE_REF_NAME, type: 'awesome', id: '1' },
+ { name: 'a', type: 'b', id: '5' },
+ ],
+ id: 'b',
+ score: 0,
+ attributes: { field: '2' } as unknown as CaseUserActionResponse,
+ },
+ {
+ type: 'a',
+ references: [{ name: SUB_CASE_REF_NAME, type: SUB_CASE_SAVED_OBJECT, id: '1' }],
+ id: 'b',
+ score: 0,
+ attributes: { field: '1' } as unknown as CaseUserActionResponse,
+ },
+ ],
+ })
+ ).toEqual([{ field: '2' }]);
+ });
+ });
+});
+
+const getFindResponseFields = () => ({ page: 1, per_page: 1, total: 0 });
diff --git a/x-pack/plugins/cases/server/client/user_actions/get.ts b/x-pack/plugins/cases/server/client/user_actions/get.ts
index 2a6608014c800..660cf1b6a336e 100644
--- a/x-pack/plugins/cases/server/client/user_actions/get.ts
+++ b/x-pack/plugins/cases/server/client/user_actions/get.ts
@@ -5,14 +5,14 @@
* 2.0.
*/
+import { SavedObjectReference, SavedObjectsFindResponse } from 'kibana/server';
import {
- CASE_COMMENT_SAVED_OBJECT,
- CASE_SAVED_OBJECT,
CaseUserActionsResponse,
CaseUserActionsResponseRt,
SUB_CASE_SAVED_OBJECT,
+ CaseUserActionResponse,
} from '../../../common';
-import { createCaseError, checkEnabledCaseConnectorOrThrow } from '../../common';
+import { createCaseError, checkEnabledCaseConnectorOrThrow, SUB_CASE_REF_NAME } from '../../common';
import { CasesClientArgs } from '..';
import { Operations } from '../../authorization';
import { UserActionGet } from './client';
@@ -40,23 +40,12 @@ export const get = async (
operation: Operations.getUserActions,
});
- return CaseUserActionsResponseRt.encode(
- userActions.saved_objects.reduce((acc, ua) => {
- if (subCaseId == null && ua.references.some((uar) => uar.type === SUB_CASE_SAVED_OBJECT)) {
- return acc;
- }
- return [
- ...acc,
- {
- ...ua.attributes,
- action_id: ua.id,
- case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '',
- comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null,
- sub_case_id: ua.references.find((r) => r.type === SUB_CASE_SAVED_OBJECT)?.id ?? '',
- },
- ];
- }, [])
- );
+ const resultsToEncode =
+ subCaseId == null
+ ? extractAttributesWithoutSubCases(userActions)
+ : extractAttributes(userActions);
+
+ return CaseUserActionsResponseRt.encode(resultsToEncode);
} catch (error) {
throw createCaseError({
message: `Failed to retrieve user actions case id: ${caseId} sub case id: ${subCaseId}: ${error}`,
@@ -65,3 +54,21 @@ export const get = async (
});
}
};
+
+export function extractAttributesWithoutSubCases(
+ userActions: SavedObjectsFindResponse
+): CaseUserActionsResponse {
+ // exclude user actions relating to sub cases from the results
+ const hasSubCaseReference = (references: SavedObjectReference[]) =>
+ references.find((ref) => ref.type === SUB_CASE_SAVED_OBJECT && ref.name === SUB_CASE_REF_NAME);
+
+ return userActions.saved_objects
+ .filter((so) => !hasSubCaseReference(so.references))
+ .map((so) => so.attributes);
+}
+
+function extractAttributes(
+ userActions: SavedObjectsFindResponse
+): CaseUserActionsResponse {
+ return userActions.saved_objects.map((so) => so.attributes);
+}
diff --git a/x-pack/plugins/cases/server/common/constants.ts b/x-pack/plugins/cases/server/common/constants.ts
index 1f6af310d6ece..eba0a64a5c0be 100644
--- a/x-pack/plugins/cases/server/common/constants.ts
+++ b/x-pack/plugins/cases/server/common/constants.ts
@@ -5,6 +5,8 @@
* 2.0.
*/
+import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../common';
+
/**
* The name of the saved object reference indicating the action connector ID. This is stored in the Saved Object reference
* field's name property.
@@ -15,3 +17,30 @@ export const CONNECTOR_ID_REFERENCE_NAME = 'connectorId';
* The name of the saved object reference indicating the action connector ID that was used to push a case.
*/
export const PUSH_CONNECTOR_ID_REFERENCE_NAME = 'pushConnectorId';
+
+/**
+ * The name of the saved object reference indicating the action connector ID that was used for
+ * adding a connector, or updating the existing connector for a user action's old_value field.
+ */
+export const USER_ACTION_OLD_ID_REF_NAME = 'oldConnectorId';
+
+/**
+ * The name of the saved object reference indicating the action connector ID that was used for pushing a case,
+ * for a user action's old_value field.
+ */
+export const USER_ACTION_OLD_PUSH_ID_REF_NAME = 'oldPushConnectorId';
+
+/**
+ * The name of the saved object reference indicating the caseId reference
+ */
+export const CASE_REF_NAME = `associated-${CASE_SAVED_OBJECT}`;
+
+/**
+ * The name of the saved object reference indicating the commentId reference
+ */
+export const COMMENT_REF_NAME = `associated-${CASE_COMMENT_SAVED_OBJECT}`;
+
+/**
+ * The name of the saved object reference indicating the subCaseId reference
+ */
+export const SUB_CASE_REF_NAME = `associated-${SUB_CASE_SAVED_OBJECT}`;
diff --git a/x-pack/plugins/cases/server/index.ts b/x-pack/plugins/cases/server/index.ts
index 5e433b46b80e5..ad76724eb49f7 100644
--- a/x-pack/plugins/cases/server/index.ts
+++ b/x-pack/plugins/cases/server/index.ts
@@ -15,7 +15,8 @@ export const config: PluginConfigDescriptor = {
exposeToBrowser: {
markdownPlugins: true,
},
- deprecations: ({ renameFromRoot }) => [
+ deprecations: ({ deprecate, renameFromRoot }) => [
+ deprecate('enabled', '8.0.0'),
renameFromRoot('xpack.case.enabled', 'xpack.cases.enabled'),
],
};
diff --git a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts
index 2313c3cad9007..4d81b6d5e11b3 100644
--- a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts
+++ b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts
@@ -45,4 +45,38 @@ export function initGetCaseApi({ router, logger }: RouteDeps) {
}
}
);
+
+ router.get(
+ {
+ path: `${CASE_DETAILS_URL}/resolve`,
+ validate: {
+ params: schema.object({
+ case_id: schema.string(),
+ }),
+ query: schema.object({
+ includeComments: schema.boolean({ defaultValue: true }),
+ includeSubCaseComments: schema.maybe(schema.boolean({ defaultValue: false })),
+ }),
+ },
+ },
+ async (context, request, response) => {
+ try {
+ const casesClient = await context.cases.getCasesClient();
+ const id = request.params.case_id;
+
+ return response.ok({
+ body: await casesClient.cases.resolve({
+ id,
+ includeComments: request.query.includeComments,
+ includeSubCaseComments: request.query.includeSubCaseComments,
+ }),
+ });
+ } catch (error) {
+ logger.error(
+ `Failed to retrieve case in resolve 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/cases/server/saved_object_types/cases.ts b/x-pack/plugins/cases/server/saved_object_types/cases.ts
index a362d77c06626..74c6a053e95c0 100644
--- a/x-pack/plugins/cases/server/saved_object_types/cases.ts
+++ b/x-pack/plugins/cases/server/saved_object_types/cases.ts
@@ -117,7 +117,7 @@ export const createCaseSavedObjectType = (
type: 'keyword',
},
title: {
- type: 'keyword',
+ type: 'text',
},
status: {
type: 'keyword',
diff --git a/x-pack/plugins/cases/server/saved_object_types/comments.ts b/x-pack/plugins/cases/server/saved_object_types/comments.ts
index af14123eca580..64e75ad26ae28 100644
--- a/x-pack/plugins/cases/server/saved_object_types/comments.ts
+++ b/x-pack/plugins/cases/server/saved_object_types/comments.ts
@@ -112,5 +112,6 @@ export const createCaseCommentSavedObjectType = ({
migrations: createCommentsMigrations(migrationDeps),
management: {
importableAndExportable: true,
+ visibleInManagement: false,
},
});
diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts
index bca12a86a544e..9020f65ae352c 100644
--- a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts
+++ b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts
@@ -30,322 +30,324 @@ const create_7_14_0_case = ({
},
});
-describe('7.15.0 connector ID migration', () => {
- it('does not create a reference when the connector.id is none', () => {
- const caseSavedObject = create_7_14_0_case({ connector: getNoneCaseConnector() });
-
- const migratedConnector = caseConnectorIdMigration(
- caseSavedObject
- ) as SavedObjectSanitizedDoc;
-
- expect(migratedConnector.references.length).toBe(0);
- expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
- expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
- Object {
- "fields": null,
- "name": "none",
- "type": ".none",
- }
- `);
- });
+describe('case migrations', () => {
+ describe('7.15.0 connector ID migration', () => {
+ it('does not create a reference when the connector.id is none', () => {
+ const caseSavedObject = create_7_14_0_case({ connector: getNoneCaseConnector() });
+
+ const migratedConnector = caseConnectorIdMigration(
+ caseSavedObject
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedConnector.references.length).toBe(0);
+ expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
+ expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
+ Object {
+ "fields": null,
+ "name": "none",
+ "type": ".none",
+ }
+ `);
+ });
- it('does not create a reference when the connector is undefined', () => {
- const caseSavedObject = create_7_14_0_case();
-
- const migratedConnector = caseConnectorIdMigration(
- caseSavedObject
- ) as SavedObjectSanitizedDoc;
-
- expect(migratedConnector.references.length).toBe(0);
- expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
- expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
- Object {
- "fields": null,
- "name": "none",
- "type": ".none",
- }
- `);
- });
+ it('does not create a reference when the connector is undefined', () => {
+ const caseSavedObject = create_7_14_0_case();
- it('sets the connector to the default none connector if the connector.id is undefined', () => {
- const caseSavedObject = create_7_14_0_case({
- connector: {
- fields: null,
- name: ConnectorTypes.jira,
- type: ConnectorTypes.jira,
- } as ESCaseConnectorWithId,
- });
+ const migratedConnector = caseConnectorIdMigration(
+ caseSavedObject
+ ) as SavedObjectSanitizedDoc;
- const migratedConnector = caseConnectorIdMigration(
- caseSavedObject
- ) as SavedObjectSanitizedDoc;
-
- expect(migratedConnector.references.length).toBe(0);
- expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
- expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
- Object {
- "fields": null,
- "name": "none",
- "type": ".none",
- }
- `);
- });
+ expect(migratedConnector.references.length).toBe(0);
+ expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
+ expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
+ Object {
+ "fields": null,
+ "name": "none",
+ "type": ".none",
+ }
+ `);
+ });
- it('does not create a reference when the external_service is null', () => {
- const caseSavedObject = create_7_14_0_case({ externalService: null });
+ it('sets the connector to the default none connector if the connector.id is undefined', () => {
+ const caseSavedObject = create_7_14_0_case({
+ connector: {
+ fields: null,
+ name: ConnectorTypes.jira,
+ type: ConnectorTypes.jira,
+ } as ESCaseConnectorWithId,
+ });
+
+ const migratedConnector = caseConnectorIdMigration(
+ caseSavedObject
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedConnector.references.length).toBe(0);
+ expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
+ expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
+ Object {
+ "fields": null,
+ "name": "none",
+ "type": ".none",
+ }
+ `);
+ });
- const migratedConnector = caseConnectorIdMigration(
- caseSavedObject
- ) as SavedObjectSanitizedDoc;
+ it('does not create a reference when the external_service is null', () => {
+ const caseSavedObject = create_7_14_0_case({ externalService: null });
- expect(migratedConnector.references.length).toBe(0);
- expect(migratedConnector.attributes.external_service).toBeNull();
- });
+ const migratedConnector = caseConnectorIdMigration(
+ caseSavedObject
+ ) as SavedObjectSanitizedDoc;
- it('does not create a reference when the external_service is undefined and sets external_service to null', () => {
- const caseSavedObject = create_7_14_0_case();
+ expect(migratedConnector.references.length).toBe(0);
+ expect(migratedConnector.attributes.external_service).toBeNull();
+ });
- const migratedConnector = caseConnectorIdMigration(
- caseSavedObject
- ) as SavedObjectSanitizedDoc;
+ it('does not create a reference when the external_service is undefined and sets external_service to null', () => {
+ const caseSavedObject = create_7_14_0_case();
- expect(migratedConnector.references.length).toBe(0);
- expect(migratedConnector.attributes.external_service).toBeNull();
- });
+ const migratedConnector = caseConnectorIdMigration(
+ caseSavedObject
+ ) as SavedObjectSanitizedDoc;
- it('does not create a reference when the external_service.connector_id is none', () => {
- const caseSavedObject = create_7_14_0_case({
- externalService: createExternalService({ connector_id: noneConnectorId }),
+ expect(migratedConnector.references.length).toBe(0);
+ expect(migratedConnector.attributes.external_service).toBeNull();
});
- const migratedConnector = caseConnectorIdMigration(
- caseSavedObject
- ) as SavedObjectSanitizedDoc;
-
- expect(migratedConnector.references.length).toBe(0);
- expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(`
- Object {
- "connector_name": ".jira",
- "external_id": "100",
- "external_title": "awesome",
- "external_url": "http://www.google.com",
- "pushed_at": "2019-11-25T21:54:48.952Z",
- "pushed_by": Object {
- "email": "testemail@elastic.co",
- "full_name": "elastic",
- "username": "elastic",
- },
- }
- `);
- });
-
- it('preserves the existing references when migrating', () => {
- const caseSavedObject = {
- ...create_7_14_0_case(),
- references: [{ id: '1', name: 'awesome', type: 'hello' }],
- };
+ it('does not create a reference when the external_service.connector_id is none', () => {
+ const caseSavedObject = create_7_14_0_case({
+ externalService: createExternalService({ connector_id: noneConnectorId }),
+ });
- const migratedConnector = caseConnectorIdMigration(
- caseSavedObject
- ) as SavedObjectSanitizedDoc;
+ const migratedConnector = caseConnectorIdMigration(
+ caseSavedObject
+ ) as SavedObjectSanitizedDoc;
- expect(migratedConnector.references.length).toBe(1);
- expect(migratedConnector.references).toMatchInlineSnapshot(`
- Array [
+ expect(migratedConnector.references.length).toBe(0);
+ expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(`
Object {
- "id": "1",
- "name": "awesome",
- "type": "hello",
- },
- ]
- `);
- });
+ "connector_name": ".jira",
+ "external_id": "100",
+ "external_title": "awesome",
+ "external_url": "http://www.google.com",
+ "pushed_at": "2019-11-25T21:54:48.952Z",
+ "pushed_by": Object {
+ "email": "testemail@elastic.co",
+ "full_name": "elastic",
+ "username": "elastic",
+ },
+ }
+ `);
+ });
- it('creates a connector reference and removes the connector.id field', () => {
- const caseSavedObject = create_7_14_0_case({
- connector: {
- id: '123',
- fields: null,
- name: 'connector',
- type: ConnectorTypes.jira,
- },
+ it('preserves the existing references when migrating', () => {
+ const caseSavedObject = {
+ ...create_7_14_0_case(),
+ references: [{ id: '1', name: 'awesome', type: 'hello' }],
+ };
+
+ const migratedConnector = caseConnectorIdMigration(
+ caseSavedObject
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedConnector.references.length).toBe(1);
+ expect(migratedConnector.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "awesome",
+ "type": "hello",
+ },
+ ]
+ `);
});
- const migratedConnector = caseConnectorIdMigration(
- caseSavedObject
- ) as SavedObjectSanitizedDoc;
-
- expect(migratedConnector.references.length).toBe(1);
- expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
- expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
- Object {
- "fields": null,
- "name": "connector",
- "type": ".jira",
- }
- `);
- expect(migratedConnector.references).toMatchInlineSnapshot(`
- Array [
- Object {
- "id": "123",
- "name": "connectorId",
- "type": "action",
+ it('creates a connector reference and removes the connector.id field', () => {
+ const caseSavedObject = create_7_14_0_case({
+ connector: {
+ id: '123',
+ fields: null,
+ name: 'connector',
+ type: ConnectorTypes.jira,
},
- ]
- `);
- });
+ });
- it('creates a push connector reference and removes the connector_id field', () => {
- const caseSavedObject = create_7_14_0_case({
- externalService: {
- connector_id: '100',
- connector_name: '.jira',
- external_id: '100',
- external_title: 'awesome',
- external_url: 'http://www.google.com',
- pushed_at: '2019-11-25T21:54:48.952Z',
- pushed_by: {
- full_name: 'elastic',
- email: 'testemail@elastic.co',
- username: 'elastic',
- },
- },
- });
+ const migratedConnector = caseConnectorIdMigration(
+ caseSavedObject
+ ) as SavedObjectSanitizedDoc;
- const migratedConnector = caseConnectorIdMigration(
- caseSavedObject
- ) as SavedObjectSanitizedDoc;
-
- expect(migratedConnector.references.length).toBe(1);
- expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id');
- expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(`
- Object {
- "connector_name": ".jira",
- "external_id": "100",
- "external_title": "awesome",
- "external_url": "http://www.google.com",
- "pushed_at": "2019-11-25T21:54:48.952Z",
- "pushed_by": Object {
- "email": "testemail@elastic.co",
- "full_name": "elastic",
- "username": "elastic",
- },
- }
- `);
- expect(migratedConnector.references).toMatchInlineSnapshot(`
- Array [
+ expect(migratedConnector.references.length).toBe(1);
+ expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
+ expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
Object {
- "id": "100",
- "name": "pushConnectorId",
- "type": "action",
- },
- ]
- `);
- });
+ "fields": null,
+ "name": "connector",
+ "type": ".jira",
+ }
+ `);
+ expect(migratedConnector.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "123",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
- it('does not create a reference and preserves the existing external_service fields when connector_id is null', () => {
- const caseSavedObject = create_7_14_0_case({
- externalService: {
- connector_id: null,
- connector_name: '.jira',
- external_id: '100',
- external_title: 'awesome',
- external_url: 'http://www.google.com',
- pushed_at: '2019-11-25T21:54:48.952Z',
- pushed_by: {
- full_name: 'elastic',
- email: 'testemail@elastic.co',
- username: 'elastic',
+ it('creates a push connector reference and removes the connector_id field', () => {
+ const caseSavedObject = create_7_14_0_case({
+ externalService: {
+ connector_id: '100',
+ connector_name: '.jira',
+ external_id: '100',
+ external_title: 'awesome',
+ external_url: 'http://www.google.com',
+ pushed_at: '2019-11-25T21:54:48.952Z',
+ pushed_by: {
+ full_name: 'elastic',
+ email: 'testemail@elastic.co',
+ username: 'elastic',
+ },
},
- },
+ });
+
+ const migratedConnector = caseConnectorIdMigration(
+ caseSavedObject
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedConnector.references.length).toBe(1);
+ expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id');
+ expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(`
+ Object {
+ "connector_name": ".jira",
+ "external_id": "100",
+ "external_title": "awesome",
+ "external_url": "http://www.google.com",
+ "pushed_at": "2019-11-25T21:54:48.952Z",
+ "pushed_by": Object {
+ "email": "testemail@elastic.co",
+ "full_name": "elastic",
+ "username": "elastic",
+ },
+ }
+ `);
+ expect(migratedConnector.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "100",
+ "name": "pushConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
});
- const migratedConnector = caseConnectorIdMigration(
- caseSavedObject
- ) as SavedObjectSanitizedDoc;
-
- expect(migratedConnector.references.length).toBe(0);
- expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id');
- expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(`
- Object {
- "connector_name": ".jira",
- "external_id": "100",
- "external_title": "awesome",
- "external_url": "http://www.google.com",
- "pushed_at": "2019-11-25T21:54:48.952Z",
- "pushed_by": Object {
- "email": "testemail@elastic.co",
- "full_name": "elastic",
- "username": "elastic",
+ it('does not create a reference and preserves the existing external_service fields when connector_id is null', () => {
+ const caseSavedObject = create_7_14_0_case({
+ externalService: {
+ connector_id: null,
+ connector_name: '.jira',
+ external_id: '100',
+ external_title: 'awesome',
+ external_url: 'http://www.google.com',
+ pushed_at: '2019-11-25T21:54:48.952Z',
+ pushed_by: {
+ full_name: 'elastic',
+ email: 'testemail@elastic.co',
+ username: 'elastic',
+ },
},
- }
- `);
- });
+ });
- it('migrates both connector and external_service when provided', () => {
- const caseSavedObject = create_7_14_0_case({
- externalService: {
- connector_id: '100',
- connector_name: '.jira',
- external_id: '100',
- external_title: 'awesome',
- external_url: 'http://www.google.com',
- pushed_at: '2019-11-25T21:54:48.952Z',
- pushed_by: {
- full_name: 'elastic',
- email: 'testemail@elastic.co',
- username: 'elastic',
- },
- },
- connector: {
- id: '123',
- fields: null,
- name: 'connector',
- type: ConnectorTypes.jira,
- },
+ const migratedConnector = caseConnectorIdMigration(
+ caseSavedObject
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedConnector.references.length).toBe(0);
+ expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id');
+ expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(`
+ Object {
+ "connector_name": ".jira",
+ "external_id": "100",
+ "external_title": "awesome",
+ "external_url": "http://www.google.com",
+ "pushed_at": "2019-11-25T21:54:48.952Z",
+ "pushed_by": Object {
+ "email": "testemail@elastic.co",
+ "full_name": "elastic",
+ "username": "elastic",
+ },
+ }
+ `);
});
- const migratedConnector = caseConnectorIdMigration(
- caseSavedObject
- ) as SavedObjectSanitizedDoc;
-
- expect(migratedConnector.references.length).toBe(2);
- expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id');
- expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(`
- Object {
- "connector_name": ".jira",
- "external_id": "100",
- "external_title": "awesome",
- "external_url": "http://www.google.com",
- "pushed_at": "2019-11-25T21:54:48.952Z",
- "pushed_by": Object {
- "email": "testemail@elastic.co",
- "full_name": "elastic",
- "username": "elastic",
+ it('migrates both connector and external_service when provided', () => {
+ const caseSavedObject = create_7_14_0_case({
+ externalService: {
+ connector_id: '100',
+ connector_name: '.jira',
+ external_id: '100',
+ external_title: 'awesome',
+ external_url: 'http://www.google.com',
+ pushed_at: '2019-11-25T21:54:48.952Z',
+ pushed_by: {
+ full_name: 'elastic',
+ email: 'testemail@elastic.co',
+ username: 'elastic',
+ },
},
- }
- `);
- expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
- expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
- Object {
- "fields": null,
- "name": "connector",
- "type": ".jira",
- }
- `);
- expect(migratedConnector.references).toMatchInlineSnapshot(`
- Array [
- Object {
- "id": "123",
- "name": "connectorId",
- "type": "action",
+ connector: {
+ id: '123',
+ fields: null,
+ name: 'connector',
+ type: ConnectorTypes.jira,
},
+ });
+
+ const migratedConnector = caseConnectorIdMigration(
+ caseSavedObject
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedConnector.references.length).toBe(2);
+ expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id');
+ expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(`
Object {
- "id": "100",
- "name": "pushConnectorId",
- "type": "action",
- },
- ]
- `);
+ "connector_name": ".jira",
+ "external_id": "100",
+ "external_title": "awesome",
+ "external_url": "http://www.google.com",
+ "pushed_at": "2019-11-25T21:54:48.952Z",
+ "pushed_by": Object {
+ "email": "testemail@elastic.co",
+ "full_name": "elastic",
+ "username": "elastic",
+ },
+ }
+ `);
+ expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
+ expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
+ Object {
+ "fields": null,
+ "name": "connector",
+ "type": ".jira",
+ }
+ `);
+ expect(migratedConnector.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "123",
+ "name": "connectorId",
+ "type": "action",
+ },
+ Object {
+ "id": "100",
+ "name": "pushConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
});
});
diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts
index bffd4171270ef..80f02fa3bf6a6 100644
--- a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts
+++ b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts
@@ -14,7 +14,11 @@ import {
} from '../../../../../../src/core/server';
import { ESConnectorFields } from '../../services';
import { ConnectorTypes, CaseType } from '../../../common';
-import { transformConnectorIdToReference, transformPushConnectorIdToReference } from './utils';
+import {
+ transformConnectorIdToReference,
+ transformPushConnectorIdToReference,
+} from '../../services/user_actions/transform';
+import { CONNECTOR_ID_REFERENCE_NAME, PUSH_CONNECTOR_ID_REFERENCE_NAME } from '../../common';
interface UnsanitizedCaseConnector {
connector_id: string;
@@ -50,11 +54,13 @@ export const caseConnectorIdMigration = (
// removing the id field since it will be stored in the references instead
const { connector, external_service, ...restAttributes } = doc.attributes;
- const { transformedConnector, references: connectorReferences } =
- transformConnectorIdToReference(connector);
+ const { transformedConnector, references: connectorReferences } = transformConnectorIdToReference(
+ CONNECTOR_ID_REFERENCE_NAME,
+ connector
+ );
const { transformedPushConnector, references: pushConnectorReferences } =
- transformPushConnectorIdToReference(external_service);
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, external_service);
const { references = [] } = doc;
diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts
index 4467b499817a5..9ae0285598dbf 100644
--- a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts
+++ b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts
@@ -40,87 +40,89 @@ const create_7_14_0_configSchema = (connector?: ESCaseConnectorWithId) => ({
},
});
-describe('7.15.0 connector ID migration', () => {
- it('does not create a reference when the connector ID is none', () => {
- const configureSavedObject = create_7_14_0_configSchema(getNoneCaseConnector());
+describe('configuration migrations', () => {
+ describe('7.15.0 connector ID migration', () => {
+ it('does not create a reference when the connector ID is none', () => {
+ const configureSavedObject = create_7_14_0_configSchema(getNoneCaseConnector());
- const migratedConnector = configureConnectorIdMigration(
- configureSavedObject
- ) as SavedObjectSanitizedDoc;
+ const migratedConnector = configureConnectorIdMigration(
+ configureSavedObject
+ ) as SavedObjectSanitizedDoc;
- expect(migratedConnector.references.length).toBe(0);
- expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
- });
+ expect(migratedConnector.references.length).toBe(0);
+ expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
+ });
- it('does not create a reference when the connector is undefined and defaults it to the none connector', () => {
- const configureSavedObject = create_7_14_0_configSchema();
+ it('does not create a reference when the connector is undefined and defaults it to the none connector', () => {
+ const configureSavedObject = create_7_14_0_configSchema();
- const migratedConnector = configureConnectorIdMigration(
- configureSavedObject
- ) as SavedObjectSanitizedDoc;
+ const migratedConnector = configureConnectorIdMigration(
+ configureSavedObject
+ ) as SavedObjectSanitizedDoc;
- expect(migratedConnector.references.length).toBe(0);
- expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
- Object {
- "fields": null,
- "name": "none",
- "type": ".none",
- }
- `);
- });
-
- it('creates a reference using the connector id', () => {
- const configureSavedObject = create_7_14_0_configSchema({
- id: '123',
- fields: null,
- name: 'connector',
- type: ConnectorTypes.jira,
+ expect(migratedConnector.references.length).toBe(0);
+ expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(`
+ Object {
+ "fields": null,
+ "name": "none",
+ "type": ".none",
+ }
+ `);
});
- const migratedConnector = configureConnectorIdMigration(
- configureSavedObject
- ) as SavedObjectSanitizedDoc;
+ it('creates a reference using the connector id', () => {
+ const configureSavedObject = create_7_14_0_configSchema({
+ id: '123',
+ fields: null,
+ name: 'connector',
+ type: ConnectorTypes.jira,
+ });
- expect(migratedConnector.references).toEqual([
- { id: '123', type: ACTION_SAVED_OBJECT_TYPE, name: CONNECTOR_ID_REFERENCE_NAME },
- ]);
- expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
- });
+ const migratedConnector = configureConnectorIdMigration(
+ configureSavedObject
+ ) as SavedObjectSanitizedDoc;
- it('returns the other attributes and default connector when the connector is undefined', () => {
- const configureSavedObject = create_7_14_0_configSchema();
+ expect(migratedConnector.references).toEqual([
+ { id: '123', type: ACTION_SAVED_OBJECT_TYPE, name: CONNECTOR_ID_REFERENCE_NAME },
+ ]);
+ expect(migratedConnector.attributes.connector).not.toHaveProperty('id');
+ });
- const migratedConnector = configureConnectorIdMigration(
- configureSavedObject
- ) as SavedObjectSanitizedDoc;
+ it('returns the other attributes and default connector when the connector is undefined', () => {
+ const configureSavedObject = create_7_14_0_configSchema();
- expect(migratedConnector).toMatchInlineSnapshot(`
- Object {
- "attributes": Object {
- "closure_type": "close-by-pushing",
- "connector": Object {
- "fields": null,
- "name": "none",
- "type": ".none",
- },
- "created_at": "2020-04-09T09:43:51.778Z",
- "created_by": Object {
- "email": "testemail@elastic.co",
- "full_name": "elastic",
- "username": "elastic",
- },
- "owner": "securitySolution",
- "updated_at": "2020-04-09T09:43:51.778Z",
- "updated_by": Object {
- "email": "testemail@elastic.co",
- "full_name": "elastic",
- "username": "elastic",
+ const migratedConnector = configureConnectorIdMigration(
+ configureSavedObject
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedConnector).toMatchInlineSnapshot(`
+ Object {
+ "attributes": Object {
+ "closure_type": "close-by-pushing",
+ "connector": Object {
+ "fields": null,
+ "name": "none",
+ "type": ".none",
+ },
+ "created_at": "2020-04-09T09:43:51.778Z",
+ "created_by": Object {
+ "email": "testemail@elastic.co",
+ "full_name": "elastic",
+ "username": "elastic",
+ },
+ "owner": "securitySolution",
+ "updated_at": "2020-04-09T09:43:51.778Z",
+ "updated_by": Object {
+ "email": "testemail@elastic.co",
+ "full_name": "elastic",
+ "username": "elastic",
+ },
},
- },
- "id": "1",
- "references": Array [],
- "type": "cases-configure",
- }
- `);
+ "id": "1",
+ "references": Array [],
+ "type": "cases-configure",
+ }
+ `);
+ });
});
});
diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts
index 527d40fca2e35..f9937253e0d2f 100644
--- a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts
+++ b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts
@@ -13,7 +13,8 @@ import {
} from '../../../../../../src/core/server';
import { ConnectorTypes } from '../../../common';
import { addOwnerToSO, SanitizedCaseOwner } from '.';
-import { transformConnectorIdToReference } from './utils';
+import { transformConnectorIdToReference } from '../../services/user_actions/transform';
+import { CONNECTOR_ID_REFERENCE_NAME } from '../../common';
interface UnsanitizedConfigureConnector {
connector_id: string;
@@ -34,8 +35,10 @@ export const configureConnectorIdMigration = (
): SavedObjectSanitizedDoc => {
// removing the id field since it will be stored in the references instead
const { connector, ...restAttributes } = doc.attributes;
- const { transformedConnector, references: connectorReferences } =
- transformConnectorIdToReference(connector);
+ const { transformedConnector, references: connectorReferences } = transformConnectorIdToReference(
+ CONNECTOR_ID_REFERENCE_NAME,
+ connector
+ );
const { references = [] } = doc;
return {
diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts
index a445131073d19..a4f50fbfcde5b 100644
--- a/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts
+++ b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts
@@ -5,24 +5,17 @@
* 2.0.
*/
-/* eslint-disable @typescript-eslint/naming-convention */
-
import {
SavedObjectUnsanitizedDoc,
SavedObjectSanitizedDoc,
} from '../../../../../../src/core/server';
-import { ConnectorTypes, SECURITY_SOLUTION_OWNER } from '../../../common';
+import { SECURITY_SOLUTION_OWNER } from '../../../common';
export { caseMigrations } from './cases';
export { configureMigrations } from './configuration';
+export { userActionsMigrations } from './user_actions';
export { createCommentsMigrations, CreateCommentsMigrationsDeps } from './comments';
-interface UserActions {
- action_field: string[];
- new_value: string;
- old_value: string;
-}
-
export interface SanitizedCaseOwner {
owner: string;
}
@@ -38,52 +31,6 @@ export const addOwnerToSO = >(
references: doc.references || [],
});
-export const userActionsMigrations = {
- '7.10.0': (doc: SavedObjectUnsanitizedDoc): SavedObjectSanitizedDoc => {
- const { action_field, new_value, old_value, ...restAttributes } = doc.attributes;
-
- if (
- action_field == null ||
- !Array.isArray(action_field) ||
- action_field[0] !== 'connector_id'
- ) {
- return { ...doc, references: doc.references || [] };
- }
-
- return {
- ...doc,
- attributes: {
- ...restAttributes,
- action_field: ['connector'],
- new_value:
- new_value != null
- ? JSON.stringify({
- id: new_value,
- name: 'none',
- type: ConnectorTypes.none,
- fields: null,
- })
- : new_value,
- old_value:
- old_value != null
- ? JSON.stringify({
- id: old_value,
- name: 'none',
- type: ConnectorTypes.none,
- fields: null,
- })
- : old_value,
- },
- references: doc.references || [],
- };
- },
- '7.14.0': (
- doc: SavedObjectUnsanitizedDoc>
- ): SavedObjectSanitizedDoc => {
- return addOwnerToSO(doc);
- },
-};
-
export const connectorMappingsMigrations = {
'7.14.0': (
doc: SavedObjectUnsanitizedDoc>
diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts
new file mode 100644
index 0000000000000..e71c8db0db694
--- /dev/null
+++ b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts
@@ -0,0 +1,562 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+/* eslint-disable @typescript-eslint/naming-convention */
+
+import { SavedObjectMigrationContext, SavedObjectSanitizedDoc } from 'kibana/server';
+import { migrationMocks } from 'src/core/server/mocks';
+import { CaseUserActionAttributes, CASE_USER_ACTION_SAVED_OBJECT } from '../../../common';
+import {
+ createConnectorObject,
+ createExternalService,
+ createJiraConnector,
+} from '../../services/test_utils';
+import { userActionsConnectorIdMigration } from './user_actions';
+
+const create_7_14_0_userAction = (
+ params: {
+ action?: string;
+ action_field?: string[];
+ new_value?: string | null | object;
+ old_value?: string | null | object;
+ } = {}
+) => {
+ const { new_value, old_value, ...restParams } = params;
+
+ return {
+ type: CASE_USER_ACTION_SAVED_OBJECT,
+ id: '1',
+ attributes: {
+ ...restParams,
+ new_value: new_value && typeof new_value === 'object' ? JSON.stringify(new_value) : new_value,
+ old_value: old_value && typeof old_value === 'object' ? JSON.stringify(old_value) : old_value,
+ },
+ };
+};
+
+describe('user action migrations', () => {
+ describe('7.15.0 connector ID migration', () => {
+ describe('userActionsConnectorIdMigration', () => {
+ let context: jest.Mocked;
+
+ beforeEach(() => {
+ context = migrationMocks.createContext();
+ });
+
+ describe('push user action', () => {
+ it('extracts the external_service connector_id to references for a new pushed user action', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'push-to-service',
+ action_field: ['pushed'],
+ new_value: createExternalService(),
+ old_value: null,
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ const parsedExternalService = JSON.parse(migratedUserAction.attributes.new_value!);
+ expect(parsedExternalService).not.toHaveProperty('connector_id');
+ expect(parsedExternalService).toMatchInlineSnapshot(`
+ Object {
+ "connector_name": ".jira",
+ "external_id": "100",
+ "external_title": "awesome",
+ "external_url": "http://www.google.com",
+ "pushed_at": "2019-11-25T21:54:48.952Z",
+ "pushed_by": Object {
+ "email": "testemail@elastic.co",
+ "full_name": "elastic",
+ "username": "elastic",
+ },
+ }
+ `);
+
+ expect(migratedUserAction.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "100",
+ "name": "pushConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+
+ expect(migratedUserAction.attributes.old_value).toBeNull();
+ });
+
+ it('extract the external_service connector_id to references for new and old pushed user action', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'push-to-service',
+ action_field: ['pushed'],
+ new_value: createExternalService(),
+ old_value: createExternalService({ connector_id: '5' }),
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ const parsedNewExternalService = JSON.parse(migratedUserAction.attributes.new_value!);
+ const parsedOldExternalService = JSON.parse(migratedUserAction.attributes.old_value!);
+
+ expect(parsedNewExternalService).not.toHaveProperty('connector_id');
+ expect(parsedOldExternalService).not.toHaveProperty('connector_id');
+ expect(migratedUserAction.references).toEqual([
+ { id: '100', name: 'pushConnectorId', type: 'action' },
+ { id: '5', name: 'oldPushConnectorId', type: 'action' },
+ ]);
+ });
+
+ it('preserves the existing references after extracting the external_service connector_id field', () => {
+ const userAction = {
+ ...create_7_14_0_userAction({
+ action: 'push-to-service',
+ action_field: ['pushed'],
+ new_value: createExternalService(),
+ old_value: createExternalService({ connector_id: '5' }),
+ }),
+ references: [{ id: '500', name: 'someReference', type: 'ref' }],
+ };
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ const parsedNewExternalService = JSON.parse(migratedUserAction.attributes.new_value!);
+ const parsedOldExternalService = JSON.parse(migratedUserAction.attributes.old_value!);
+
+ expect(parsedNewExternalService).not.toHaveProperty('connector_id');
+ expect(parsedOldExternalService).not.toHaveProperty('connector_id');
+ expect(migratedUserAction.references).toEqual([
+ { id: '500', name: 'someReference', type: 'ref' },
+ { id: '100', name: 'pushConnectorId', type: 'action' },
+ { id: '5', name: 'oldPushConnectorId', type: 'action' },
+ ]);
+ });
+
+ it('leaves the object unmodified when it is not a valid push user action', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'push-to-service',
+ action_field: ['invalid field'],
+ new_value: 'hello',
+ old_value: null,
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedUserAction.attributes.old_value).toBeNull();
+ expect(migratedUserAction).toMatchInlineSnapshot(`
+ Object {
+ "attributes": Object {
+ "action": "push-to-service",
+ "action_field": Array [
+ "invalid field",
+ ],
+ "new_value": "hello",
+ "old_value": null,
+ },
+ "id": "1",
+ "references": Array [],
+ "type": "cases-user-actions",
+ }
+ `);
+ });
+
+ it('leaves the object unmodified when it new value is invalid json', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'push-to-service',
+ action_field: ['pushed'],
+ new_value: '{a',
+ old_value: null,
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedUserAction.attributes.old_value).toBeNull();
+ expect(migratedUserAction.attributes.new_value).toEqual('{a');
+ expect(migratedUserAction).toMatchInlineSnapshot(`
+ Object {
+ "attributes": Object {
+ "action": "push-to-service",
+ "action_field": Array [
+ "pushed",
+ ],
+ "new_value": "{a",
+ "old_value": null,
+ },
+ "id": "1",
+ "references": Array [],
+ "type": "cases-user-actions",
+ }
+ `);
+ });
+
+ it('logs an error new value is invalid json', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'push-to-service',
+ action_field: ['pushed'],
+ new_value: '{a',
+ old_value: null,
+ });
+
+ userActionsConnectorIdMigration(userAction, context);
+
+ expect(context.log.error).toHaveBeenCalled();
+ });
+ });
+
+ describe('update connector user action', () => {
+ it('extracts the connector id to references for a new create connector user action', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'update',
+ action_field: ['connector'],
+ new_value: createJiraConnector(),
+ old_value: null,
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ const parsedConnector = JSON.parse(migratedUserAction.attributes.new_value!);
+ expect(parsedConnector).not.toHaveProperty('id');
+ expect(parsedConnector).toMatchInlineSnapshot(`
+ Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ }
+ `);
+
+ expect(migratedUserAction.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+
+ expect(migratedUserAction.attributes.old_value).toBeNull();
+ });
+
+ it('extracts the connector id to references for a new and old create connector user action', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'update',
+ action_field: ['connector'],
+ new_value: createJiraConnector(),
+ old_value: { ...createJiraConnector(), id: '5' },
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ const parsedNewConnector = JSON.parse(migratedUserAction.attributes.new_value!);
+ const parsedOldConnector = JSON.parse(migratedUserAction.attributes.new_value!);
+
+ expect(parsedNewConnector).not.toHaveProperty('id');
+ expect(parsedOldConnector).not.toHaveProperty('id');
+
+ expect(migratedUserAction.references).toEqual([
+ { id: '1', name: 'connectorId', type: 'action' },
+ { id: '5', name: 'oldConnectorId', type: 'action' },
+ ]);
+ });
+
+ it('preserves the existing references after extracting the connector.id field', () => {
+ const userAction = {
+ ...create_7_14_0_userAction({
+ action: 'update',
+ action_field: ['connector'],
+ new_value: createJiraConnector(),
+ old_value: { ...createJiraConnector(), id: '5' },
+ }),
+ references: [{ id: '500', name: 'someReference', type: 'ref' }],
+ };
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ const parsedNewConnectorId = JSON.parse(migratedUserAction.attributes.new_value!);
+ const parsedOldConnectorId = JSON.parse(migratedUserAction.attributes.old_value!);
+
+ expect(parsedNewConnectorId).not.toHaveProperty('id');
+ expect(parsedOldConnectorId).not.toHaveProperty('id');
+ expect(migratedUserAction.references).toEqual([
+ { id: '500', name: 'someReference', type: 'ref' },
+ { id: '1', name: 'connectorId', type: 'action' },
+ { id: '5', name: 'oldConnectorId', type: 'action' },
+ ]);
+ });
+
+ it('leaves the object unmodified when it is not a valid create connector user action', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'update',
+ action_field: ['invalid action'],
+ new_value: 'new json value',
+ old_value: 'old value',
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedUserAction).toMatchInlineSnapshot(`
+ Object {
+ "attributes": Object {
+ "action": "update",
+ "action_field": Array [
+ "invalid action",
+ ],
+ "new_value": "new json value",
+ "old_value": "old value",
+ },
+ "id": "1",
+ "references": Array [],
+ "type": "cases-user-actions",
+ }
+ `);
+ });
+
+ it('leaves the object unmodified when old_value is invalid json', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'update',
+ action_field: ['connector'],
+ new_value: '{}',
+ old_value: '{b',
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedUserAction).toMatchInlineSnapshot(`
+ Object {
+ "attributes": Object {
+ "action": "update",
+ "action_field": Array [
+ "connector",
+ ],
+ "new_value": "{}",
+ "old_value": "{b",
+ },
+ "id": "1",
+ "references": Array [],
+ "type": "cases-user-actions",
+ }
+ `);
+ });
+
+ it('logs an error message when old_value is invalid json', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'update',
+ action_field: ['connector'],
+ new_value: createJiraConnector(),
+ old_value: '{b',
+ });
+
+ userActionsConnectorIdMigration(userAction, context);
+
+ expect(context.log.error).toHaveBeenCalled();
+ });
+ });
+
+ describe('create connector user action', () => {
+ it('extracts the connector id to references for a new create connector user action', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'create',
+ action_field: ['connector'],
+ new_value: createConnectorObject(),
+ old_value: null,
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ const parsedConnector = JSON.parse(migratedUserAction.attributes.new_value!);
+ expect(parsedConnector.connector).not.toHaveProperty('id');
+ expect(parsedConnector).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ },
+ }
+ `);
+
+ expect(migratedUserAction.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+
+ expect(migratedUserAction.attributes.old_value).toBeNull();
+ });
+
+ it('extracts the connector id to references for a new and old create connector user action', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'create',
+ action_field: ['connector'],
+ new_value: createConnectorObject(),
+ old_value: createConnectorObject({ id: '5' }),
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ const parsedNewConnector = JSON.parse(migratedUserAction.attributes.new_value!);
+ const parsedOldConnector = JSON.parse(migratedUserAction.attributes.new_value!);
+
+ expect(parsedNewConnector.connector).not.toHaveProperty('id');
+ expect(parsedOldConnector.connector).not.toHaveProperty('id');
+
+ expect(migratedUserAction.references).toEqual([
+ { id: '1', name: 'connectorId', type: 'action' },
+ { id: '5', name: 'oldConnectorId', type: 'action' },
+ ]);
+ });
+
+ it('preserves the existing references after extracting the connector.id field', () => {
+ const userAction = {
+ ...create_7_14_0_userAction({
+ action: 'create',
+ action_field: ['connector'],
+ new_value: createConnectorObject(),
+ old_value: createConnectorObject({ id: '5' }),
+ }),
+ references: [{ id: '500', name: 'someReference', type: 'ref' }],
+ };
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ const parsedNewConnectorId = JSON.parse(migratedUserAction.attributes.new_value!);
+ const parsedOldConnectorId = JSON.parse(migratedUserAction.attributes.old_value!);
+
+ expect(parsedNewConnectorId.connector).not.toHaveProperty('id');
+ expect(parsedOldConnectorId.connector).not.toHaveProperty('id');
+ expect(migratedUserAction.references).toEqual([
+ { id: '500', name: 'someReference', type: 'ref' },
+ { id: '1', name: 'connectorId', type: 'action' },
+ { id: '5', name: 'oldConnectorId', type: 'action' },
+ ]);
+ });
+
+ it('leaves the object unmodified when it is not a valid create connector user action', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'create',
+ action_field: ['invalid action'],
+ new_value: 'new json value',
+ old_value: 'old value',
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedUserAction).toMatchInlineSnapshot(`
+ Object {
+ "attributes": Object {
+ "action": "create",
+ "action_field": Array [
+ "invalid action",
+ ],
+ "new_value": "new json value",
+ "old_value": "old value",
+ },
+ "id": "1",
+ "references": Array [],
+ "type": "cases-user-actions",
+ }
+ `);
+ });
+
+ it('leaves the object unmodified when new_value is invalid json', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'create',
+ action_field: ['connector'],
+ new_value: 'new json value',
+ old_value: 'old value',
+ });
+
+ const migratedUserAction = userActionsConnectorIdMigration(
+ userAction,
+ context
+ ) as SavedObjectSanitizedDoc;
+
+ expect(migratedUserAction).toMatchInlineSnapshot(`
+ Object {
+ "attributes": Object {
+ "action": "create",
+ "action_field": Array [
+ "connector",
+ ],
+ "new_value": "new json value",
+ "old_value": "old value",
+ },
+ "id": "1",
+ "references": Array [],
+ "type": "cases-user-actions",
+ }
+ `);
+ });
+
+ it('logs an error message when new_value is invalid json', () => {
+ const userAction = create_7_14_0_userAction({
+ action: 'create',
+ action_field: ['connector'],
+ new_value: 'new json value',
+ old_value: 'old value',
+ });
+
+ userActionsConnectorIdMigration(userAction, context);
+
+ expect(context.log.error).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts
new file mode 100644
index 0000000000000..ed6b57ef647f9
--- /dev/null
+++ b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts
@@ -0,0 +1,159 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+/* eslint-disable @typescript-eslint/naming-convention */
+
+import { addOwnerToSO, SanitizedCaseOwner } from '.';
+import {
+ SavedObjectUnsanitizedDoc,
+ SavedObjectSanitizedDoc,
+ SavedObjectMigrationContext,
+ LogMeta,
+} from '../../../../../../src/core/server';
+import { ConnectorTypes, isCreateConnector, isPush, isUpdateConnector } from '../../../common';
+
+import { extractConnectorIdFromJson } from '../../services/user_actions/transform';
+import { UserActionFieldType } from '../../services/user_actions/types';
+
+interface UserActions {
+ action_field: string[];
+ new_value: string;
+ old_value: string;
+}
+
+interface UserActionUnmigratedConnectorDocument {
+ action?: string;
+ action_field?: string[];
+ new_value?: string | null;
+ old_value?: string | null;
+}
+
+interface UserActionLogMeta extends LogMeta {
+ migrations: { userAction: { id: string } };
+}
+
+export function userActionsConnectorIdMigration(
+ doc: SavedObjectUnsanitizedDoc,
+ context: SavedObjectMigrationContext
+): SavedObjectSanitizedDoc {
+ const originalDocWithReferences = { ...doc, references: doc.references ?? [] };
+
+ if (!isConnectorUserAction(doc.attributes.action, doc.attributes.action_field)) {
+ return originalDocWithReferences;
+ }
+
+ try {
+ return formatDocumentWithConnectorReferences(doc);
+ } catch (error) {
+ logError(doc.id, context, error);
+
+ return originalDocWithReferences;
+ }
+}
+
+function isConnectorUserAction(action?: string, actionFields?: string[]): boolean {
+ return (
+ isCreateConnector(action, actionFields) ||
+ isUpdateConnector(action, actionFields) ||
+ isPush(action, actionFields)
+ );
+}
+
+function formatDocumentWithConnectorReferences(
+ doc: SavedObjectUnsanitizedDoc