+
{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 6191df2ecce5..9684b7b7ff35 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/server/lib/get_vis_data.ts b/src/plugins/vis_types/timeseries/server/lib/get_vis_data.ts
index bc4fbf9159a0..a76132e0fbd2 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 0fa92b5f061f..ff1c3c0ac71e 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 903e7f239f82..e3ede5777422 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 e61635058cee..2adbc31482f0 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/vega_view/vega_map_view/view.ts b/src/plugins/vis_types/vega/public/vega_view/vega_map_view/view.ts
index cf5bf15d1505..777806d90d9a 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/visualizations/server/saved_objects/visualization.ts b/src/plugins/visualizations/server/saved_objects/visualization.ts
index 880e277294fc..53027d5d5046 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/application/components/visualize_no_match.tsx b/src/plugins/visualize/public/application/components/visualize_no_match.tsx
index 3b735eb23671..ad993af43008 100644
--- a/src/plugins/visualize/public/application/components/visualize_no_match.tsx
+++ b/src/plugins/visualize/public/application/components/visualize_no_match.tsx
@@ -24,7 +24,7 @@ export const VisualizeNoMatch = () => {
services.restorePreviousUrl();
const { navigated } = services.urlForwarding.navigateToLegacyKibanaUrl(
- services.history.location.pathname
+ services.history.location.pathname + services.history.location.search
);
if (!navigated) {
diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts
index aef131ce8d53..b128c0920974 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 5a3ec9d8fc86..c8a7ac566b55 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/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts
index f3f4b56cdccf..9a5f94f9d8b9 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 5fbd5cad8ec8..8ee5005348bc 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/functional/apps/dashboard/bwc_shared_urls.ts b/test/functional/apps/dashboard/bwc_shared_urls.ts
index d40cf03327fd..569cd8e2a67d 100644
--- a/test/functional/apps/dashboard/bwc_shared_urls.ts
+++ b/test/functional/apps/dashboard/bwc_shared_urls.ts
@@ -86,6 +86,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
describe('6.0 urls', () => {
+ let savedDashboardId: string;
+
it('loads an unsaved dashboard', async function () {
const url = `${kibanaLegacyBaseUrl}#/dashboard?${urlQuery}`;
log.debug(`Navigating to ${url}`);
@@ -106,8 +108,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
storeTimeWithDashboard: true,
});
- const id = await PageObjects.dashboard.getDashboardIdFromCurrentUrl();
- const url = `${kibanaLegacyBaseUrl}#/dashboard/${id}`;
+ savedDashboardId = await PageObjects.dashboard.getDashboardIdFromCurrentUrl();
+ const url = `${kibanaLegacyBaseUrl}#/dashboard/${savedDashboardId}`;
log.debug(`Navigating to ${url}`);
await browser.get(url, true);
await PageObjects.header.waitUntilLoadingHasFinished();
@@ -121,6 +123,22 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardExpect.selectedLegendColorCount('#F9D9F9', 5);
});
+ it('loads a saved dashboard with query via dashboard_no_match', async function () {
+ await PageObjects.dashboard.gotoDashboardLandingPage();
+ const currentUrl = await browser.getCurrentUrl();
+ const dashboardBaseUrl = currentUrl.substring(0, currentUrl.indexOf('/app/dashboards'));
+ const url = `${dashboardBaseUrl}/app/dashboards#/dashboard/${savedDashboardId}?_a=(query:(language:kuery,query:'boop'))`;
+ log.debug(`Navigating to ${url}`);
+ await browser.get(url);
+ await PageObjects.header.waitUntilLoadingHasFinished();
+
+ const query = await queryBar.getQueryString();
+ expect(query).to.equal('boop');
+
+ await dashboardExpect.panelCount(2);
+ await PageObjects.dashboard.waitForRenderComplete();
+ });
+
it('uiState in url takes precedence over saved dashboard state', async function () {
const id = await PageObjects.dashboard.getDashboardIdFromCurrentUrl();
const updatedQuery = urlQuery.replace(/F9D9F9/g, '000000');
diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts
index 6b71dd34b76f..8043c8bf8cc3 100644
--- a/test/functional/apps/dashboard/dashboard_unsaved_state.ts
+++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts
@@ -12,6 +12,9 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']);
+ const browser = getService('browser');
+ const queryBar = getService('queryBar');
+ const filterBar = getService('filterBar');
const esArchiver = getService('esArchiver');
const testSubjects = getService('testSubjects');
const kibanaServer = getService('kibanaServer');
@@ -19,9 +22,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
let originalPanelCount = 0;
let unsavedPanelCount = 0;
+ const testQuery = 'Test Query';
- // FLAKY: https://github.com/elastic/kibana/issues/91191
- describe.skip('dashboard unsaved panels', () => {
+ describe('dashboard unsaved state', () => {
before(async () => {
await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana');
await kibanaServer.uiSettings.replace({
@@ -31,79 +34,123 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.dashboard.preserveCrossAppState();
await PageObjects.dashboard.loadSavedDashboard('few panels');
await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.dashboard.waitForRenderComplete();
originalPanelCount = await PageObjects.dashboard.getPanelCount();
});
- it('does not show unsaved changes badge when there are no unsaved changes', async () => {
- await testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
- });
+ describe('view mode state', () => {
+ before(async () => {
+ await queryBar.setQuery(testQuery);
+ await filterBar.addFilter('bytes', 'exists');
+ await queryBar.submitQuery();
+ });
- it('shows the unsaved changes badge after adding panels', async () => {
- await PageObjects.dashboard.switchToEditMode();
- // add an area chart by value
- await dashboardAddPanel.clickEditorMenuButton();
- await dashboardAddPanel.clickAggBasedVisualizations();
- await PageObjects.visualize.clickAreaChart();
- await PageObjects.visualize.clickNewSearch();
- await PageObjects.visualize.saveVisualizationAndReturn();
+ const validateQueryAndFilter = async () => {
+ const query = await queryBar.getQueryString();
+ expect(query).to.eql(testQuery);
+ const filterCount = await filterBar.getFilterCount();
+ expect(filterCount).to.eql(1);
+ };
+
+ it('persists after navigating to the listing page and back', async () => {
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.dashboard.gotoDashboardLandingPage();
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.dashboard.loadSavedDashboard('few panels');
+ await PageObjects.dashboard.waitForRenderComplete();
+ await validateQueryAndFilter();
+ });
- // add a metric by reference
- await dashboardAddPanel.addVisualization('Rendering-Test: metric');
+ it('persists after navigating to Visualize and back', async () => {
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.visualize.gotoVisualizationLandingPage();
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.common.navigateToApp('dashboards');
+ await PageObjects.dashboard.loadSavedDashboard('few panels');
+ await PageObjects.dashboard.waitForRenderComplete();
+ await validateQueryAndFilter();
+ });
- await PageObjects.header.waitUntilLoadingHasFinished();
- await testSubjects.existOrFail('dashboardUnsavedChangesBadge');
- });
+ it('persists after a hard refresh', async () => {
+ await browser.refresh();
+ const alert = await browser.getAlert();
+ await alert?.accept();
+ await PageObjects.dashboard.waitForRenderComplete();
+ await validateQueryAndFilter();
+ });
- it('has correct number of panels', async () => {
- unsavedPanelCount = await PageObjects.dashboard.getPanelCount();
- expect(unsavedPanelCount).to.eql(originalPanelCount + 2);
+ after(async () => {
+ // discard changes made in view mode
+ await PageObjects.dashboard.switchToEditMode();
+ await PageObjects.dashboard.clickCancelOutOfEditMode();
+ });
});
- it('retains unsaved panel count after navigating to listing page and back', async () => {
- await PageObjects.header.waitUntilLoadingHasFinished();
- await PageObjects.dashboard.gotoDashboardLandingPage();
- await PageObjects.header.waitUntilLoadingHasFinished();
- await PageObjects.dashboard.loadSavedDashboard('few panels');
- await PageObjects.dashboard.switchToEditMode();
- const currentPanelCount = await PageObjects.dashboard.getPanelCount();
- expect(currentPanelCount).to.eql(unsavedPanelCount);
- });
+ describe('edit mode state', () => {
+ const addPanels = async () => {
+ // add an area chart by value
+ await dashboardAddPanel.clickEditorMenuButton();
+ await dashboardAddPanel.clickAggBasedVisualizations();
+ await PageObjects.visualize.clickAreaChart();
+ await PageObjects.visualize.clickNewSearch();
+ await PageObjects.visualize.saveVisualizationAndReturn();
+
+ // add a metric by reference
+ await dashboardAddPanel.addVisualization('Rendering-Test: metric');
+ };
+
+ it('does not show unsaved changes badge when there are no unsaved changes', async () => {
+ await testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
+ });
- it('retains unsaved panel count after navigating to another app and back', async () => {
- await PageObjects.header.waitUntilLoadingHasFinished();
- await PageObjects.visualize.gotoVisualizationLandingPage();
- await PageObjects.header.waitUntilLoadingHasFinished();
- await PageObjects.common.navigateToApp('dashboards');
- await PageObjects.dashboard.loadSavedDashboard('few panels');
- await PageObjects.dashboard.switchToEditMode();
- const currentPanelCount = await PageObjects.dashboard.getPanelCount();
- expect(currentPanelCount).to.eql(unsavedPanelCount);
- });
+ it('shows the unsaved changes badge after adding panels', async () => {
+ await PageObjects.dashboard.switchToEditMode();
+ await addPanels();
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await testSubjects.existOrFail('dashboardUnsavedChangesBadge');
+ });
- it('resets to original panel count upon entering view mode', async () => {
- await PageObjects.header.waitUntilLoadingHasFinished();
- await PageObjects.dashboard.clickCancelOutOfEditMode();
- await PageObjects.header.waitUntilLoadingHasFinished();
- const currentPanelCount = await PageObjects.dashboard.getPanelCount();
- expect(currentPanelCount).to.eql(originalPanelCount);
- });
+ it('has correct number of panels', async () => {
+ unsavedPanelCount = await PageObjects.dashboard.getPanelCount();
+ expect(unsavedPanelCount).to.eql(originalPanelCount + 2);
+ });
- it('shows unsaved changes badge in view mode if changes have not been discarded', async () => {
- await testSubjects.existOrFail('dashboardUnsavedChangesBadge');
- });
+ it('retains unsaved panel count after navigating to listing page and back', async () => {
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.dashboard.gotoDashboardLandingPage();
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.dashboard.loadSavedDashboard('few panels');
+ const currentPanelCount = await PageObjects.dashboard.getPanelCount();
+ expect(currentPanelCount).to.eql(unsavedPanelCount);
+ });
- it('retains unsaved panel count after returning to edit mode', async () => {
- await PageObjects.header.waitUntilLoadingHasFinished();
- await PageObjects.dashboard.switchToEditMode();
- await PageObjects.header.waitUntilLoadingHasFinished();
- const currentPanelCount = await PageObjects.dashboard.getPanelCount();
- expect(currentPanelCount).to.eql(unsavedPanelCount);
- });
+ it('retains unsaved panel count after navigating to another app and back', async () => {
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.visualize.gotoVisualizationLandingPage();
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.common.navigateToApp('dashboards');
+ await PageObjects.dashboard.loadSavedDashboard('few panels');
+ const currentPanelCount = await PageObjects.dashboard.getPanelCount();
+ expect(currentPanelCount).to.eql(unsavedPanelCount);
+ });
- it('does not show unsaved changes badge after saving', async () => {
- await PageObjects.dashboard.saveDashboard('Unsaved State Test');
- await PageObjects.header.waitUntilLoadingHasFinished();
- await testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
+ it('resets to original panel count after discarding changes', async () => {
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.dashboard.clickCancelOutOfEditMode();
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ const currentPanelCount = await PageObjects.dashboard.getPanelCount();
+ expect(currentPanelCount).to.eql(originalPanelCount);
+ expect(PageObjects.dashboard.getIsInViewMode()).to.eql(true);
+ });
+
+ it('does not show unsaved changes badge after saving', async () => {
+ await PageObjects.dashboard.switchToEditMode();
+ await addPanels();
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.dashboard.saveDashboard('Unsaved State Test');
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await testSubjects.missingOrFail('dashboardUnsavedChangesBadge');
+ });
});
});
}
diff --git a/test/functional/apps/discover/_runtime_fields_editor.ts b/test/functional/apps/discover/_runtime_fields_editor.ts
index 642743d3a037..4757807cb7ac 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/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts
deleted file mode 100644
index f4bf45c0b7f7..000000000000
--- 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 0b367b284e74..12e0cc8863f1 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 000000000000..839c262acffa
--- /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 ea8cb8b13ba4..85dbf7cc5ca9 100644
--- a/test/functional/apps/visualize/_timelion.ts
+++ b/test/functional/apps/visualize/_timelion.ts
@@ -277,17 +277,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 6a5c062268c2..c530b00364fd 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 21bee2d16442..09dc61e9f5f6 100644
--- a/test/functional/apps/visualize/_tsvb_time_series.ts
+++ b/test/functional/apps/visualize/_tsvb_time_series.ts
@@ -433,6 +433,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/index.ts b/test/functional/apps/visualize/index.ts
index 878c7b88341a..3bc4da016390 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/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts
index c324de1231b7..a9116591f8f7 100644
--- a/test/functional/page_objects/visual_builder_page.ts
+++ b/test/functional/page_objects/visual_builder_page.ts
@@ -502,12 +502,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/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx
index 505fd5c1020b..bbc77bcabca5 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/empty_state_loading.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/empty_state_loading.tsx
new file mode 100644
index 000000000000..b02672721ce8
--- /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/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts
index 37a491cdad4c..05a053307b29 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,
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 03912c550d77..e86ce5248a6f 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 77af90b5d08c..2b3483b4f618 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 5305318cc9aa..d38b1a779981 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 bf4ec0da6ee5..c89c3eb08263 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 {
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 000000000000..7de0d7066eae
--- /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/security_solution/server/lib/timeline/saved_object_mappings/migrations/index.ts b/x-pack/plugins/cases/public/common/user_actions/index.ts
similarity index 72%
rename from x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/index.ts
rename to x-pack/plugins/cases/public/common/user_actions/index.ts
index e4c8858321e1..507455f7102a 100644
--- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/index.ts
+++ b/x-pack/plugins/cases/public/common/user_actions/index.ts
@@ -5,5 +5,4 @@
* 2.0.
*/
-export { timelinesMigrations } from './timelines';
-export { notesMigrations } from './notes';
+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 000000000000..c6d13cc41686
--- /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 000000000000..dfea22443aa5
--- /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/edit_connector/helpers.test.ts b/x-pack/plugins/cases/public/components/edit_connector/helpers.test.ts
new file mode 100644
index 000000000000..e20d6b37258b
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/edit_connector/helpers.test.ts
@@ -0,0 +1,187 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { CaseUserActionConnector, ConnectorTypes } from '../../../common';
+import { CaseUserActions } from '../../containers/types';
+import { getConnectorFieldsFromUserActions } from './helpers';
+
+describe('helpers', () => {
+ 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 36eb3f58c8aa..b97035c458ac 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 b49a010cff38..841f0d36bbf1 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 744b14926b35..2eb44f91190c 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 784817229caf..7ea415324194 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/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts
index c955bb34240e..fcd564969d48 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,9 @@ import {
CommentResponse,
CommentType,
ConnectorTypes,
+ isCreateConnector,
+ isPush,
+ isUpdateConnector,
SECURITY_SOLUTION_OWNER,
UserAction,
UserActionField,
@@ -240,7 +244,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 +314,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 +329,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 +351,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 +386,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 +409,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_user_actions.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_user_actions.test.tsx
index 62b4cf92434c..e7e46fa46c7c 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 e481519ba19a..36d600c3f1c9 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 de67b1cfbd6f..b0cc0c72fee7 100644
--- a/x-pack/plugins/cases/public/containers/utils.ts
+++ b/x-pack/plugins/cases/public/containers/utils.ts
@@ -36,14 +36,6 @@ 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)) {
diff --git a/x-pack/plugins/cases/server/client/attachments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts
index 507405d58cef..b84a6bd84c43 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 9816efd9a845..b5e9e6c37235 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/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts
index 887990fef893..488bc523f779 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 80a687a0e72f..4333535f17a2 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/mock.ts b/x-pack/plugins/cases/server/client/cases/mock.ts
index 313d6cd12a6d..22520cea1101 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 3048cf01bb3b..1b090a653546 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 d7c45d3e1e9a..315e9966d347 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 359ad4b41ead..f5cf2fe4b3f5 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/user_actions/get.test.ts b/x-pack/plugins/cases/server/client/user_actions/get.test.ts
new file mode 100644
index 000000000000..302e069cde4d
--- /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 2a6608014c80..660cf1b6a336 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 1f6af310d6ec..eba0a64a5c0b 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/saved_object_types/cases.ts b/x-pack/plugins/cases/server/saved_object_types/cases.ts
index a362d77c0662..74c6a053e95c 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 af14123eca58..64e75ad26ae2 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 bca12a86a544..9020f65ae352 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 bffd4171270e..80f02fa3bf6a 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 4467b499817a..9ae0285598db 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 527d40fca2e3..f9937253e0d2 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 a445131073d1..a4f50fbfcde5 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 000000000000..e71c8db0db69
--- /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 000000000000..ed6b57ef647f
--- /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
+): SavedObjectSanitizedDoc {
+ const { new_value, old_value, action, action_field, ...restAttributes } = doc.attributes;
+ const { references = [] } = doc;
+
+ const { transformedActionDetails: transformedNewValue, references: newValueConnectorRefs } =
+ extractConnectorIdFromJson({
+ action,
+ actionFields: action_field,
+ actionDetails: new_value,
+ fieldType: UserActionFieldType.New,
+ });
+
+ const { transformedActionDetails: transformedOldValue, references: oldValueConnectorRefs } =
+ extractConnectorIdFromJson({
+ action,
+ actionFields: action_field,
+ actionDetails: old_value,
+ fieldType: UserActionFieldType.Old,
+ });
+
+ return {
+ ...doc,
+ attributes: {
+ ...restAttributes,
+ action,
+ action_field,
+ new_value: transformedNewValue,
+ old_value: transformedOldValue,
+ },
+ references: [...references, ...newValueConnectorRefs, ...oldValueConnectorRefs],
+ };
+}
+
+function logError(id: string, context: SavedObjectMigrationContext, error: Error) {
+ context.log.error(
+ `Failed to migrate user action connector doc id: ${id} version: ${context.migrationVersion} error: ${error.message}`,
+ {
+ migrations: {
+ userAction: {
+ id,
+ },
+ },
+ }
+ );
+}
+
+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);
+ },
+ '7.16.0': userActionsConnectorIdMigration,
+};
diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts
deleted file mode 100644
index f591bef6b323..000000000000
--- a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts
+++ /dev/null
@@ -1,229 +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 { noneConnectorId } from '../../../common';
-import { createExternalService, createJiraConnector } from '../../services/test_utils';
-import { transformConnectorIdToReference, transformPushConnectorIdToReference } from './utils';
-
-describe('migration utils', () => {
- describe('transformConnectorIdToReference', () => {
- it('returns the default none connector when the connector is undefined', () => {
- expect(transformConnectorIdToReference().transformedConnector).toMatchInlineSnapshot(`
- Object {
- "connector": Object {
- "fields": null,
- "name": "none",
- "type": ".none",
- },
- }
- `);
- });
-
- it('returns the default none connector when the id is undefined', () => {
- expect(transformConnectorIdToReference({ id: undefined }).transformedConnector)
- .toMatchInlineSnapshot(`
- Object {
- "connector": Object {
- "fields": null,
- "name": "none",
- "type": ".none",
- },
- }
- `);
- });
-
- it('returns the default none connector when the id is none', () => {
- expect(transformConnectorIdToReference({ id: noneConnectorId }).transformedConnector)
- .toMatchInlineSnapshot(`
- Object {
- "connector": Object {
- "fields": null,
- "name": "none",
- "type": ".none",
- },
- }
- `);
- });
-
- it('returns the default none connector when the id is none and other fields are defined', () => {
- expect(
- transformConnectorIdToReference({ ...createJiraConnector(), id: noneConnectorId })
- .transformedConnector
- ).toMatchInlineSnapshot(`
- Object {
- "connector": Object {
- "fields": null,
- "name": "none",
- "type": ".none",
- },
- }
- `);
- });
-
- it('returns an empty array of references when the connector is undefined', () => {
- expect(transformConnectorIdToReference().references.length).toBe(0);
- });
-
- it('returns an empty array of references when the id is undefined', () => {
- expect(transformConnectorIdToReference({ id: undefined }).references.length).toBe(0);
- });
-
- it('returns an empty array of references when the id is the none connector', () => {
- expect(transformConnectorIdToReference({ id: noneConnectorId }).references.length).toBe(0);
- });
-
- it('returns an empty array of references when the id is the none connector and other fields are defined', () => {
- expect(
- transformConnectorIdToReference({ ...createJiraConnector(), id: noneConnectorId })
- .references.length
- ).toBe(0);
- });
-
- it('returns a jira connector', () => {
- const transformedFields = transformConnectorIdToReference(createJiraConnector());
- expect(transformedFields.transformedConnector).toMatchInlineSnapshot(`
- Object {
- "connector": Object {
- "fields": Object {
- "issueType": "bug",
- "parent": "2",
- "priority": "high",
- },
- "name": ".jira",
- "type": ".jira",
- },
- }
- `);
- expect(transformedFields.references).toMatchInlineSnapshot(`
- Array [
- Object {
- "id": "1",
- "name": "connectorId",
- "type": "action",
- },
- ]
- `);
- });
- });
-
- describe('transformPushConnectorIdToReference', () => {
- it('sets external_service to null when it is undefined', () => {
- expect(transformPushConnectorIdToReference().transformedPushConnector).toMatchInlineSnapshot(`
- Object {
- "external_service": null,
- }
- `);
- });
-
- it('sets external_service to null when it is null', () => {
- expect(transformPushConnectorIdToReference(null).transformedPushConnector)
- .toMatchInlineSnapshot(`
- Object {
- "external_service": null,
- }
- `);
- });
-
- it('returns an object when external_service is defined but connector_id is undefined', () => {
- expect(
- transformPushConnectorIdToReference({ connector_id: undefined }).transformedPushConnector
- ).toMatchInlineSnapshot(`
- Object {
- "external_service": Object {},
- }
- `);
- });
-
- it('returns an object when external_service is defined but connector_id is null', () => {
- expect(transformPushConnectorIdToReference({ connector_id: null }).transformedPushConnector)
- .toMatchInlineSnapshot(`
- Object {
- "external_service": Object {},
- }
- `);
- });
-
- it('returns an object when external_service is defined but connector_id is none', () => {
- const otherFields = { otherField: 'hi' };
-
- expect(
- transformPushConnectorIdToReference({ ...otherFields, connector_id: noneConnectorId })
- .transformedPushConnector
- ).toMatchInlineSnapshot(`
- Object {
- "external_service": Object {
- "otherField": "hi",
- },
- }
- `);
- });
-
- it('returns an empty array of references when the external_service is undefined', () => {
- expect(transformPushConnectorIdToReference().references.length).toBe(0);
- });
-
- it('returns an empty array of references when the external_service is null', () => {
- expect(transformPushConnectorIdToReference(null).references.length).toBe(0);
- });
-
- it('returns an empty array of references when the connector_id is undefined', () => {
- expect(
- transformPushConnectorIdToReference({ connector_id: undefined }).references.length
- ).toBe(0);
- });
-
- it('returns an empty array of references when the connector_id is null', () => {
- expect(
- transformPushConnectorIdToReference({ connector_id: undefined }).references.length
- ).toBe(0);
- });
-
- it('returns an empty array of references when the connector_id is the none connector', () => {
- expect(
- transformPushConnectorIdToReference({ connector_id: noneConnectorId }).references.length
- ).toBe(0);
- });
-
- it('returns an empty array of references when the connector_id is the none connector and other fields are defined', () => {
- expect(
- transformPushConnectorIdToReference({
- ...createExternalService(),
- connector_id: noneConnectorId,
- }).references.length
- ).toBe(0);
- });
-
- it('returns the external_service connector', () => {
- const transformedFields = transformPushConnectorIdToReference(createExternalService());
- expect(transformedFields.transformedPushConnector).toMatchInlineSnapshot(`
- Object {
- "external_service": 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(transformedFields.references).toMatchInlineSnapshot(`
- Array [
- Object {
- "id": "100",
- "name": "pushConnectorId",
- "type": "action",
- },
- ]
- `);
- });
- });
-});
diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts
deleted file mode 100644
index 0100a04cde67..000000000000
--- a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts
+++ /dev/null
@@ -1,73 +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.
- */
-
-/* eslint-disable @typescript-eslint/naming-convention */
-
-import { noneConnectorId } from '../../../common';
-import { SavedObjectReference } from '../../../../../../src/core/server';
-import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server';
-import {
- getNoneCaseConnector,
- CONNECTOR_ID_REFERENCE_NAME,
- PUSH_CONNECTOR_ID_REFERENCE_NAME,
-} from '../../common';
-
-export const transformConnectorIdToReference = (connector?: {
- id?: string;
-}): { transformedConnector: Record; references: SavedObjectReference[] } => {
- const { id: connectorId, ...restConnector } = connector ?? {};
-
- const references = createConnectorReference(
- connectorId,
- ACTION_SAVED_OBJECT_TYPE,
- CONNECTOR_ID_REFERENCE_NAME
- );
-
- const { id: ignoreNoneId, ...restNoneConnector } = getNoneCaseConnector();
- const connectorFieldsToReturn =
- connector && references.length > 0 ? restConnector : restNoneConnector;
-
- return {
- transformedConnector: {
- connector: connectorFieldsToReturn,
- },
- references,
- };
-};
-
-const createConnectorReference = (
- id: string | null | undefined,
- type: string,
- name: string
-): SavedObjectReference[] => {
- return id && id !== noneConnectorId
- ? [
- {
- id,
- type,
- name,
- },
- ]
- : [];
-};
-
-export const transformPushConnectorIdToReference = (
- external_service?: { connector_id?: string | null } | null
-): { transformedPushConnector: Record; references: SavedObjectReference[] } => {
- const { connector_id: pushConnectorId, ...restExternalService } = external_service ?? {};
-
- const references = createConnectorReference(
- pushConnectorId,
- ACTION_SAVED_OBJECT_TYPE,
- PUSH_CONNECTOR_ID_REFERENCE_NAME
- );
-
- return {
- transformedPushConnector: { external_service: external_service ? restExternalService : null },
- references,
- };
-};
diff --git a/x-pack/plugins/cases/server/saved_object_types/user_actions.ts b/x-pack/plugins/cases/server/saved_object_types/user_actions.ts
index 883105982bcb..7ef7c639ed9d 100644
--- a/x-pack/plugins/cases/server/saved_object_types/user_actions.ts
+++ b/x-pack/plugins/cases/server/saved_object_types/user_actions.ts
@@ -51,5 +51,6 @@ export const caseUserActionSavedObjectType: SavedObjectsType = {
migrations: userActionsMigrations,
management: {
importableAndExportable: true,
+ visibleInManagement: false,
},
};
diff --git a/x-pack/plugins/cases/server/services/cases/index.test.ts b/x-pack/plugins/cases/server/services/cases/index.test.ts
index 18f4ff867cfa..8c71abe5bff4 100644
--- a/x-pack/plugins/cases/server/services/cases/index.test.ts
+++ b/x-pack/plugins/cases/server/services/cases/index.test.ts
@@ -40,6 +40,7 @@ import {
createSavedObjectReferences,
createCaseSavedObjectResponse,
basicCaseFields,
+ createSOFindResponse,
} from '../test_utils';
import { ESCaseAttributes } from './types';
@@ -87,13 +88,6 @@ const createFindSO = (
score: 0,
});
-const createSOFindResponse = (savedObjects: Array>) => ({
- saved_objects: savedObjects,
- total: savedObjects.length,
- per_page: savedObjects.length,
- page: 1,
-});
-
const createCaseUpdateParams = (
connector?: CaseConnector,
externalService?: CaseFullExternalService
diff --git a/x-pack/plugins/cases/server/services/test_utils.ts b/x-pack/plugins/cases/server/services/test_utils.ts
index b712ea07f9c7..07743eda6121 100644
--- a/x-pack/plugins/cases/server/services/test_utils.ts
+++ b/x-pack/plugins/cases/server/services/test_utils.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { SavedObject, SavedObjectReference } from 'kibana/server';
+import { SavedObject, SavedObjectReference, SavedObjectsFindResult } from 'kibana/server';
import { ESConnectorFields } from '.';
import { CONNECTOR_ID_REFERENCE_NAME, PUSH_CONNECTOR_ID_REFERENCE_NAME } from '../common';
import {
@@ -54,7 +54,7 @@ export const createESJiraConnector = (
{ key: 'parent', value: '2' },
],
type: ConnectorTypes.jira,
- ...(overrides && { ...overrides }),
+ ...overrides,
};
};
@@ -94,7 +94,7 @@ export const createExternalService = (
email: 'testemail@elastic.co',
username: 'elastic',
},
- ...(overrides && { ...overrides }),
+ ...overrides,
});
export const basicCaseFields = {
@@ -198,3 +198,14 @@ export const createSavedObjectReferences = ({
]
: []),
];
+
+export const createConnectorObject = (overrides?: Partial) => ({
+ connector: { ...createJiraConnector(), ...overrides },
+});
+
+export const createSOFindResponse = (savedObjects: Array>) => ({
+ saved_objects: savedObjects,
+ total: savedObjects.length,
+ per_page: savedObjects.length,
+ page: 1,
+});
diff --git a/x-pack/plugins/cases/server/services/user_actions/helpers.test.ts b/x-pack/plugins/cases/server/services/user_actions/helpers.test.ts
new file mode 100644
index 000000000000..7bcbaf58d0f6
--- /dev/null
+++ b/x-pack/plugins/cases/server/services/user_actions/helpers.test.ts
@@ -0,0 +1,332 @@
+/*
+ * 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 { UserActionField } from '../../../common';
+import { createConnectorObject, createExternalService, createJiraConnector } from '../test_utils';
+import { buildCaseUserActionItem } from './helpers';
+
+const defaultFields = () => ({
+ actionAt: 'now',
+ actionBy: {
+ email: 'a',
+ full_name: 'j',
+ username: '1',
+ },
+ caseId: '300',
+ owner: 'securitySolution',
+});
+
+describe('user action helpers', () => {
+ describe('buildCaseUserActionItem', () => {
+ describe('push user action', () => {
+ it('extracts the external_service connector_id to references for a new pushed user action', () => {
+ const userAction = buildCaseUserActionItem({
+ ...defaultFields(),
+ action: 'push-to-service',
+ fields: ['pushed'],
+ newValue: createExternalService(),
+ });
+
+ const parsedExternalService = JSON.parse(userAction.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(userAction.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "300",
+ "name": "associated-cases",
+ "type": "cases",
+ },
+ Object {
+ "id": "100",
+ "name": "pushConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+
+ expect(userAction.attributes.old_value).toBeNull();
+ });
+
+ it('extract the external_service connector_id to references for new and old pushed user action', () => {
+ const userAction = buildCaseUserActionItem({
+ ...defaultFields(),
+ action: 'push-to-service',
+ fields: ['pushed'],
+ newValue: createExternalService(),
+ oldValue: createExternalService({ connector_id: '5' }),
+ });
+
+ const parsedNewExternalService = JSON.parse(userAction.attributes.new_value!);
+ const parsedOldExternalService = JSON.parse(userAction.attributes.old_value!);
+
+ expect(parsedNewExternalService).not.toHaveProperty('connector_id');
+ expect(parsedOldExternalService).not.toHaveProperty('connector_id');
+ expect(userAction.references).toEqual([
+ { id: '300', name: 'associated-cases', type: 'cases' },
+ { 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 = buildCaseUserActionItem({
+ ...defaultFields(),
+ action: 'push-to-service',
+ fields: ['invalid field'] as unknown as UserActionField,
+ newValue: 'hello' as unknown as Record,
+ });
+
+ expect(userAction.attributes.old_value).toBeNull();
+ expect(userAction).toMatchInlineSnapshot(`
+ Object {
+ "attributes": Object {
+ "action": "push-to-service",
+ "action_at": "now",
+ "action_by": Object {
+ "email": "a",
+ "full_name": "j",
+ "username": "1",
+ },
+ "action_field": Array [
+ "invalid field",
+ ],
+ "new_value": "hello",
+ "old_value": null,
+ "owner": "securitySolution",
+ },
+ "references": Array [
+ Object {
+ "id": "300",
+ "name": "associated-cases",
+ "type": "cases",
+ },
+ ],
+ }
+ `);
+ });
+ });
+
+ describe('update connector user action', () => {
+ it('extracts the connector id to references for a new create connector user action', () => {
+ const userAction = buildCaseUserActionItem({
+ ...defaultFields(),
+ action: 'update',
+ fields: ['connector'],
+ newValue: createJiraConnector(),
+ });
+
+ const parsedConnector = JSON.parse(userAction.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(userAction.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "300",
+ "name": "associated-cases",
+ "type": "cases",
+ },
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+
+ expect(userAction.attributes.old_value).toBeNull();
+ });
+
+ it('extracts the connector id to references for a new and old create connector user action', () => {
+ const userAction = buildCaseUserActionItem({
+ ...defaultFields(),
+ action: 'update',
+ fields: ['connector'],
+ newValue: createJiraConnector(),
+ oldValue: { ...createJiraConnector(), id: '5' },
+ });
+
+ const parsedNewConnector = JSON.parse(userAction.attributes.new_value!);
+ const parsedOldConnector = JSON.parse(userAction.attributes.new_value!);
+
+ expect(parsedNewConnector).not.toHaveProperty('id');
+ expect(parsedOldConnector).not.toHaveProperty('id');
+
+ expect(userAction.references).toEqual([
+ { id: '300', name: 'associated-cases', type: 'cases' },
+ { 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 = buildCaseUserActionItem({
+ ...defaultFields(),
+ action: 'update',
+ fields: ['invalid field'] as unknown as UserActionField,
+ newValue: 'hello' as unknown as Record,
+ oldValue: 'old value' as unknown as Record,
+ });
+
+ expect(userAction).toMatchInlineSnapshot(`
+ Object {
+ "attributes": Object {
+ "action": "update",
+ "action_at": "now",
+ "action_by": Object {
+ "email": "a",
+ "full_name": "j",
+ "username": "1",
+ },
+ "action_field": Array [
+ "invalid field",
+ ],
+ "new_value": "hello",
+ "old_value": "old value",
+ "owner": "securitySolution",
+ },
+ "references": Array [
+ Object {
+ "id": "300",
+ "name": "associated-cases",
+ "type": "cases",
+ },
+ ],
+ }
+ `);
+ });
+ });
+
+ describe('create connector user action', () => {
+ it('extracts the connector id to references for a new create connector user action', () => {
+ const userAction = buildCaseUserActionItem({
+ ...defaultFields(),
+ action: 'create',
+ fields: ['connector'],
+ newValue: createConnectorObject(),
+ });
+
+ const parsedConnector = JSON.parse(userAction.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(userAction.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "300",
+ "name": "associated-cases",
+ "type": "cases",
+ },
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+
+ expect(userAction.attributes.old_value).toBeNull();
+ });
+
+ it('extracts the connector id to references for a new and old create connector user action', () => {
+ const userAction = buildCaseUserActionItem({
+ ...defaultFields(),
+ action: 'create',
+ fields: ['connector'],
+ newValue: createConnectorObject(),
+ oldValue: createConnectorObject({ id: '5' }),
+ });
+
+ const parsedNewConnector = JSON.parse(userAction.attributes.new_value!);
+ const parsedOldConnector = JSON.parse(userAction.attributes.new_value!);
+
+ expect(parsedNewConnector.connector).not.toHaveProperty('id');
+ expect(parsedOldConnector.connector).not.toHaveProperty('id');
+
+ expect(userAction.references).toEqual([
+ { id: '300', name: 'associated-cases', type: 'cases' },
+ { 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 = buildCaseUserActionItem({
+ ...defaultFields(),
+ action: 'create',
+ fields: ['invalid action'] as unknown as UserActionField,
+ newValue: 'new json value' as unknown as Record,
+ oldValue: 'old value' as unknown as Record,
+ });
+
+ expect(userAction).toMatchInlineSnapshot(`
+ Object {
+ "attributes": Object {
+ "action": "create",
+ "action_at": "now",
+ "action_by": Object {
+ "email": "a",
+ "full_name": "j",
+ "username": "1",
+ },
+ "action_field": Array [
+ "invalid action",
+ ],
+ "new_value": "new json value",
+ "old_value": "old value",
+ "owner": "securitySolution",
+ },
+ "references": Array [
+ Object {
+ "id": "300",
+ "name": "associated-cases",
+ "type": "cases",
+ },
+ ],
+ }
+ `);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/cases/server/services/user_actions/helpers.ts b/x-pack/plugins/cases/server/services/user_actions/helpers.ts
index 223e731aa8d9..e91b69f0995b 100644
--- a/x-pack/plugins/cases/server/services/user_actions/helpers.ts
+++ b/x-pack/plugins/cases/server/services/user_actions/helpers.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { SavedObject, SavedObjectsUpdateResponse } from 'kibana/server';
+import { SavedObject, SavedObjectReference, SavedObjectsUpdateResponse } from 'kibana/server';
import { get, isPlainObject, isString } from 'lodash';
import deepEqual from 'fast-deep-equal';
@@ -23,8 +23,68 @@ import {
} from '../../../common';
import { isTwoArraysDifference } from '../../client/utils';
import { UserActionItem } from '.';
+import { extractConnectorId } from './transform';
+import { UserActionFieldType } from './types';
+import { CASE_REF_NAME, COMMENT_REF_NAME, SUB_CASE_REF_NAME } from '../../common';
-export const transformNewUserAction = ({
+interface BuildCaseUserActionParams {
+ action: UserAction;
+ actionAt: string;
+ actionBy: User;
+ caseId: string;
+ owner: string;
+ fields: UserActionField;
+ newValue?: Record | string | null;
+ oldValue?: Record | string | null;
+ subCaseId?: string;
+}
+
+export const buildCaseUserActionItem = ({
+ action,
+ actionAt,
+ actionBy,
+ caseId,
+ fields,
+ newValue,
+ oldValue,
+ subCaseId,
+ owner,
+}: BuildCaseUserActionParams): UserActionItem => {
+ const { transformedActionDetails: transformedNewValue, references: newValueReferences } =
+ extractConnectorId({
+ action,
+ actionFields: fields,
+ actionDetails: newValue,
+ fieldType: UserActionFieldType.New,
+ });
+
+ const { transformedActionDetails: transformedOldValue, references: oldValueReferences } =
+ extractConnectorId({
+ action,
+ actionFields: fields,
+ actionDetails: oldValue,
+ fieldType: UserActionFieldType.Old,
+ });
+
+ return {
+ attributes: transformNewUserAction({
+ actionField: fields,
+ action,
+ actionAt,
+ owner,
+ ...actionBy,
+ newValue: transformedNewValue,
+ oldValue: transformedOldValue,
+ }),
+ references: [
+ ...createCaseReferences(caseId, subCaseId),
+ ...newValueReferences,
+ ...oldValueReferences,
+ ],
+ };
+};
+
+const transformNewUserAction = ({
actionField,
action,
actionAt,
@@ -55,103 +115,43 @@ export const transformNewUserAction = ({
owner,
});
-interface BuildCaseUserAction {
- action: UserAction;
- actionAt: string;
- actionBy: User;
- caseId: string;
- owner: string;
- fields: UserActionField | unknown[];
- newValue?: string | unknown;
- oldValue?: string | unknown;
- subCaseId?: string;
-}
+const createCaseReferences = (caseId: string, subCaseId?: string): SavedObjectReference[] => [
+ {
+ type: CASE_SAVED_OBJECT,
+ name: CASE_REF_NAME,
+ id: caseId,
+ },
+ ...(subCaseId
+ ? [
+ {
+ type: SUB_CASE_SAVED_OBJECT,
+ name: SUB_CASE_REF_NAME,
+ id: subCaseId,
+ },
+ ]
+ : []),
+];
-interface BuildCommentUserActionItem extends BuildCaseUserAction {
+interface BuildCommentUserActionItem extends BuildCaseUserActionParams {
commentId: string;
}
-export const buildCommentUserActionItem = ({
- action,
- actionAt,
- actionBy,
- caseId,
- commentId,
- fields,
- newValue,
- oldValue,
- subCaseId,
- owner,
-}: BuildCommentUserActionItem): UserActionItem => ({
- attributes: transformNewUserAction({
- actionField: fields as UserActionField,
- action,
- actionAt,
- owner,
- ...actionBy,
- newValue: newValue as string,
- oldValue: oldValue as string,
- }),
- references: [
- {
- type: CASE_SAVED_OBJECT,
- name: `associated-${CASE_SAVED_OBJECT}`,
- id: caseId,
- },
- {
- type: CASE_COMMENT_SAVED_OBJECT,
- name: `associated-${CASE_COMMENT_SAVED_OBJECT}`,
- id: commentId,
- },
- ...(subCaseId
- ? [
- {
- type: SUB_CASE_SAVED_OBJECT,
- id: subCaseId,
- name: `associated-${SUB_CASE_SAVED_OBJECT}`,
- },
- ]
- : []),
- ],
-});
+export const buildCommentUserActionItem = (params: BuildCommentUserActionItem): UserActionItem => {
+ const { commentId } = params;
+ const { attributes, references } = buildCaseUserActionItem(params);
-export const buildCaseUserActionItem = ({
- action,
- actionAt,
- actionBy,
- caseId,
- fields,
- newValue,
- oldValue,
- subCaseId,
- owner,
-}: BuildCaseUserAction): UserActionItem => ({
- attributes: transformNewUserAction({
- actionField: fields as UserActionField,
- action,
- actionAt,
- owner,
- ...actionBy,
- newValue: newValue as string,
- oldValue: oldValue as string,
- }),
- references: [
- {
- type: CASE_SAVED_OBJECT,
- name: `associated-${CASE_SAVED_OBJECT}`,
- id: caseId,
- },
- ...(subCaseId
- ? [
- {
- type: SUB_CASE_SAVED_OBJECT,
- name: `associated-${SUB_CASE_SAVED_OBJECT}`,
- id: subCaseId,
- },
- ]
- : []),
- ],
-});
+ return {
+ attributes,
+ references: [
+ ...references,
+ {
+ type: CASE_COMMENT_SAVED_OBJECT,
+ name: COMMENT_REF_NAME,
+ id: commentId,
+ },
+ ],
+ };
+};
const userActionFieldsAllowed: UserActionField = [
'comment',
@@ -278,8 +278,8 @@ const buildGenericCaseUserActions = ({
caseId,
subCaseId,
fields: [field],
- newValue: JSON.stringify(updatedValue),
- oldValue: JSON.stringify(origValue),
+ newValue: updatedValue,
+ oldValue: origValue,
owner: originalItem.attributes.owner,
}),
];
diff --git a/x-pack/plugins/cases/server/services/user_actions/index.test.ts b/x-pack/plugins/cases/server/services/user_actions/index.test.ts
new file mode 100644
index 000000000000..c4a350f4ac01
--- /dev/null
+++ b/x-pack/plugins/cases/server/services/user_actions/index.test.ts
@@ -0,0 +1,557 @@
+/*
+ * 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 { SavedObject, SavedObjectsFindResult } from 'kibana/server';
+import { transformFindResponseToExternalModel, UserActionItem } from '.';
+import {
+ CaseUserActionAttributes,
+ CASE_USER_ACTION_SAVED_OBJECT,
+ UserAction,
+ UserActionField,
+} from '../../../common';
+
+import {
+ createConnectorObject,
+ createExternalService,
+ createJiraConnector,
+ createSOFindResponse,
+} from '../test_utils';
+import { buildCaseUserActionItem, buildCommentUserActionItem } from './helpers';
+
+const createConnectorUserAction = (
+ subCaseId?: string,
+ overrides?: Partial
+): SavedObject => {
+ return {
+ ...createUserActionSO({
+ action: 'create',
+ fields: ['connector'],
+ newValue: createConnectorObject(),
+ subCaseId,
+ }),
+ ...(overrides && { ...overrides }),
+ };
+};
+
+const updateConnectorUserAction = ({
+ subCaseId,
+ overrides,
+ oldValue,
+}: {
+ subCaseId?: string;
+ overrides?: Partial;
+ oldValue?: string | null | Record;
+} = {}): SavedObject => {
+ return {
+ ...createUserActionSO({
+ action: 'update',
+ fields: ['connector'],
+ newValue: createJiraConnector(),
+ oldValue,
+ subCaseId,
+ }),
+ ...(overrides && { ...overrides }),
+ };
+};
+
+const pushConnectorUserAction = ({
+ subCaseId,
+ overrides,
+ oldValue,
+}: {
+ subCaseId?: string;
+ overrides?: Partial;
+ oldValue?: string | null | Record;
+} = {}): SavedObject => {
+ return {
+ ...createUserActionSO({
+ action: 'push-to-service',
+ fields: ['pushed'],
+ newValue: createExternalService(),
+ oldValue,
+ subCaseId,
+ }),
+ ...(overrides && { ...overrides }),
+ };
+};
+
+const createUserActionFindSO = (
+ userAction: SavedObject
+): SavedObjectsFindResult => ({
+ ...userAction,
+ score: 0,
+});
+
+const createUserActionSO = ({
+ action,
+ fields,
+ subCaseId,
+ newValue,
+ oldValue,
+ attributesOverrides,
+ commentId,
+}: {
+ action: UserAction;
+ fields: UserActionField;
+ subCaseId?: string;
+ newValue?: string | null | Record;
+ oldValue?: string | null | Record;
+ attributesOverrides?: Partial;
+ commentId?: string;
+}): SavedObject => {
+ const defaultParams = {
+ action,
+ actionAt: 'abc',
+ actionBy: {
+ email: 'a',
+ username: 'b',
+ full_name: 'abc',
+ },
+ caseId: '1',
+ subCaseId,
+ fields,
+ newValue,
+ oldValue,
+ owner: 'securitySolution',
+ };
+
+ let userAction: UserActionItem;
+
+ if (commentId) {
+ userAction = buildCommentUserActionItem({
+ commentId,
+ ...defaultParams,
+ });
+ } else {
+ userAction = buildCaseUserActionItem(defaultParams);
+ }
+
+ return {
+ type: CASE_USER_ACTION_SAVED_OBJECT,
+ id: '100',
+ attributes: {
+ ...userAction.attributes,
+ ...(attributesOverrides && { ...attributesOverrides }),
+ },
+ references: userAction.references,
+ };
+};
+
+describe('CaseUserActionService', () => {
+ describe('transformFindResponseToExternalModel', () => {
+ it('does not populate the ids when the response is an empty array', () => {
+ expect(transformFindResponseToExternalModel(createSOFindResponse([]))).toMatchInlineSnapshot(`
+ Object {
+ "page": 1,
+ "per_page": 0,
+ "saved_objects": Array [],
+ "total": 0,
+ }
+ `);
+ });
+
+ it('preserves the saved object fields and attributes when inject the ids', () => {
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(createConnectorUserAction())])
+ );
+
+ expect(transformed).toMatchInlineSnapshot(`
+ Object {
+ "page": 1,
+ "per_page": 1,
+ "saved_objects": Array [
+ Object {
+ "attributes": Object {
+ "action": "create",
+ "action_at": "abc",
+ "action_by": Object {
+ "email": "a",
+ "full_name": "abc",
+ "username": "b",
+ },
+ "action_field": Array [
+ "connector",
+ ],
+ "action_id": "100",
+ "case_id": "1",
+ "comment_id": null,
+ "new_val_connector_id": "1",
+ "new_value": "{\\"connector\\":{\\"name\\":\\".jira\\",\\"type\\":\\".jira\\",\\"fields\\":{\\"issueType\\":\\"bug\\",\\"priority\\":\\"high\\",\\"parent\\":\\"2\\"}}}",
+ "old_val_connector_id": null,
+ "old_value": null,
+ "owner": "securitySolution",
+ "sub_case_id": "",
+ },
+ "id": "100",
+ "references": Array [
+ Object {
+ "id": "1",
+ "name": "associated-cases",
+ "type": "cases",
+ },
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ],
+ "score": 0,
+ "type": "cases-user-actions",
+ },
+ ],
+ "total": 1,
+ }
+ `);
+ });
+
+ it('populates the new_val_connector_id for multiple user actions', () => {
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([
+ createUserActionFindSO(createConnectorUserAction()),
+ createUserActionFindSO(createConnectorUserAction()),
+ ])
+ );
+
+ expect(transformed.saved_objects[0].attributes.new_val_connector_id).toEqual('1');
+ expect(transformed.saved_objects[1].attributes.new_val_connector_id).toEqual('1');
+ });
+
+ it('populates the old_val_connector_id for multiple user actions', () => {
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([
+ createUserActionFindSO(
+ createUserActionSO({
+ action: 'create',
+ fields: ['connector'],
+ oldValue: createConnectorObject(),
+ })
+ ),
+ createUserActionFindSO(
+ createUserActionSO({
+ action: 'create',
+ fields: ['connector'],
+ oldValue: createConnectorObject({ id: '10' }),
+ })
+ ),
+ ])
+ );
+
+ expect(transformed.saved_objects[0].attributes.old_val_connector_id).toEqual('1');
+ expect(transformed.saved_objects[1].attributes.old_val_connector_id).toEqual('10');
+ });
+
+ describe('reference ids', () => {
+ it('sets case_id to an empty string when it cannot find the reference', () => {
+ const userAction = {
+ ...createConnectorUserAction(),
+ references: [],
+ };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.case_id).toEqual('');
+ });
+
+ it('sets comment_id to null when it cannot find the reference', () => {
+ const userAction = {
+ ...createUserActionSO({ action: 'create', fields: ['connector'], commentId: '5' }),
+ references: [],
+ };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.comment_id).toBeNull();
+ });
+
+ it('sets sub_case_id to an empty string when it cannot find the reference', () => {
+ const userAction = {
+ ...createUserActionSO({ action: 'create', fields: ['connector'], subCaseId: '5' }),
+ references: [],
+ };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.comment_id).toBeNull();
+ });
+
+ it('sets case_id correctly when it finds the reference', () => {
+ const userAction = createConnectorUserAction();
+
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.case_id).toEqual('1');
+ });
+
+ it('sets comment_id correctly when it finds the reference', () => {
+ const userAction = createUserActionSO({
+ action: 'create',
+ fields: ['connector'],
+ commentId: '5',
+ });
+
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.comment_id).toEqual('5');
+ });
+
+ it('sets sub_case_id correctly when it finds the reference', () => {
+ const userAction = {
+ ...createUserActionSO({ action: 'create', fields: ['connector'], subCaseId: '5' }),
+ };
+
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.sub_case_id).toEqual('5');
+ });
+
+ it('sets action_id correctly to the saved object id', () => {
+ const userAction = {
+ ...createUserActionSO({ action: 'create', fields: ['connector'], subCaseId: '5' }),
+ };
+
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.action_id).toEqual('100');
+ });
+ });
+
+ describe('create connector', () => {
+ it('does not populate the new_val_connector_id when it cannot find the reference', () => {
+ const userAction = { ...createConnectorUserAction(), references: [] };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull();
+ });
+
+ it('does not populate the old_val_connector_id when it cannot find the reference', () => {
+ const userAction = { ...createConnectorUserAction(), references: [] };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull();
+ });
+
+ it('does not populate the new_val_connector_id when the reference exists but the action and fields are invalid', () => {
+ const validUserAction = createConnectorUserAction();
+ const invalidUserAction = {
+ ...validUserAction,
+ attributes: { ...validUserAction.attributes, action: 'invalid' },
+ };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([
+ createUserActionFindSO(invalidUserAction as SavedObject),
+ ])
+ );
+
+ expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull();
+ });
+
+ it('does not populate the old_val_connector_id when the reference exists but the action and fields are invalid', () => {
+ const validUserAction = createUserActionSO({
+ action: 'create',
+ fields: ['connector'],
+ oldValue: createConnectorObject(),
+ });
+
+ const invalidUserAction = {
+ ...validUserAction,
+ attributes: { ...validUserAction.attributes, action: 'invalid' },
+ };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([
+ createUserActionFindSO(invalidUserAction as SavedObject),
+ ])
+ );
+
+ expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull();
+ });
+
+ it('populates the new_val_connector_id', () => {
+ const userAction = createConnectorUserAction();
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.new_val_connector_id).toEqual('1');
+ });
+
+ it('populates the old_val_connector_id', () => {
+ const userAction = createUserActionSO({
+ action: 'create',
+ fields: ['connector'],
+ oldValue: createConnectorObject(),
+ });
+
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.old_val_connector_id).toEqual('1');
+ });
+ });
+
+ describe('update connector', () => {
+ it('does not populate the new_val_connector_id when it cannot find the reference', () => {
+ const userAction = { ...updateConnectorUserAction(), references: [] };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull();
+ });
+
+ it('does not populate the old_val_connector_id when it cannot find the reference', () => {
+ const userAction = {
+ ...updateConnectorUserAction({ oldValue: createJiraConnector() }),
+ references: [],
+ };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull();
+ });
+
+ it('does not populate the new_val_connector_id when the reference exists but the action and fields are invalid', () => {
+ const validUserAction = updateConnectorUserAction();
+ const invalidUserAction = {
+ ...validUserAction,
+ attributes: { ...validUserAction.attributes, action: 'invalid' },
+ };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([
+ createUserActionFindSO(invalidUserAction as SavedObject),
+ ])
+ );
+
+ expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull();
+ });
+
+ it('does not populate the old_val_connector_id when the reference exists but the action and fields are invalid', () => {
+ const validUserAction = updateConnectorUserAction({ oldValue: createJiraConnector() });
+
+ const invalidUserAction = {
+ ...validUserAction,
+ attributes: { ...validUserAction.attributes, action: 'invalid' },
+ };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([
+ createUserActionFindSO(invalidUserAction as SavedObject),
+ ])
+ );
+
+ expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull();
+ });
+
+ it('populates the new_val_connector_id', () => {
+ const userAction = updateConnectorUserAction();
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.new_val_connector_id).toEqual('1');
+ });
+
+ it('populates the old_val_connector_id', () => {
+ const userAction = updateConnectorUserAction({ oldValue: createJiraConnector() });
+
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.old_val_connector_id).toEqual('1');
+ });
+ });
+
+ describe('push connector', () => {
+ it('does not populate the new_val_connector_id when it cannot find the reference', () => {
+ const userAction = { ...pushConnectorUserAction(), references: [] };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull();
+ });
+
+ it('does not populate the old_val_connector_id when it cannot find the reference', () => {
+ const userAction = {
+ ...pushConnectorUserAction({ oldValue: createExternalService() }),
+ references: [],
+ };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull();
+ });
+
+ it('does not populate the new_val_connector_id when the reference exists but the action and fields are invalid', () => {
+ const validUserAction = pushConnectorUserAction();
+ const invalidUserAction = {
+ ...validUserAction,
+ attributes: { ...validUserAction.attributes, action: 'invalid' },
+ };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([
+ createUserActionFindSO(invalidUserAction as SavedObject),
+ ])
+ );
+
+ expect(transformed.saved_objects[0].attributes.new_val_connector_id).toBeNull();
+ });
+
+ it('does not populate the old_val_connector_id when the reference exists but the action and fields are invalid', () => {
+ const validUserAction = pushConnectorUserAction({ oldValue: createExternalService() });
+
+ const invalidUserAction = {
+ ...validUserAction,
+ attributes: { ...validUserAction.attributes, action: 'invalid' },
+ };
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([
+ createUserActionFindSO(invalidUserAction as SavedObject),
+ ])
+ );
+
+ expect(transformed.saved_objects[0].attributes.old_val_connector_id).toBeNull();
+ });
+
+ it('populates the new_val_connector_id', () => {
+ const userAction = pushConnectorUserAction();
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.new_val_connector_id).toEqual('100');
+ });
+
+ it('populates the old_val_connector_id', () => {
+ const userAction = pushConnectorUserAction({ oldValue: createExternalService() });
+
+ const transformed = transformFindResponseToExternalModel(
+ createSOFindResponse([createUserActionFindSO(userAction)])
+ );
+
+ expect(transformed.saved_objects[0].attributes.old_val_connector_id).toEqual('100');
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/cases/server/services/user_actions/index.ts b/x-pack/plugins/cases/server/services/user_actions/index.ts
index b70244816555..4f158862e3d6 100644
--- a/x-pack/plugins/cases/server/services/user_actions/index.ts
+++ b/x-pack/plugins/cases/server/services/user_actions/index.ts
@@ -5,7 +5,12 @@
* 2.0.
*/
-import { Logger, SavedObjectReference } from 'kibana/server';
+import {
+ Logger,
+ SavedObjectReference,
+ SavedObjectsFindResponse,
+ SavedObjectsFindResult,
+} from 'kibana/server';
import {
CASE_SAVED_OBJECT,
@@ -13,8 +18,17 @@ import {
CaseUserActionAttributes,
MAX_DOCS_PER_PAGE,
SUB_CASE_SAVED_OBJECT,
+ CaseUserActionResponse,
+ CASE_COMMENT_SAVED_OBJECT,
+ isCreateConnector,
+ isPush,
+ isUpdateConnector,
} from '../../../common';
import { ClientArgs } from '..';
+import { UserActionFieldType } from './types';
+import { CASE_REF_NAME, COMMENT_REF_NAME, SUB_CASE_REF_NAME } from '../../common';
+import { ConnectorIdReferenceName, PushConnectorIdReferenceName } from './transform';
+import { findConnectorIdReference } from '../transform';
interface GetCaseUserActionArgs extends ClientArgs {
caseId: string;
@@ -33,12 +47,16 @@ interface PostCaseUserActionArgs extends ClientArgs {
export class CaseUserActionService {
constructor(private readonly log: Logger) {}
- public async getAll({ unsecuredSavedObjectsClient, caseId, subCaseId }: GetCaseUserActionArgs) {
+ public async getAll({
+ unsecuredSavedObjectsClient,
+ caseId,
+ subCaseId,
+ }: GetCaseUserActionArgs): Promise> {
try {
const id = subCaseId ?? caseId;
const type = subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT;
- return await unsecuredSavedObjectsClient.find({
+ const userActions = await unsecuredSavedObjectsClient.find({
type: CASE_USER_ACTION_SAVED_OBJECT,
hasReference: { type, id },
page: 1,
@@ -46,17 +64,22 @@ export class CaseUserActionService {
sortField: 'action_at',
sortOrder: 'asc',
});
+
+ return transformFindResponseToExternalModel(userActions);
} catch (error) {
this.log.error(`Error on GET case user action case id: ${caseId}: ${error}`);
throw error;
}
}
- public async bulkCreate({ unsecuredSavedObjectsClient, actions }: PostCaseUserActionArgs) {
+ public async bulkCreate({
+ unsecuredSavedObjectsClient,
+ actions,
+ }: PostCaseUserActionArgs): Promise {
try {
this.log.debug(`Attempting to POST a new case user action`);
- return await unsecuredSavedObjectsClient.bulkCreate(
+ await unsecuredSavedObjectsClient.bulkCreate(
actions.map((action) => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action }))
);
} catch (error) {
@@ -65,3 +88,71 @@ export class CaseUserActionService {
}
}
}
+
+export function transformFindResponseToExternalModel(
+ userActions: SavedObjectsFindResponse
+): SavedObjectsFindResponse {
+ return {
+ ...userActions,
+ saved_objects: userActions.saved_objects.map((so) => ({
+ ...so,
+ ...transformToExternalModel(so),
+ })),
+ };
+}
+
+function transformToExternalModel(
+ userAction: SavedObjectsFindResult
+): SavedObjectsFindResult {
+ const { references } = userAction;
+
+ const newValueConnectorId = getConnectorIdFromReferences(UserActionFieldType.New, userAction);
+ const oldValueConnectorId = getConnectorIdFromReferences(UserActionFieldType.Old, userAction);
+
+ const caseId = findReferenceId(CASE_REF_NAME, CASE_SAVED_OBJECT, references) ?? '';
+ const commentId =
+ findReferenceId(COMMENT_REF_NAME, CASE_COMMENT_SAVED_OBJECT, references) ?? null;
+ const subCaseId = findReferenceId(SUB_CASE_REF_NAME, SUB_CASE_SAVED_OBJECT, references) ?? '';
+
+ return {
+ ...userAction,
+ attributes: {
+ ...userAction.attributes,
+ action_id: userAction.id,
+ case_id: caseId,
+ comment_id: commentId,
+ sub_case_id: subCaseId,
+ new_val_connector_id: newValueConnectorId,
+ old_val_connector_id: oldValueConnectorId,
+ },
+ };
+}
+
+function getConnectorIdFromReferences(
+ fieldType: UserActionFieldType,
+ userAction: SavedObjectsFindResult
+): string | null {
+ const {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ attributes: { action, action_field },
+ references,
+ } = userAction;
+
+ if (isCreateConnector(action, action_field) || isUpdateConnector(action, action_field)) {
+ return findConnectorIdReference(ConnectorIdReferenceName[fieldType], references)?.id ?? null;
+ } else if (isPush(action, action_field)) {
+ return (
+ findConnectorIdReference(PushConnectorIdReferenceName[fieldType], references)?.id ?? null
+ );
+ }
+
+ return null;
+}
+
+function findReferenceId(
+ name: string,
+ type: string,
+ references: SavedObjectReference[]
+): string | undefined {
+ return references.find((ref) => ref.name === name && ref.type === type)?.id;
+}
diff --git a/x-pack/plugins/cases/server/services/user_actions/transform.test.ts b/x-pack/plugins/cases/server/services/user_actions/transform.test.ts
new file mode 100644
index 000000000000..2d2877061709
--- /dev/null
+++ b/x-pack/plugins/cases/server/services/user_actions/transform.test.ts
@@ -0,0 +1,1246 @@
+/*
+ * 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 { noneConnectorId } from '../../../common';
+import {
+ CONNECTOR_ID_REFERENCE_NAME,
+ getNoneCaseConnector,
+ PUSH_CONNECTOR_ID_REFERENCE_NAME,
+ USER_ACTION_OLD_ID_REF_NAME,
+ USER_ACTION_OLD_PUSH_ID_REF_NAME,
+} from '../../common';
+import { createConnectorObject, createExternalService, createJiraConnector } from '../test_utils';
+import {
+ extractConnectorIdHelper,
+ extractConnectorIdFromJson,
+ extractConnectorId,
+ transformConnectorIdToReference,
+ transformPushConnectorIdToReference,
+} from './transform';
+import { UserActionFieldType } from './types';
+
+describe('user action transform utils', () => {
+ describe('transformConnectorIdToReference', () => {
+ it('returns the default none connector when the connector is undefined', () => {
+ expect(transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME).transformedConnector)
+ .toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": null,
+ "name": "none",
+ "type": ".none",
+ },
+ }
+ `);
+ });
+
+ it('returns the default none connector when the id is undefined', () => {
+ expect(
+ transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, { id: undefined })
+ .transformedConnector
+ ).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": null,
+ "name": "none",
+ "type": ".none",
+ },
+ }
+ `);
+ });
+
+ it('returns the default none connector when the id is none', () => {
+ expect(
+ transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, { id: noneConnectorId })
+ .transformedConnector
+ ).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": null,
+ "name": "none",
+ "type": ".none",
+ },
+ }
+ `);
+ });
+
+ it('returns the default none connector when the id is none and other fields are defined', () => {
+ expect(
+ transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, {
+ ...createJiraConnector(),
+ id: noneConnectorId,
+ }).transformedConnector
+ ).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": null,
+ "name": "none",
+ "type": ".none",
+ },
+ }
+ `);
+ });
+
+ it('returns an empty array of references when the connector is undefined', () => {
+ expect(transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME).references.length).toBe(
+ 0
+ );
+ });
+
+ it('returns an empty array of references when the id is undefined', () => {
+ expect(
+ transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, { id: undefined }).references
+ .length
+ ).toBe(0);
+ });
+
+ it('returns an empty array of references when the id is the none connector', () => {
+ expect(
+ transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, { id: noneConnectorId })
+ .references.length
+ ).toBe(0);
+ });
+
+ it('returns an empty array of references when the id is the none connector and other fields are defined', () => {
+ expect(
+ transformConnectorIdToReference(CONNECTOR_ID_REFERENCE_NAME, {
+ ...createJiraConnector(),
+ id: noneConnectorId,
+ }).references.length
+ ).toBe(0);
+ });
+
+ it('returns a jira connector', () => {
+ const transformedFields = transformConnectorIdToReference(
+ CONNECTOR_ID_REFERENCE_NAME,
+ createJiraConnector()
+ );
+ expect(transformedFields.transformedConnector).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ },
+ }
+ `);
+ expect(transformedFields.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+
+ it('returns a jira connector with the user action reference name', () => {
+ const transformedFields = transformConnectorIdToReference(
+ USER_ACTION_OLD_ID_REF_NAME,
+ createJiraConnector()
+ );
+ expect(transformedFields.transformedConnector).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ },
+ }
+ `);
+ expect(transformedFields.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "oldConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+ });
+
+ describe('transformPushConnectorIdToReference', () => {
+ it('sets external_service to null when it is undefined', () => {
+ expect(
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME)
+ .transformedPushConnector
+ ).toMatchInlineSnapshot(`
+ Object {
+ "external_service": null,
+ }
+ `);
+ });
+
+ it('sets external_service to null when it is null', () => {
+ expect(
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, null)
+ .transformedPushConnector
+ ).toMatchInlineSnapshot(`
+ Object {
+ "external_service": null,
+ }
+ `);
+ });
+
+ it('returns an object when external_service is defined but connector_id is undefined', () => {
+ expect(
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, {
+ connector_id: undefined,
+ }).transformedPushConnector
+ ).toMatchInlineSnapshot(`
+ Object {
+ "external_service": Object {},
+ }
+ `);
+ });
+
+ it('returns an object when external_service is defined but connector_id is null', () => {
+ expect(
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, {
+ connector_id: null,
+ }).transformedPushConnector
+ ).toMatchInlineSnapshot(`
+ Object {
+ "external_service": Object {},
+ }
+ `);
+ });
+
+ it('returns an object when external_service is defined but connector_id is none', () => {
+ const otherFields = { otherField: 'hi' };
+
+ expect(
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, {
+ ...otherFields,
+ connector_id: noneConnectorId,
+ }).transformedPushConnector
+ ).toMatchInlineSnapshot(`
+ Object {
+ "external_service": Object {
+ "otherField": "hi",
+ },
+ }
+ `);
+ });
+
+ it('returns an empty array of references when the external_service is undefined', () => {
+ expect(
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME).references.length
+ ).toBe(0);
+ });
+
+ it('returns an empty array of references when the external_service is null', () => {
+ expect(
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, null).references
+ .length
+ ).toBe(0);
+ });
+
+ it('returns an empty array of references when the connector_id is undefined', () => {
+ expect(
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, {
+ connector_id: undefined,
+ }).references.length
+ ).toBe(0);
+ });
+
+ it('returns an empty array of references when the connector_id is null', () => {
+ expect(
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, {
+ connector_id: undefined,
+ }).references.length
+ ).toBe(0);
+ });
+
+ it('returns an empty array of references when the connector_id is the none connector', () => {
+ expect(
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, {
+ connector_id: noneConnectorId,
+ }).references.length
+ ).toBe(0);
+ });
+
+ it('returns an empty array of references when the connector_id is the none connector and other fields are defined', () => {
+ expect(
+ transformPushConnectorIdToReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, {
+ ...createExternalService(),
+ connector_id: noneConnectorId,
+ }).references.length
+ ).toBe(0);
+ });
+
+ it('returns the external_service connector', () => {
+ const transformedFields = transformPushConnectorIdToReference(
+ PUSH_CONNECTOR_ID_REFERENCE_NAME,
+ createExternalService()
+ );
+ expect(transformedFields.transformedPushConnector).toMatchInlineSnapshot(`
+ Object {
+ "external_service": 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(transformedFields.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "100",
+ "name": "pushConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+
+ it('returns the external_service connector with a user actions reference name', () => {
+ const transformedFields = transformPushConnectorIdToReference(
+ USER_ACTION_OLD_PUSH_ID_REF_NAME,
+ createExternalService()
+ );
+
+ expect(transformedFields.references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "100",
+ "name": "oldPushConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+ });
+
+ describe('extractConnectorIdHelper', () => {
+ it('throws an error when action details has a circular reference', () => {
+ const circularRef = { prop: {} };
+ circularRef.prop = circularRef;
+
+ expect(() => {
+ extractConnectorIdHelper({
+ action: 'a',
+ actionFields: [],
+ actionDetails: circularRef,
+ fieldType: UserActionFieldType.New,
+ });
+ }).toThrow();
+ });
+
+ describe('create action', () => {
+ it('returns no references and untransformed json when actionDetails is not a valid connector', () => {
+ expect(
+ extractConnectorIdHelper({
+ action: 'create',
+ actionFields: ['connector'],
+ actionDetails: { a: 'hello' },
+ fieldType: UserActionFieldType.New,
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "references": Array [],
+ "transformedActionDetails": "{\\"a\\":\\"hello\\"}",
+ }
+ `);
+ });
+
+ it('returns no references and untransformed json when the action is create and action fields does not contain connector', () => {
+ expect(
+ extractConnectorIdHelper({
+ action: 'create',
+ actionFields: ['', 'something', 'onnector'],
+ actionDetails: { a: 'hello' },
+ fieldType: UserActionFieldType.New,
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "references": Array [],
+ "transformedActionDetails": "{\\"a\\":\\"hello\\"}",
+ }
+ `);
+ });
+
+ it('returns the stringified json without the id', () => {
+ const jiraConnector = createConnectorObject();
+
+ const { transformedActionDetails } = extractConnectorIdHelper({
+ action: 'create',
+ actionFields: ['connector'],
+ actionDetails: jiraConnector,
+ fieldType: UserActionFieldType.New,
+ });
+
+ expect(JSON.parse(transformedActionDetails)).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ },
+ }
+ `);
+ });
+
+ it('removes the connector.id when the connector is none', () => {
+ const connector = { connector: getNoneCaseConnector() };
+
+ const { transformedActionDetails } = extractConnectorIdHelper({
+ action: 'create',
+ actionFields: ['connector'],
+ actionDetails: connector,
+ fieldType: UserActionFieldType.New,
+ })!;
+
+ const parsedJson = JSON.parse(transformedActionDetails);
+
+ expect(parsedJson.connector).not.toHaveProperty('id');
+ expect(parsedJson).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": null,
+ "name": "none",
+ "type": ".none",
+ },
+ }
+ `);
+ });
+
+ it('does not return a reference when the connector is none', () => {
+ const connector = { connector: getNoneCaseConnector() };
+
+ const { references } = extractConnectorIdHelper({
+ action: 'create',
+ actionFields: ['connector'],
+ actionDetails: connector,
+ fieldType: UserActionFieldType.New,
+ })!;
+
+ expect(references).toEqual([]);
+ });
+
+ it('returns a reference to the connector.id', () => {
+ const connector = createConnectorObject();
+
+ const { references } = extractConnectorIdHelper({
+ action: 'create',
+ actionFields: ['connector'],
+ actionDetails: connector,
+ fieldType: UserActionFieldType.New,
+ })!;
+
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+
+ it('returns an old reference name to the connector.id', () => {
+ const connector = createConnectorObject();
+
+ const { references } = extractConnectorIdHelper({
+ action: 'create',
+ actionFields: ['connector'],
+ actionDetails: connector,
+ fieldType: UserActionFieldType.Old,
+ })!;
+
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "oldConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+
+ it('returns the transformed connector and the description', () => {
+ const details = { ...createConnectorObject(), description: 'a description' };
+
+ const { transformedActionDetails } = extractConnectorIdHelper({
+ action: 'create',
+ actionFields: ['connector'],
+ actionDetails: details,
+ fieldType: UserActionFieldType.Old,
+ })!;
+
+ const parsedJson = JSON.parse(transformedActionDetails);
+
+ expect(parsedJson).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ },
+ "description": "a description",
+ }
+ `);
+ });
+ });
+
+ describe('update action', () => {
+ it('returns no references and untransformed json when actionDetails is not a valid connector', () => {
+ expect(
+ extractConnectorIdHelper({
+ action: 'update',
+ actionFields: ['connector'],
+ actionDetails: { a: 'hello' },
+ fieldType: UserActionFieldType.New,
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "references": Array [],
+ "transformedActionDetails": "{\\"a\\":\\"hello\\"}",
+ }
+ `);
+ });
+
+ it('returns no references and untransformed json when the action is update and action fields does not contain connector', () => {
+ expect(
+ extractConnectorIdHelper({
+ action: 'update',
+ actionFields: ['', 'something', 'onnector'],
+ actionDetails: 5,
+ fieldType: UserActionFieldType.New,
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "references": Array [],
+ "transformedActionDetails": "5",
+ }
+ `);
+ });
+
+ it('returns the stringified json without the id', () => {
+ const jiraConnector = createJiraConnector();
+
+ const { transformedActionDetails } = extractConnectorIdHelper({
+ action: 'update',
+ actionFields: ['connector'],
+ actionDetails: jiraConnector,
+ fieldType: UserActionFieldType.New,
+ });
+
+ const transformedConnetor = JSON.parse(transformedActionDetails!);
+ expect(transformedConnetor).not.toHaveProperty('id');
+ expect(transformedConnetor).toMatchInlineSnapshot(`
+ Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ }
+ `);
+ });
+
+ it('returns the stringified json without the id when the connector is none', () => {
+ const connector = getNoneCaseConnector();
+
+ const { transformedActionDetails } = extractConnectorIdHelper({
+ action: 'update',
+ actionFields: ['connector'],
+ actionDetails: connector,
+ fieldType: UserActionFieldType.New,
+ });
+
+ const transformedConnetor = JSON.parse(transformedActionDetails);
+ expect(transformedConnetor).not.toHaveProperty('id');
+ expect(transformedConnetor).toMatchInlineSnapshot(`
+ Object {
+ "fields": null,
+ "name": "none",
+ "type": ".none",
+ }
+ `);
+ });
+
+ it('returns a reference to the connector.id', () => {
+ const jiraConnector = createJiraConnector();
+
+ const { references } = extractConnectorIdHelper({
+ action: 'update',
+ actionFields: ['connector'],
+ actionDetails: jiraConnector,
+ fieldType: UserActionFieldType.New,
+ })!;
+
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+
+ it('does not return a reference when the connector is none', () => {
+ const connector = getNoneCaseConnector();
+
+ const { references } = extractConnectorIdHelper({
+ action: 'update',
+ actionFields: ['connector'],
+ actionDetails: connector,
+ fieldType: UserActionFieldType.New,
+ })!;
+
+ expect(references).toEqual([]);
+ });
+
+ it('returns an old reference name to the connector.id', () => {
+ const jiraConnector = createJiraConnector();
+
+ const { references } = extractConnectorIdHelper({
+ action: 'update',
+ actionFields: ['connector'],
+ actionDetails: jiraConnector,
+ fieldType: UserActionFieldType.Old,
+ })!;
+
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "oldConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+ });
+
+ describe('push action', () => {
+ it('returns no references and untransformed json when actionDetails is not a valid external_service', () => {
+ expect(
+ extractConnectorIdHelper({
+ action: 'push-to-service',
+ actionFields: ['pushed'],
+ actionDetails: { a: 'hello' },
+ fieldType: UserActionFieldType.New,
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "references": Array [],
+ "transformedActionDetails": "{\\"a\\":\\"hello\\"}",
+ }
+ `);
+ });
+
+ it('returns no references and untransformed json when the action is push-to-service and action fields does not contain pushed', () => {
+ expect(
+ extractConnectorIdHelper({
+ action: 'push-to-service',
+ actionFields: ['', 'something', 'ushed'],
+ actionDetails: { a: 'hello' },
+ fieldType: UserActionFieldType.New,
+ })
+ ).toMatchInlineSnapshot(`
+ Object {
+ "references": Array [],
+ "transformedActionDetails": "{\\"a\\":\\"hello\\"}",
+ }
+ `);
+ });
+
+ it('returns the stringified json without the connector_id', () => {
+ const externalService = createExternalService();
+
+ const { transformedActionDetails } = extractConnectorIdHelper({
+ action: 'push-to-service',
+ actionFields: ['pushed'],
+ actionDetails: externalService,
+ fieldType: UserActionFieldType.New,
+ });
+
+ const transformedExternalService = JSON.parse(transformedActionDetails);
+ expect(transformedExternalService).not.toHaveProperty('connector_id');
+ expect(transformedExternalService).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('returns a reference to the connector_id', () => {
+ const externalService = createExternalService();
+
+ const { references } = extractConnectorIdHelper({
+ action: 'push-to-service',
+ actionFields: ['pushed'],
+ actionDetails: externalService,
+ fieldType: UserActionFieldType.New,
+ })!;
+
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "100",
+ "name": "pushConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+
+ it('returns an old reference name to the connector_id', () => {
+ const externalService = createExternalService();
+
+ const { references } = extractConnectorIdHelper({
+ action: 'push-to-service',
+ actionFields: ['pushed'],
+ actionDetails: externalService,
+ fieldType: UserActionFieldType.Old,
+ })!;
+
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "100",
+ "name": "oldPushConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+ });
+ });
+
+ describe('extractConnectorId', () => {
+ it('returns null when the action details has a circular reference', () => {
+ const circularRef = { prop: {} };
+ circularRef.prop = circularRef;
+
+ const { transformedActionDetails, references } = extractConnectorId({
+ action: 'a',
+ actionFields: ['a'],
+ actionDetails: circularRef,
+ fieldType: UserActionFieldType.New,
+ });
+
+ expect(transformedActionDetails).toBeNull();
+ expect(references).toEqual([]);
+ });
+
+ describe('fails to extract the id', () => {
+ it('returns a null transformed action details when it is initially null', () => {
+ const { transformedActionDetails, references } = extractConnectorId({
+ action: 'a',
+ actionFields: ['a'],
+ actionDetails: null,
+ fieldType: UserActionFieldType.New,
+ });
+
+ expect(transformedActionDetails).toBeNull();
+ expect(references).toEqual([]);
+ });
+
+ it('returns an undefined transformed action details when it is initially undefined', () => {
+ const { transformedActionDetails, references } = extractConnectorId({
+ action: 'a',
+ actionFields: ['a'],
+ actionDetails: undefined,
+ fieldType: UserActionFieldType.New,
+ });
+
+ expect(transformedActionDetails).toBeUndefined();
+ expect(references).toEqual([]);
+ });
+
+ it('returns a json encoded string and empty references when the action is not a valid connector', () => {
+ const { transformedActionDetails, references } = extractConnectorId({
+ action: 'a',
+ actionFields: ['a'],
+ actionDetails: { a: 'hello' },
+ fieldType: UserActionFieldType.New,
+ });
+
+ expect(JSON.parse(transformedActionDetails!)).toEqual({ a: 'hello' });
+ expect(references).toEqual([]);
+ });
+
+ it('returns a json encoded string and empty references when the action details is an invalid object', () => {
+ const { transformedActionDetails, references } = extractConnectorId({
+ action: 'a',
+ actionFields: ['a'],
+ actionDetails: 5 as unknown as Record,
+ fieldType: UserActionFieldType.New,
+ });
+
+ expect(transformedActionDetails!).toEqual('5');
+ expect(references).toEqual([]);
+ });
+ });
+
+ describe('create', () => {
+ it('extracts the connector.id from a new create jira connector to the references', () => {
+ const { transformedActionDetails, references } = extractConnectorId({
+ action: 'create',
+ actionFields: ['connector'],
+ actionDetails: createConnectorObject(),
+ fieldType: UserActionFieldType.New,
+ });
+
+ const parsedJson = JSON.parse(transformedActionDetails!);
+
+ expect(parsedJson).not.toHaveProperty('id');
+ expect(parsedJson).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ },
+ }
+ `);
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+
+ it('extracts the connector.id from an old create jira connector to the references', () => {
+ const { transformedActionDetails, references } = extractConnectorId({
+ action: 'create',
+ actionFields: ['connector'],
+ actionDetails: createConnectorObject(),
+ fieldType: UserActionFieldType.Old,
+ });
+
+ const parsedJson = JSON.parse(transformedActionDetails!);
+
+ expect(parsedJson).not.toHaveProperty('id');
+ expect(parsedJson).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ },
+ }
+ `);
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "oldConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+ });
+
+ describe('update', () => {
+ it('extracts the connector.id from a new create jira connector to the references', () => {
+ const { transformedActionDetails, references } = extractConnectorId({
+ action: 'update',
+ actionFields: ['connector'],
+ actionDetails: createJiraConnector(),
+ fieldType: UserActionFieldType.New,
+ });
+
+ const parsedJson = JSON.parse(transformedActionDetails!);
+
+ expect(parsedJson).not.toHaveProperty('id');
+ expect(parsedJson).toMatchInlineSnapshot(`
+ Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ }
+ `);
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+
+ it('extracts the connector.id from an old create jira connector to the references', () => {
+ const { transformedActionDetails, references } = extractConnectorId({
+ action: 'update',
+ actionFields: ['connector'],
+ actionDetails: createJiraConnector(),
+ fieldType: UserActionFieldType.Old,
+ });
+
+ const parsedJson = JSON.parse(transformedActionDetails!);
+
+ expect(parsedJson).not.toHaveProperty('id');
+ expect(parsedJson).toMatchInlineSnapshot(`
+ Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ }
+ `);
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "oldConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+ });
+
+ describe('push action', () => {
+ it('returns the stringified json without the connector_id', () => {
+ const externalService = createExternalService();
+
+ const { transformedActionDetails } = extractConnectorId({
+ action: 'push-to-service',
+ actionFields: ['pushed'],
+ actionDetails: externalService,
+ fieldType: UserActionFieldType.New,
+ });
+
+ const transformedExternalService = JSON.parse(transformedActionDetails!);
+ expect(transformedExternalService).not.toHaveProperty('connector_id');
+ expect(transformedExternalService).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('returns a reference to the connector_id', () => {
+ const externalService = createExternalService();
+
+ const { references } = extractConnectorId({
+ action: 'push-to-service',
+ actionFields: ['pushed'],
+ actionDetails: externalService,
+ fieldType: UserActionFieldType.New,
+ })!;
+
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "100",
+ "name": "pushConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+
+ it('returns a reference to the old action details connector_id', () => {
+ const externalService = createExternalService();
+
+ const { references } = extractConnectorId({
+ action: 'push-to-service',
+ actionFields: ['pushed'],
+ actionDetails: externalService,
+ fieldType: UserActionFieldType.Old,
+ })!;
+
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "100",
+ "name": "oldPushConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+ });
+ });
+
+ describe('extractConnectorIdFromJson', () => {
+ describe('fails to extract the id', () => {
+ it('returns no references and null transformed json when action is undefined', () => {
+ expect(
+ extractConnectorIdFromJson({
+ actionFields: [],
+ actionDetails: undefined,
+ fieldType: UserActionFieldType.New,
+ })
+ ).toEqual({
+ transformedActionDetails: undefined,
+ references: [],
+ });
+ });
+
+ it('returns no references and undefined transformed json when actionFields is undefined', () => {
+ expect(
+ extractConnectorIdFromJson({ action: 'a', fieldType: UserActionFieldType.New })
+ ).toEqual({
+ transformedActionDetails: undefined,
+ references: [],
+ });
+ });
+
+ it('returns no references and undefined transformed json when actionDetails is undefined', () => {
+ expect(
+ extractConnectorIdFromJson({
+ action: 'a',
+ actionFields: [],
+ fieldType: UserActionFieldType.New,
+ })
+ ).toEqual({
+ transformedActionDetails: undefined,
+ references: [],
+ });
+ });
+
+ it('returns no references and undefined transformed json when actionDetails is null', () => {
+ expect(
+ extractConnectorIdFromJson({
+ action: 'a',
+ actionFields: [],
+ actionDetails: null,
+ fieldType: UserActionFieldType.New,
+ })
+ ).toEqual({
+ transformedActionDetails: null,
+ references: [],
+ });
+ });
+
+ it('throws an error when actionDetails is invalid json', () => {
+ expect(() =>
+ extractConnectorIdFromJson({
+ action: 'a',
+ actionFields: [],
+ actionDetails: '{a',
+ fieldType: UserActionFieldType.New,
+ })
+ ).toThrow();
+ });
+ });
+
+ describe('create action', () => {
+ it('returns the stringified json without the id', () => {
+ const jiraConnector = createConnectorObject();
+
+ const { transformedActionDetails } = extractConnectorIdFromJson({
+ action: 'create',
+ actionFields: ['connector'],
+ actionDetails: JSON.stringify(jiraConnector),
+ fieldType: UserActionFieldType.New,
+ });
+
+ expect(JSON.parse(transformedActionDetails!)).toMatchInlineSnapshot(`
+ Object {
+ "connector": Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ },
+ }
+ `);
+ });
+
+ it('returns a reference to the connector.id', () => {
+ const jiraConnector = createConnectorObject();
+
+ const { references } = extractConnectorIdFromJson({
+ action: 'create',
+ actionFields: ['connector'],
+ actionDetails: JSON.stringify(jiraConnector),
+ fieldType: UserActionFieldType.New,
+ })!;
+
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+ });
+
+ describe('update action', () => {
+ it('returns the stringified json without the id', () => {
+ const jiraConnector = createJiraConnector();
+
+ const { transformedActionDetails } = extractConnectorIdFromJson({
+ action: 'update',
+ actionFields: ['connector'],
+ actionDetails: JSON.stringify(jiraConnector),
+ fieldType: UserActionFieldType.New,
+ });
+
+ const transformedConnetor = JSON.parse(transformedActionDetails!);
+ expect(transformedConnetor).not.toHaveProperty('id');
+ expect(transformedConnetor).toMatchInlineSnapshot(`
+ Object {
+ "fields": Object {
+ "issueType": "bug",
+ "parent": "2",
+ "priority": "high",
+ },
+ "name": ".jira",
+ "type": ".jira",
+ }
+ `);
+ });
+
+ it('returns a reference to the connector.id', () => {
+ const jiraConnector = createJiraConnector();
+
+ const { references } = extractConnectorIdFromJson({
+ action: 'update',
+ actionFields: ['connector'],
+ actionDetails: JSON.stringify(jiraConnector),
+ fieldType: UserActionFieldType.New,
+ })!;
+
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "1",
+ "name": "connectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+ });
+
+ describe('push action', () => {
+ it('returns the stringified json without the connector_id', () => {
+ const externalService = createExternalService();
+
+ const { transformedActionDetails } = extractConnectorIdFromJson({
+ action: 'push-to-service',
+ actionFields: ['pushed'],
+ actionDetails: JSON.stringify(externalService),
+ fieldType: UserActionFieldType.New,
+ });
+
+ const transformedExternalService = JSON.parse(transformedActionDetails!);
+ expect(transformedExternalService).not.toHaveProperty('connector_id');
+ expect(transformedExternalService).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('returns a reference to the connector_id', () => {
+ const externalService = createExternalService();
+
+ const { references } = extractConnectorIdFromJson({
+ action: 'push-to-service',
+ actionFields: ['pushed'],
+ actionDetails: JSON.stringify(externalService),
+ fieldType: UserActionFieldType.New,
+ })!;
+
+ expect(references).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "id": "100",
+ "name": "pushConnectorId",
+ "type": "action",
+ },
+ ]
+ `);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/cases/server/services/user_actions/transform.ts b/x-pack/plugins/cases/server/services/user_actions/transform.ts
new file mode 100644
index 000000000000..93595374208a
--- /dev/null
+++ b/x-pack/plugins/cases/server/services/user_actions/transform.ts
@@ -0,0 +1,320 @@
+/*
+ * 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 * as rt from 'io-ts';
+import { isString } from 'lodash';
+
+import { SavedObjectReference } from '../../../../../../src/core/server';
+import {
+ CaseAttributes,
+ CaseConnector,
+ CaseConnectorRt,
+ CaseExternalServiceBasicRt,
+ isCreateConnector,
+ isPush,
+ isUpdateConnector,
+ noneConnectorId,
+} from '../../../common';
+import {
+ CONNECTOR_ID_REFERENCE_NAME,
+ getNoneCaseConnector,
+ PUSH_CONNECTOR_ID_REFERENCE_NAME,
+ USER_ACTION_OLD_ID_REF_NAME,
+ USER_ACTION_OLD_PUSH_ID_REF_NAME,
+} from '../../common';
+import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server';
+import { UserActionFieldType } from './types';
+
+/**
+ * Extracts the connector id from a json encoded string and formats it as a saved object reference. This will remove
+ * the field it extracted the connector id from.
+ */
+export function extractConnectorIdFromJson({
+ action,
+ actionFields,
+ actionDetails,
+ fieldType,
+}: {
+ action?: string;
+ actionFields?: string[];
+ actionDetails?: string | null;
+ fieldType: UserActionFieldType;
+}): { transformedActionDetails?: string | null; references: SavedObjectReference[] } {
+ if (!action || !actionFields || !actionDetails) {
+ return { transformedActionDetails: actionDetails, references: [] };
+ }
+
+ const decodedJson = JSON.parse(actionDetails);
+
+ return extractConnectorIdHelper({
+ action,
+ actionFields,
+ actionDetails: decodedJson,
+ fieldType,
+ });
+}
+
+/**
+ * Extracts the connector id from an unencoded object and formats it as a saved object reference.
+ * This will remove the field it extracted the connector id from.
+ */
+export function extractConnectorId({
+ action,
+ actionFields,
+ actionDetails,
+ fieldType,
+}: {
+ action: string;
+ actionFields: string[];
+ actionDetails?: Record | string | null;
+ fieldType: UserActionFieldType;
+}): {
+ transformedActionDetails?: string | null;
+ references: SavedObjectReference[];
+} {
+ if (!actionDetails || isString(actionDetails)) {
+ // the action was null, undefined, or a regular string so just return it unmodified and not encoded
+ return { transformedActionDetails: actionDetails, references: [] };
+ }
+
+ try {
+ return extractConnectorIdHelper({
+ action,
+ actionFields,
+ actionDetails,
+ fieldType,
+ });
+ } catch (error) {
+ return { transformedActionDetails: encodeActionDetails(actionDetails), references: [] };
+ }
+}
+
+function encodeActionDetails(actionDetails: Record): string | null {
+ try {
+ return JSON.stringify(actionDetails);
+ } catch (error) {
+ return null;
+ }
+}
+
+/**
+ * Internal helper function for extracting the connector id. This is only exported for usage in unit tests.
+ * This function handles encoding the transformed fields as a json string
+ */
+export function extractConnectorIdHelper({
+ action,
+ actionFields,
+ actionDetails,
+ fieldType,
+}: {
+ action: string;
+ actionFields: string[];
+ actionDetails: unknown;
+ fieldType: UserActionFieldType;
+}): { transformedActionDetails: string; references: SavedObjectReference[] } {
+ let transformedActionDetails: unknown = actionDetails;
+ let referencesToReturn: SavedObjectReference[] = [];
+
+ try {
+ if (isCreateCaseConnector(action, actionFields, actionDetails)) {
+ const { transformedActionDetails: transformedConnectorPortion, references } =
+ transformConnectorFromCreateAndUpdateAction(actionDetails.connector, fieldType);
+
+ // the above call only transforms the connector portion of the action details so let's add back
+ // the rest of the details and we'll overwrite the connector portion when the transformed one
+ transformedActionDetails = {
+ ...actionDetails,
+ ...transformedConnectorPortion,
+ };
+ referencesToReturn = references;
+ } else if (isUpdateCaseConnector(action, actionFields, actionDetails)) {
+ const {
+ transformedActionDetails: { connector: transformedConnector },
+ references,
+ } = transformConnectorFromCreateAndUpdateAction(actionDetails, fieldType);
+
+ transformedActionDetails = transformedConnector;
+ referencesToReturn = references;
+ } else if (isPushConnector(action, actionFields, actionDetails)) {
+ ({ transformedActionDetails, references: referencesToReturn } =
+ transformConnectorFromPushAction(actionDetails, fieldType));
+ }
+ } catch (error) {
+ // ignore any errors, we'll just return whatever was passed in for action details in that case
+ }
+
+ return {
+ transformedActionDetails: JSON.stringify(transformedActionDetails),
+ references: referencesToReturn,
+ };
+}
+
+function isCreateCaseConnector(
+ action: string,
+ actionFields: string[],
+ actionDetails: unknown
+): actionDetails is { connector: CaseConnector } {
+ try {
+ const unsafeCase = actionDetails as CaseAttributes;
+
+ return (
+ isCreateConnector(action, actionFields) &&
+ unsafeCase.connector !== undefined &&
+ CaseConnectorRt.is(unsafeCase.connector)
+ );
+ } catch {
+ return false;
+ }
+}
+
+export const ConnectorIdReferenceName: Record = {
+ [UserActionFieldType.New]: CONNECTOR_ID_REFERENCE_NAME,
+ [UserActionFieldType.Old]: USER_ACTION_OLD_ID_REF_NAME,
+};
+
+function transformConnectorFromCreateAndUpdateAction(
+ connector: CaseConnector,
+ fieldType: UserActionFieldType
+): {
+ transformedActionDetails: { connector: unknown };
+ references: SavedObjectReference[];
+} {
+ const { transformedConnector, references } = transformConnectorIdToReference(
+ ConnectorIdReferenceName[fieldType],
+ connector
+ );
+
+ return {
+ transformedActionDetails: transformedConnector,
+ references,
+ };
+}
+
+type ConnectorIdRefNameType =
+ | typeof CONNECTOR_ID_REFERENCE_NAME
+ | typeof USER_ACTION_OLD_ID_REF_NAME;
+
+export const transformConnectorIdToReference = (
+ referenceName: ConnectorIdRefNameType,
+ connector?: {
+ id?: string;
+ }
+): {
+ transformedConnector: { connector: unknown };
+ references: SavedObjectReference[];
+} => {
+ const { id: connectorId, ...restConnector } = connector ?? {};
+
+ const references = createConnectorReference(connectorId, ACTION_SAVED_OBJECT_TYPE, referenceName);
+
+ const { id: ignoreNoneId, ...restNoneConnector } = getNoneCaseConnector();
+ const connectorFieldsToReturn =
+ connector && isConnectorIdValid(connectorId) ? restConnector : restNoneConnector;
+
+ return {
+ transformedConnector: {
+ connector: connectorFieldsToReturn,
+ },
+ references,
+ };
+};
+
+const createConnectorReference = (
+ id: string | null | undefined,
+ type: string,
+ name: string
+): SavedObjectReference[] => {
+ return isConnectorIdValid(id)
+ ? [
+ {
+ id,
+ type,
+ name,
+ },
+ ]
+ : [];
+};
+
+const isConnectorIdValid = (id: string | null | undefined): id is string =>
+ id != null && id !== noneConnectorId;
+
+function isUpdateCaseConnector(
+ action: string,
+ actionFields: string[],
+ actionDetails: unknown
+): actionDetails is CaseConnector {
+ try {
+ return isUpdateConnector(action, actionFields) && CaseConnectorRt.is(actionDetails);
+ } catch {
+ return false;
+ }
+}
+
+type CaseExternalService = rt.TypeOf;
+
+function isPushConnector(
+ action: string,
+ actionFields: string[],
+ actionDetails: unknown
+): actionDetails is CaseExternalService {
+ try {
+ return isPush(action, actionFields) && CaseExternalServiceBasicRt.is(actionDetails);
+ } catch {
+ return false;
+ }
+}
+
+export const PushConnectorIdReferenceName: Record =
+ {
+ [UserActionFieldType.New]: PUSH_CONNECTOR_ID_REFERENCE_NAME,
+ [UserActionFieldType.Old]: USER_ACTION_OLD_PUSH_ID_REF_NAME,
+ };
+
+function transformConnectorFromPushAction(
+ externalService: CaseExternalService,
+ fieldType: UserActionFieldType
+): {
+ transformedActionDetails: {} | null;
+ references: SavedObjectReference[];
+} {
+ const { transformedPushConnector, references } = transformPushConnectorIdToReference(
+ PushConnectorIdReferenceName[fieldType],
+ externalService
+ );
+
+ return {
+ transformedActionDetails: transformedPushConnector.external_service,
+ references,
+ };
+}
+
+type PushConnectorIdRefNameType =
+ | typeof PUSH_CONNECTOR_ID_REFERENCE_NAME
+ | typeof USER_ACTION_OLD_PUSH_ID_REF_NAME;
+
+export const transformPushConnectorIdToReference = (
+ referenceName: PushConnectorIdRefNameType,
+ external_service?: { connector_id?: string | null } | null
+): {
+ transformedPushConnector: { external_service: {} | null };
+ references: SavedObjectReference[];
+} => {
+ const { connector_id: pushConnectorId, ...restExternalService } = external_service ?? {};
+
+ const references = createConnectorReference(
+ pushConnectorId,
+ ACTION_SAVED_OBJECT_TYPE,
+ referenceName
+ );
+
+ return {
+ transformedPushConnector: { external_service: external_service ? restExternalService : null },
+ references,
+ };
+};
diff --git a/x-pack/plugins/cases/server/services/user_actions/types.ts b/x-pack/plugins/cases/server/services/user_actions/types.ts
new file mode 100644
index 000000000000..3c67535255ec
--- /dev/null
+++ b/x-pack/plugins/cases/server/services/user_actions/types.ts
@@ -0,0 +1,14 @@
+/*
+ * 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.
+ */
+
+/**
+ * Indicates whether which user action field is being parsed, the new_value or the old_value.
+ */
+export enum UserActionFieldType {
+ New = 'New',
+ Old = 'Old',
+}
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts
index 49a09b8a7a3f..5ff153c3beb6 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts
@@ -321,26 +321,25 @@ export const ResultSettingsLogic = kea {
const fullInputs: FullAgentPolicyInput[] = [];
@@ -32,7 +33,7 @@ export const storedPackagePoliciesToAgentInputs = (
data_stream: {
namespace: packagePolicy.namespace || 'default',
},
- use_output: DEFAULT_OUTPUT.name,
+ use_output: outputId,
...(input.compiled_input || {}),
...(input.streams.length
? {
diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts
index 0deda3bf3265..bd970fc2cd83 100644
--- a/x-pack/plugins/fleet/common/types/index.ts
+++ b/x-pack/plugins/fleet/common/types/index.ts
@@ -8,7 +8,11 @@
export * from './models';
export * from './rest_spec';
-import type { PreconfiguredAgentPolicy, PreconfiguredPackage } from './models/preconfiguration';
+import type {
+ PreconfiguredAgentPolicy,
+ PreconfiguredPackage,
+ PreconfiguredOutput,
+} from './models/preconfiguration';
export interface FleetConfigType {
enabled: boolean;
@@ -26,6 +30,7 @@ export interface FleetConfigType {
};
agentPolicies?: PreconfiguredAgentPolicy[];
packages?: PreconfiguredPackage[];
+ outputs?: PreconfiguredOutput[];
agentIdVerificationEnabled?: boolean;
}
diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts
index f64467ca674f..3f9e43e72c51 100644
--- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts
+++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts
@@ -23,6 +23,8 @@ export interface NewAgentPolicy {
monitoring_enabled?: MonitoringType;
unenroll_timeout?: number;
is_preconfigured?: boolean;
+ data_output_id?: string;
+ monitoring_output_id?: string;
}
export interface AgentPolicy extends NewAgentPolicy {
@@ -71,12 +73,14 @@ export interface FullAgentPolicyOutputPermissions {
};
}
+export type FullAgentPolicyOutput = Pick & {
+ [key: string]: any;
+};
+
export interface FullAgentPolicy {
id: string;
outputs: {
- [key: string]: Pick & {
- [key: string]: any;
- };
+ [key: string]: FullAgentPolicyOutput;
};
output_permissions?: {
[output: string]: FullAgentPolicyOutputPermissions;
diff --git a/x-pack/plugins/fleet/common/types/models/output.ts b/x-pack/plugins/fleet/common/types/models/output.ts
index c1dc2a4b4e05..4f70460e89ff 100644
--- a/x-pack/plugins/fleet/common/types/models/output.ts
+++ b/x-pack/plugins/fleet/common/types/models/output.ts
@@ -17,11 +17,13 @@ export interface NewOutput {
hosts?: string[];
ca_sha256?: string;
api_key?: string;
- config?: Record;
config_yaml?: string;
+ is_preconfigured?: boolean;
}
-export type OutputSOAttributes = NewOutput;
+export type OutputSOAttributes = NewOutput & {
+ output_id?: string;
+};
export type Output = NewOutput & {
id: string;
diff --git a/x-pack/plugins/fleet/common/types/models/preconfiguration.ts b/x-pack/plugins/fleet/common/types/models/preconfiguration.ts
index 6087c910510c..17f9b946885b 100644
--- a/x-pack/plugins/fleet/common/types/models/preconfiguration.ts
+++ b/x-pack/plugins/fleet/common/types/models/preconfiguration.ts
@@ -11,6 +11,7 @@ import type {
NewPackagePolicyInput,
} from './package_policy';
import type { NewAgentPolicy } from './agent_policy';
+import type { Output } from './output';
export type InputsOverride = Partial & {
vars?: Array;
@@ -29,3 +30,7 @@ export interface PreconfiguredAgentPolicy extends Omit;
+
+export interface PreconfiguredOutput extends Omit {
+ config?: Record;
+}
diff --git a/x-pack/plugins/fleet/server/errors/utils.ts b/x-pack/plugins/fleet/server/errors/utils.ts
index 2eae04e05bd6..d58f82b94fcd 100644
--- a/x-pack/plugins/fleet/server/errors/utils.ts
+++ b/x-pack/plugins/fleet/server/errors/utils.ts
@@ -11,6 +11,6 @@ export function isESClientError(error: unknown): error is ResponseError {
return error instanceof ResponseError;
}
-export const isElasticsearchVersionConflictError = (error: Error): boolean => {
+export function isElasticsearchVersionConflictError(error: Error): boolean {
return isESClientError(error) && error.meta.statusCode === 409;
-};
+}
diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts
index 21cdf659f2f5..05ad8a9a9c83 100644
--- a/x-pack/plugins/fleet/server/index.ts
+++ b/x-pack/plugins/fleet/server/index.ts
@@ -9,7 +9,11 @@ import { schema } from '@kbn/config-schema';
import type { TypeOf } from '@kbn/config-schema';
import type { PluginConfigDescriptor, PluginInitializerContext } from 'src/core/server';
-import { PreconfiguredPackagesSchema, PreconfiguredAgentPoliciesSchema } from './types';
+import {
+ PreconfiguredPackagesSchema,
+ PreconfiguredAgentPoliciesSchema,
+ PreconfiguredOutputsSchema,
+} from './types';
import { FleetPlugin } from './plugin';
@@ -113,6 +117,7 @@ export const config: PluginConfigDescriptor = {
}),
packages: PreconfiguredPackagesSchema,
agentPolicies: PreconfiguredAgentPoliciesSchema,
+ outputs: PreconfiguredOutputsSchema,
agentIdVerificationEnabled: schema.boolean({ defaultValue: true }),
}),
};
diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts
index 5c117909432b..83188e004704 100644
--- a/x-pack/plugins/fleet/server/saved_objects/index.ts
+++ b/x-pack/plugins/fleet/server/saved_objects/index.ts
@@ -156,6 +156,8 @@ const getSavedObjectTypes = (
revision: { type: 'integer' },
monitoring_enabled: { type: 'keyword', index: false },
is_preconfigured: { type: 'keyword' },
+ data_output_id: { type: 'keyword' },
+ monitoring_output_id: { type: 'keyword' },
},
},
migrations: {
@@ -196,6 +198,7 @@ const getSavedObjectTypes = (
},
mappings: {
properties: {
+ output_id: { type: 'keyword', index: false },
name: { type: 'keyword' },
type: { type: 'keyword' },
is_default: { type: 'boolean' },
@@ -203,6 +206,7 @@ const getSavedObjectTypes = (
ca_sha256: { type: 'keyword', index: false },
config: { type: 'flattened' },
config_yaml: { type: 'text' },
+ is_preconfigured: { type: 'boolean', index: false },
},
},
migrations: {
diff --git a/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/full_agent_policy.test.ts.snap b/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/full_agent_policy.test.ts.snap
new file mode 100644
index 000000000000..970bccbafa63
--- /dev/null
+++ b/x-pack/plugins/fleet/server/services/agent_policies/__snapshots__/full_agent_policy.test.ts.snap
@@ -0,0 +1,292 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`getFullAgentPolicy should support a different data output 1`] = `
+Object {
+ "agent": Object {
+ "monitoring": Object {
+ "enabled": true,
+ "logs": false,
+ "metrics": true,
+ "namespace": "default",
+ "use_output": "default",
+ },
+ },
+ "fleet": Object {
+ "hosts": Array [
+ "http://fleetserver:8220",
+ ],
+ },
+ "id": "agent-policy",
+ "inputs": Array [],
+ "output_permissions": Object {
+ "data-output-id": Object {
+ "_elastic_agent_checks": Object {
+ "cluster": Array [
+ "monitor",
+ ],
+ },
+ "_fallback": Object {
+ "cluster": Array [
+ "monitor",
+ ],
+ "indices": Array [
+ Object {
+ "names": Array [
+ "logs-*",
+ "metrics-*",
+ "traces-*",
+ "synthetics-*",
+ ".logs-endpoint.diagnostic.collection-*",
+ ],
+ "privileges": Array [
+ "auto_configure",
+ "create_doc",
+ ],
+ },
+ ],
+ },
+ },
+ "default": Object {
+ "_elastic_agent_checks": Object {
+ "cluster": Array [
+ "monitor",
+ ],
+ "indices": Array [
+ Object {
+ "names": Array [
+ "metrics-elastic_agent-default",
+ "metrics-elastic_agent.elastic_agent-default",
+ "metrics-elastic_agent.apm_server-default",
+ "metrics-elastic_agent.filebeat-default",
+ "metrics-elastic_agent.fleet_server-default",
+ "metrics-elastic_agent.metricbeat-default",
+ "metrics-elastic_agent.osquerybeat-default",
+ "metrics-elastic_agent.packetbeat-default",
+ "metrics-elastic_agent.endpoint_security-default",
+ "metrics-elastic_agent.auditbeat-default",
+ "metrics-elastic_agent.heartbeat-default",
+ ],
+ "privileges": Array [
+ "auto_configure",
+ "create_doc",
+ ],
+ },
+ ],
+ },
+ },
+ },
+ "outputs": Object {
+ "data-output-id": Object {
+ "api_key": undefined,
+ "ca_sha256": undefined,
+ "hosts": Array [
+ "http://es-data.co:9201",
+ ],
+ "type": "elasticsearch",
+ },
+ "default": Object {
+ "api_key": undefined,
+ "ca_sha256": undefined,
+ "hosts": Array [
+ "http://127.0.0.1:9201",
+ ],
+ "type": "elasticsearch",
+ },
+ },
+ "revision": 1,
+}
+`;
+
+exports[`getFullAgentPolicy should support a different monitoring output 1`] = `
+Object {
+ "agent": Object {
+ "monitoring": Object {
+ "enabled": true,
+ "logs": false,
+ "metrics": true,
+ "namespace": "default",
+ "use_output": "monitoring-output-id",
+ },
+ },
+ "fleet": Object {
+ "hosts": Array [
+ "http://fleetserver:8220",
+ ],
+ },
+ "id": "agent-policy",
+ "inputs": Array [],
+ "output_permissions": Object {
+ "default": Object {
+ "_elastic_agent_checks": Object {
+ "cluster": Array [
+ "monitor",
+ ],
+ },
+ "_fallback": Object {
+ "cluster": Array [
+ "monitor",
+ ],
+ "indices": Array [
+ Object {
+ "names": Array [
+ "logs-*",
+ "metrics-*",
+ "traces-*",
+ "synthetics-*",
+ ".logs-endpoint.diagnostic.collection-*",
+ ],
+ "privileges": Array [
+ "auto_configure",
+ "create_doc",
+ ],
+ },
+ ],
+ },
+ },
+ "monitoring-output-id": Object {
+ "_elastic_agent_checks": Object {
+ "cluster": Array [
+ "monitor",
+ ],
+ "indices": Array [
+ Object {
+ "names": Array [
+ "metrics-elastic_agent-default",
+ "metrics-elastic_agent.elastic_agent-default",
+ "metrics-elastic_agent.apm_server-default",
+ "metrics-elastic_agent.filebeat-default",
+ "metrics-elastic_agent.fleet_server-default",
+ "metrics-elastic_agent.metricbeat-default",
+ "metrics-elastic_agent.osquerybeat-default",
+ "metrics-elastic_agent.packetbeat-default",
+ "metrics-elastic_agent.endpoint_security-default",
+ "metrics-elastic_agent.auditbeat-default",
+ "metrics-elastic_agent.heartbeat-default",
+ ],
+ "privileges": Array [
+ "auto_configure",
+ "create_doc",
+ ],
+ },
+ ],
+ },
+ },
+ },
+ "outputs": Object {
+ "default": Object {
+ "api_key": undefined,
+ "ca_sha256": undefined,
+ "hosts": Array [
+ "http://127.0.0.1:9201",
+ ],
+ "type": "elasticsearch",
+ },
+ "monitoring-output-id": Object {
+ "api_key": undefined,
+ "ca_sha256": undefined,
+ "hosts": Array [
+ "http://es-monitoring.co:9201",
+ ],
+ "type": "elasticsearch",
+ },
+ },
+ "revision": 1,
+}
+`;
+
+exports[`getFullAgentPolicy should support both different outputs for data and monitoring 1`] = `
+Object {
+ "agent": Object {
+ "monitoring": Object {
+ "enabled": true,
+ "logs": false,
+ "metrics": true,
+ "namespace": "default",
+ "use_output": "monitoring-output-id",
+ },
+ },
+ "fleet": Object {
+ "hosts": Array [
+ "http://fleetserver:8220",
+ ],
+ },
+ "id": "agent-policy",
+ "inputs": Array [],
+ "output_permissions": Object {
+ "data-output-id": Object {
+ "_elastic_agent_checks": Object {
+ "cluster": Array [
+ "monitor",
+ ],
+ },
+ "_fallback": Object {
+ "cluster": Array [
+ "monitor",
+ ],
+ "indices": Array [
+ Object {
+ "names": Array [
+ "logs-*",
+ "metrics-*",
+ "traces-*",
+ "synthetics-*",
+ ".logs-endpoint.diagnostic.collection-*",
+ ],
+ "privileges": Array [
+ "auto_configure",
+ "create_doc",
+ ],
+ },
+ ],
+ },
+ },
+ "monitoring-output-id": Object {
+ "_elastic_agent_checks": Object {
+ "cluster": Array [
+ "monitor",
+ ],
+ "indices": Array [
+ Object {
+ "names": Array [
+ "metrics-elastic_agent-default",
+ "metrics-elastic_agent.elastic_agent-default",
+ "metrics-elastic_agent.apm_server-default",
+ "metrics-elastic_agent.filebeat-default",
+ "metrics-elastic_agent.fleet_server-default",
+ "metrics-elastic_agent.metricbeat-default",
+ "metrics-elastic_agent.osquerybeat-default",
+ "metrics-elastic_agent.packetbeat-default",
+ "metrics-elastic_agent.endpoint_security-default",
+ "metrics-elastic_agent.auditbeat-default",
+ "metrics-elastic_agent.heartbeat-default",
+ ],
+ "privileges": Array [
+ "auto_configure",
+ "create_doc",
+ ],
+ },
+ ],
+ },
+ },
+ },
+ "outputs": Object {
+ "data-output-id": Object {
+ "api_key": undefined,
+ "ca_sha256": undefined,
+ "hosts": Array [
+ "http://es-data.co:9201",
+ ],
+ "type": "elasticsearch",
+ },
+ "monitoring-output-id": Object {
+ "api_key": undefined,
+ "ca_sha256": undefined,
+ "hosts": Array [
+ "http://es-monitoring.co:9201",
+ ],
+ "type": "elasticsearch",
+ },
+ },
+ "revision": 1,
+}
+`;
diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts
new file mode 100644
index 000000000000..8df1234982ee
--- /dev/null
+++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.test.ts
@@ -0,0 +1,256 @@
+/*
+ * 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 { savedObjectsClientMock } from 'src/core/server/mocks';
+
+import type { AgentPolicy, Output } from '../../types';
+
+import { agentPolicyService } from '../agent_policy';
+import { agentPolicyUpdateEventHandler } from '../agent_policy_update';
+
+import { getFullAgentPolicy } from './full_agent_policy';
+
+const mockedAgentPolicyService = agentPolicyService as jest.Mocked;
+
+function mockAgentPolicy(data: Partial) {
+ mockedAgentPolicyService.get.mockResolvedValue({
+ id: 'agent-policy',
+ status: 'active',
+ package_policies: [],
+ is_managed: false,
+ namespace: 'default',
+ revision: 1,
+ name: 'Policy',
+ updated_at: '2020-01-01',
+ updated_by: 'qwerty',
+ ...data,
+ });
+}
+
+jest.mock('../settings', () => {
+ return {
+ getSettings: () => {
+ return {
+ id: '93f74c0-e876-11ea-b7d3-8b2acec6f75c',
+ fleet_server_hosts: ['http://fleetserver:8220'],
+ };
+ },
+ };
+});
+
+jest.mock('../agent_policy');
+
+jest.mock('../output', () => {
+ return {
+ outputService: {
+ getDefaultOutputId: () => 'test-id',
+ get: (soClient: any, id: string): Output => {
+ switch (id) {
+ case 'data-output-id':
+ return {
+ id: 'data-output-id',
+ is_default: false,
+ name: 'Data output',
+ // @ts-ignore
+ type: 'elasticsearch',
+ hosts: ['http://es-data.co:9201'],
+ };
+ case 'monitoring-output-id':
+ return {
+ id: 'monitoring-output-id',
+ is_default: false,
+ name: 'Monitoring output',
+ // @ts-ignore
+ type: 'elasticsearch',
+ hosts: ['http://es-monitoring.co:9201'],
+ };
+ default:
+ return {
+ id: 'test-id',
+ is_default: true,
+ name: 'default',
+ // @ts-ignore
+ type: 'elasticsearch',
+ hosts: ['http://127.0.0.1:9201'],
+ };
+ }
+ },
+ },
+ };
+});
+
+jest.mock('../agent_policy_update');
+jest.mock('../agents');
+jest.mock('../package_policy');
+
+function getAgentPolicyUpdateMock() {
+ return agentPolicyUpdateEventHandler as unknown as jest.Mock<
+ typeof agentPolicyUpdateEventHandler
+ >;
+}
+
+describe('getFullAgentPolicy', () => {
+ beforeEach(() => {
+ getAgentPolicyUpdateMock().mockClear();
+ mockedAgentPolicyService.get.mockReset();
+ });
+
+ it('should return a policy without monitoring if monitoring is not enabled', async () => {
+ mockAgentPolicy({
+ revision: 1,
+ });
+ const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy');
+
+ expect(agentPolicy).toMatchObject({
+ id: 'agent-policy',
+ outputs: {
+ default: {
+ type: 'elasticsearch',
+ hosts: ['http://127.0.0.1:9201'],
+ ca_sha256: undefined,
+ api_key: undefined,
+ },
+ },
+ inputs: [],
+ revision: 1,
+ fleet: {
+ hosts: ['http://fleetserver:8220'],
+ },
+ agent: {
+ monitoring: {
+ enabled: false,
+ logs: false,
+ metrics: false,
+ },
+ },
+ });
+ });
+
+ it('should return a policy with monitoring if monitoring is enabled for logs', async () => {
+ mockAgentPolicy({
+ namespace: 'default',
+ revision: 1,
+ monitoring_enabled: ['logs'],
+ });
+ const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy');
+
+ expect(agentPolicy).toMatchObject({
+ id: 'agent-policy',
+ outputs: {
+ default: {
+ type: 'elasticsearch',
+ hosts: ['http://127.0.0.1:9201'],
+ ca_sha256: undefined,
+ api_key: undefined,
+ },
+ },
+ inputs: [],
+ revision: 1,
+ fleet: {
+ hosts: ['http://fleetserver:8220'],
+ },
+ agent: {
+ monitoring: {
+ namespace: 'default',
+ use_output: 'default',
+ enabled: true,
+ logs: true,
+ metrics: false,
+ },
+ },
+ });
+ });
+
+ it('should return a policy with monitoring if monitoring is enabled for metrics', async () => {
+ mockAgentPolicy({
+ namespace: 'default',
+ revision: 1,
+ monitoring_enabled: ['metrics'],
+ });
+ const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy');
+
+ expect(agentPolicy).toMatchObject({
+ id: 'agent-policy',
+ outputs: {
+ default: {
+ type: 'elasticsearch',
+ hosts: ['http://127.0.0.1:9201'],
+ ca_sha256: undefined,
+ api_key: undefined,
+ },
+ },
+ inputs: [],
+ revision: 1,
+ fleet: {
+ hosts: ['http://fleetserver:8220'],
+ },
+ agent: {
+ monitoring: {
+ namespace: 'default',
+ use_output: 'default',
+ enabled: true,
+ logs: false,
+ metrics: true,
+ },
+ },
+ });
+ });
+
+ it('should support a different monitoring output', async () => {
+ mockAgentPolicy({
+ namespace: 'default',
+ revision: 1,
+ monitoring_enabled: ['metrics'],
+ monitoring_output_id: 'monitoring-output-id',
+ });
+ const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy');
+
+ expect(agentPolicy).toMatchSnapshot();
+ });
+
+ it('should support a different data output', async () => {
+ mockAgentPolicy({
+ namespace: 'default',
+ revision: 1,
+ monitoring_enabled: ['metrics'],
+ data_output_id: 'data-output-id',
+ });
+ const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy');
+
+ expect(agentPolicy).toMatchSnapshot();
+ });
+
+ it('should support both different outputs for data and monitoring ', async () => {
+ mockAgentPolicy({
+ namespace: 'default',
+ revision: 1,
+ monitoring_enabled: ['metrics'],
+ data_output_id: 'data-output-id',
+ monitoring_output_id: 'monitoring-output-id',
+ });
+ const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy');
+
+ expect(agentPolicy).toMatchSnapshot();
+ });
+
+ it('should use "default" as the default policy id', async () => {
+ mockAgentPolicy({
+ id: 'policy',
+ status: 'active',
+ package_policies: [],
+ is_managed: false,
+ namespace: 'default',
+ revision: 1,
+ data_output_id: 'test-id',
+ monitoring_output_id: 'test-id',
+ });
+
+ const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy');
+
+ expect(agentPolicy?.outputs.default).toBeDefined();
+ });
+});
diff --git a/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts
new file mode 100644
index 000000000000..4e8b3a2c1952
--- /dev/null
+++ b/x-pack/plugins/fleet/server/services/agent_policies/full_agent_policy.ts
@@ -0,0 +1,229 @@
+/*
+ * 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 { SavedObjectsClientContract } from 'kibana/server';
+import { safeLoad } from 'js-yaml';
+
+import type {
+ FullAgentPolicy,
+ PackagePolicy,
+ Settings,
+ Output,
+ FullAgentPolicyOutput,
+} from '../../types';
+import { agentPolicyService } from '../agent_policy';
+import { outputService } from '../output';
+import {
+ storedPackagePoliciesToAgentPermissions,
+ DEFAULT_PERMISSIONS,
+} from '../package_policies_to_agent_permissions';
+import { storedPackagePoliciesToAgentInputs, dataTypes, outputType } from '../../../common';
+import type { FullAgentPolicyOutputPermissions } from '../../../common';
+import { getSettings } from '../settings';
+import { PACKAGE_POLICY_DEFAULT_INDEX_PRIVILEGES, DEFAULT_OUTPUT } from '../../constants';
+
+const MONITORING_DATASETS = [
+ 'elastic_agent',
+ 'elastic_agent.elastic_agent',
+ 'elastic_agent.apm_server',
+ 'elastic_agent.filebeat',
+ 'elastic_agent.fleet_server',
+ 'elastic_agent.metricbeat',
+ 'elastic_agent.osquerybeat',
+ 'elastic_agent.packetbeat',
+ 'elastic_agent.endpoint_security',
+ 'elastic_agent.auditbeat',
+ 'elastic_agent.heartbeat',
+];
+
+export async function getFullAgentPolicy(
+ soClient: SavedObjectsClientContract,
+ id: string,
+ options?: { standalone: boolean }
+): Promise {
+ let agentPolicy;
+ const standalone = options?.standalone;
+
+ try {
+ agentPolicy = await agentPolicyService.get(soClient, id);
+ } catch (err) {
+ if (!err.isBoom || err.output.statusCode !== 404) {
+ throw err;
+ }
+ }
+
+ if (!agentPolicy) {
+ return null;
+ }
+
+ const defaultOutputId = await outputService.getDefaultOutputId(soClient);
+ if (!defaultOutputId) {
+ throw new Error('Default output is not setup');
+ }
+
+ const dataOutputId = agentPolicy.data_output_id || defaultOutputId;
+ const monitoringOutputId = agentPolicy.monitoring_output_id || defaultOutputId;
+
+ const outputs = await Promise.all(
+ Array.from(new Set([dataOutputId, monitoringOutputId])).map((outputId) =>
+ outputService.get(soClient, outputId)
+ )
+ );
+
+ const dataOutput = outputs.find((output) => output.id === dataOutputId);
+ if (!dataOutput) {
+ throw new Error(`Data output not found ${dataOutputId}`);
+ }
+ const monitoringOutput = outputs.find((output) => output.id === monitoringOutputId);
+ if (!monitoringOutput) {
+ throw new Error(`Monitoring output not found ${monitoringOutputId}`);
+ }
+
+ const fullAgentPolicy: FullAgentPolicy = {
+ id: agentPolicy.id,
+ outputs: {
+ ...outputs.reduce((acc, output) => {
+ acc[getOutputIdForAgentPolicy(output)] = transformOutputToFullPolicyOutput(output);
+
+ return acc;
+ }, {}),
+ },
+ inputs: storedPackagePoliciesToAgentInputs(
+ agentPolicy.package_policies as PackagePolicy[],
+ getOutputIdForAgentPolicy(dataOutput)
+ ),
+ revision: agentPolicy.revision,
+ ...(agentPolicy.monitoring_enabled && agentPolicy.monitoring_enabled.length > 0
+ ? {
+ agent: {
+ monitoring: {
+ namespace: agentPolicy.namespace,
+ use_output: getOutputIdForAgentPolicy(monitoringOutput),
+ enabled: true,
+ logs: agentPolicy.monitoring_enabled.includes(dataTypes.Logs),
+ metrics: agentPolicy.monitoring_enabled.includes(dataTypes.Metrics),
+ },
+ },
+ }
+ : {
+ agent: {
+ monitoring: { enabled: false, logs: false, metrics: false },
+ },
+ }),
+ };
+
+ const dataPermissions = (await storedPackagePoliciesToAgentPermissions(
+ soClient,
+ agentPolicy.package_policies
+ )) || { _fallback: DEFAULT_PERMISSIONS };
+
+ dataPermissions._elastic_agent_checks = {
+ cluster: DEFAULT_PERMISSIONS.cluster,
+ };
+
+ // TODO: fetch this from the elastic agent package https://github.com/elastic/kibana/issues/107738
+ const monitoringNamespace = fullAgentPolicy.agent?.monitoring.namespace;
+ const monitoringPermissions: FullAgentPolicyOutputPermissions =
+ monitoringOutputId === dataOutputId
+ ? dataPermissions
+ : {
+ _elastic_agent_checks: {
+ cluster: DEFAULT_PERMISSIONS.cluster,
+ },
+ };
+ if (
+ fullAgentPolicy.agent?.monitoring.enabled &&
+ monitoringNamespace &&
+ monitoringOutput &&
+ monitoringOutput.type === outputType.Elasticsearch
+ ) {
+ let names: string[] = [];
+ if (fullAgentPolicy.agent.monitoring.logs) {
+ names = names.concat(
+ MONITORING_DATASETS.map((dataset) => `logs-${dataset}-${monitoringNamespace}`)
+ );
+ }
+ if (fullAgentPolicy.agent.monitoring.metrics) {
+ names = names.concat(
+ MONITORING_DATASETS.map((dataset) => `metrics-${dataset}-${monitoringNamespace}`)
+ );
+ }
+
+ monitoringPermissions._elastic_agent_checks.indices = [
+ {
+ names,
+ privileges: PACKAGE_POLICY_DEFAULT_INDEX_PRIVILEGES,
+ },
+ ];
+ }
+
+ // Only add permissions if output.type is "elasticsearch"
+ fullAgentPolicy.output_permissions = Object.keys(fullAgentPolicy.outputs).reduce<
+ NonNullable
+ >((outputPermissions, outputId) => {
+ const output = fullAgentPolicy.outputs[outputId];
+ if (output && output.type === outputType.Elasticsearch) {
+ outputPermissions[outputId] =
+ outputId === getOutputIdForAgentPolicy(dataOutput)
+ ? dataPermissions
+ : monitoringPermissions;
+ }
+ return outputPermissions;
+ }, {});
+
+ // only add settings if not in standalone
+ if (!standalone) {
+ let settings: Settings;
+ try {
+ settings = await getSettings(soClient);
+ } catch (error) {
+ throw new Error('Default settings is not setup');
+ }
+ if (settings.fleet_server_hosts && settings.fleet_server_hosts.length) {
+ fullAgentPolicy.fleet = {
+ hosts: settings.fleet_server_hosts,
+ };
+ }
+ }
+ return fullAgentPolicy;
+}
+
+function transformOutputToFullPolicyOutput(
+ output: Output,
+ standalone = false
+): FullAgentPolicyOutput {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ const { config_yaml, type, hosts, ca_sha256, api_key } = output;
+ const configJs = config_yaml ? safeLoad(config_yaml) : {};
+ const newOutput: FullAgentPolicyOutput = {
+ type,
+ hosts,
+ ca_sha256,
+ api_key,
+ ...configJs,
+ };
+
+ if (standalone) {
+ delete newOutput.api_key;
+ newOutput.username = 'ES_USERNAME';
+ newOutput.password = 'ES_PASSWORD';
+ }
+
+ return newOutput;
+}
+
+/**
+ * Get id used in full agent policy (sent to the agents)
+ * we use "default" for the default policy to avoid breaking changes
+ */
+function getOutputIdForAgentPolicy(output: Output) {
+ if (output.is_default) {
+ return DEFAULT_OUTPUT.name;
+ }
+
+ return output.id;
+}
diff --git a/x-pack/plugins/fleet/server/services/agent_policies/index.ts b/x-pack/plugins/fleet/server/services/agent_policies/index.ts
new file mode 100644
index 000000000000..b793ed26a08b
--- /dev/null
+++ b/x-pack/plugins/fleet/server/services/agent_policies/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export { getFullAgentPolicy } from './full_agent_policy';
diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts
index 59e0f6fd7840..6a5cb28dbaa0 100644
--- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts
+++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts
@@ -7,7 +7,7 @@
import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks';
-import type { AgentPolicy, NewAgentPolicy, Output } from '../types';
+import type { AgentPolicy, NewAgentPolicy } from '../types';
import { agentPolicyService } from './agent_policy';
import { agentPolicyUpdateEventHandler } from './agent_policy_update';
@@ -47,24 +47,6 @@ function getSavedObjectMock(agentPolicyAttributes: any) {
return mock;
}
-jest.mock('./output', () => {
- return {
- outputService: {
- getDefaultOutputId: () => 'test-id',
- get: (): Output => {
- return {
- id: 'test-id',
- is_default: true,
- name: 'default',
- // @ts-ignore
- type: 'elasticsearch',
- hosts: ['http://127.0.0.1:9201'],
- };
- },
- },
- };
-});
-
jest.mock('./agent_policy_update');
jest.mock('./agents');
jest.mock('./package_policy');
@@ -186,106 +168,17 @@ describe('agent policy', () => {
});
});
- describe('getFullAgentPolicy', () => {
- it('should return a policy without monitoring if monitoring is not enabled', async () => {
- const soClient = getSavedObjectMock({
- revision: 1,
- });
- const agentPolicy = await agentPolicyService.getFullAgentPolicy(soClient, 'agent-policy');
-
- expect(agentPolicy).toMatchObject({
- id: 'agent-policy',
- outputs: {
- default: {
- type: 'elasticsearch',
- hosts: ['http://127.0.0.1:9201'],
- ca_sha256: undefined,
- api_key: undefined,
- },
- },
- inputs: [],
- revision: 1,
- fleet: {
- hosts: ['http://fleetserver:8220'],
- },
- agent: {
- monitoring: {
- enabled: false,
- logs: false,
- metrics: false,
- },
- },
- });
- });
-
- it('should return a policy with monitoring if monitoring is enabled for logs', async () => {
- const soClient = getSavedObjectMock({
- namespace: 'default',
- revision: 1,
- monitoring_enabled: ['logs'],
- });
- const agentPolicy = await agentPolicyService.getFullAgentPolicy(soClient, 'agent-policy');
-
- expect(agentPolicy).toMatchObject({
- id: 'agent-policy',
- outputs: {
- default: {
- type: 'elasticsearch',
- hosts: ['http://127.0.0.1:9201'],
- ca_sha256: undefined,
- api_key: undefined,
- },
- },
- inputs: [],
- revision: 1,
- fleet: {
- hosts: ['http://fleetserver:8220'],
- },
- agent: {
- monitoring: {
- namespace: 'default',
- use_output: 'default',
- enabled: true,
- logs: true,
- metrics: false,
- },
- },
- });
- });
-
- it('should return a policy with monitoring if monitoring is enabled for metrics', async () => {
+ describe('bumpAllAgentPoliciesForOutput', () => {
+ it('should call agentPolicyUpdateEventHandler with updated event once', async () => {
const soClient = getSavedObjectMock({
- namespace: 'default',
revision: 1,
monitoring_enabled: ['metrics'],
});
- const agentPolicy = await agentPolicyService.getFullAgentPolicy(soClient, 'agent-policy');
+ const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
- expect(agentPolicy).toMatchObject({
- id: 'agent-policy',
- outputs: {
- default: {
- type: 'elasticsearch',
- hosts: ['http://127.0.0.1:9201'],
- ca_sha256: undefined,
- api_key: undefined,
- },
- },
- inputs: [],
- revision: 1,
- fleet: {
- hosts: ['http://fleetserver:8220'],
- },
- agent: {
- monitoring: {
- namespace: 'default',
- use_output: 'default',
- enabled: true,
- logs: false,
- metrics: true,
- },
- },
- });
+ await agentPolicyService.bumpAllAgentPoliciesForOutput(soClient, esClient, 'output-id-123');
+
+ expect(agentPolicyUpdateEventHandler).toHaveBeenCalledTimes(1);
});
});
diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts
index 38fb07754bdd..751e981cb808 100644
--- a/x-pack/plugins/fleet/server/services/agent_policy.ts
+++ b/x-pack/plugins/fleet/server/services/agent_policy.ts
@@ -6,7 +6,6 @@
*/
import { uniq, omit } from 'lodash';
-import { safeLoad } from 'js-yaml';
import uuid from 'uuid/v4';
import type {
ElasticsearchClient,
@@ -21,7 +20,6 @@ import {
AGENT_POLICY_SAVED_OBJECT_TYPE,
AGENT_SAVED_OBJECT_TYPE,
PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE,
- PACKAGE_POLICY_DEFAULT_INDEX_PRIVILEGES,
} from '../constants';
import type {
PackagePolicy,
@@ -33,52 +31,27 @@ import type {
ListWithKuery,
NewPackagePolicy,
} from '../types';
-import {
- agentPolicyStatuses,
- storedPackagePoliciesToAgentInputs,
- dataTypes,
- packageToPackagePolicy,
- AGENT_POLICY_INDEX,
-} from '../../common';
+import { agentPolicyStatuses, packageToPackagePolicy, AGENT_POLICY_INDEX } from '../../common';
import type {
DeleteAgentPolicyResponse,
- Settings,
FleetServerPolicy,
Installation,
Output,
DeletePackagePoliciesResponse,
} from '../../common';
import { AgentPolicyNameExistsError, HostedAgentPolicyRestrictionRelatedError } from '../errors';
-import {
- storedPackagePoliciesToAgentPermissions,
- DEFAULT_PERMISSIONS,
-} from '../services/package_policies_to_agent_permissions';
import { getPackageInfo } from './epm/packages';
import { getAgentsByKuery } from './agents';
import { packagePolicyService } from './package_policy';
import { outputService } from './output';
import { agentPolicyUpdateEventHandler } from './agent_policy_update';
-import { getSettings } from './settings';
import { normalizeKuery, escapeSearchQueryPhrase } from './saved_object';
import { appContextService } from './app_context';
+import { getFullAgentPolicy } from './agent_policies';
const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE;
-const MONITORING_DATASETS = [
- 'elastic_agent',
- 'elastic_agent.elastic_agent',
- 'elastic_agent.apm_server',
- 'elastic_agent.filebeat',
- 'elastic_agent.fleet_server',
- 'elastic_agent.metricbeat',
- 'elastic_agent.osquerybeat',
- 'elastic_agent.packetbeat',
- 'elastic_agent.endpoint_security',
- 'elastic_agent.auditbeat',
- 'elastic_agent.heartbeat',
-];
-
class AgentPolicyService {
private triggerAgentPolicyUpdatedEvent = async (
soClient: SavedObjectsClientContract,
@@ -472,6 +445,38 @@ class AgentPolicyService {
return res;
}
+ public async bumpAllAgentPoliciesForOutput(
+ soClient: SavedObjectsClientContract,
+ esClient: ElasticsearchClient,
+ outputId: string,
+ options?: { user?: AuthenticatedUser }
+ ): Promise> {
+ const currentPolicies = await soClient.find({
+ type: SAVED_OBJECT_TYPE,
+ fields: ['revision', 'data_output_id', 'monitoring_output_id'],
+ searchFields: ['data_output_id', 'monitoring_output_id'],
+ search: escapeSearchQueryPhrase(outputId),
+ });
+ const bumpedPolicies = currentPolicies.saved_objects.map((policy) => {
+ policy.attributes = {
+ ...policy.attributes,
+ revision: policy.attributes.revision + 1,
+ updated_at: new Date().toISOString(),
+ updated_by: options?.user ? options.user.username : 'system',
+ };
+ return policy;
+ });
+ const res = await soClient.bulkUpdate(bumpedPolicies);
+
+ await Promise.all(
+ currentPolicies.saved_objects.map((policy) =>
+ this.triggerAgentPolicyUpdatedEvent(soClient, esClient, 'updated', policy.id)
+ )
+ );
+
+ return res;
+ }
+
public async bumpAllAgentPolicies(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
@@ -724,139 +729,7 @@ class AgentPolicyService {
id: string,
options?: { standalone: boolean }
): Promise {
- let agentPolicy;
- const standalone = options?.standalone;
-
- try {
- agentPolicy = await this.get(soClient, id);
- } catch (err) {
- if (!err.isBoom || err.output.statusCode !== 404) {
- throw err;
- }
- }
-
- if (!agentPolicy) {
- return null;
- }
-
- const defaultOutputId = await outputService.getDefaultOutputId(soClient);
- if (!defaultOutputId) {
- throw new Error('Default output is not setup');
- }
- const defaultOutput = await outputService.get(soClient, defaultOutputId);
-
- const fullAgentPolicy: FullAgentPolicy = {
- id: agentPolicy.id,
- outputs: {
- // TEMPORARY as we only support a default output
- ...[defaultOutput].reduce(
- // eslint-disable-next-line @typescript-eslint/naming-convention
- (outputs, { config_yaml, name, type, hosts, ca_sha256, api_key }) => {
- const configJs = config_yaml ? safeLoad(config_yaml) : {};
- outputs[name] = {
- type,
- hosts,
- ca_sha256,
- api_key,
- ...configJs,
- };
-
- if (options?.standalone) {
- delete outputs[name].api_key;
- outputs[name].username = 'ES_USERNAME';
- outputs[name].password = 'ES_PASSWORD';
- }
-
- return outputs;
- },
- {}
- ),
- },
- inputs: storedPackagePoliciesToAgentInputs(agentPolicy.package_policies as PackagePolicy[]),
- revision: agentPolicy.revision,
- ...(agentPolicy.monitoring_enabled && agentPolicy.monitoring_enabled.length > 0
- ? {
- agent: {
- monitoring: {
- namespace: agentPolicy.namespace,
- use_output: defaultOutput.name,
- enabled: true,
- logs: agentPolicy.monitoring_enabled.includes(dataTypes.Logs),
- metrics: agentPolicy.monitoring_enabled.includes(dataTypes.Metrics),
- },
- },
- }
- : {
- agent: {
- monitoring: { enabled: false, logs: false, metrics: false },
- },
- }),
- };
-
- const permissions = (await storedPackagePoliciesToAgentPermissions(
- soClient,
- agentPolicy.package_policies
- )) || { _fallback: DEFAULT_PERMISSIONS };
-
- permissions._elastic_agent_checks = {
- cluster: DEFAULT_PERMISSIONS.cluster,
- };
-
- // TODO: fetch this from the elastic agent package
- const monitoringOutput = fullAgentPolicy.agent?.monitoring.use_output;
- const monitoringNamespace = fullAgentPolicy.agent?.monitoring.namespace;
- if (
- fullAgentPolicy.agent?.monitoring.enabled &&
- monitoringNamespace &&
- monitoringOutput &&
- fullAgentPolicy.outputs[monitoringOutput]?.type === 'elasticsearch'
- ) {
- let names: string[] = [];
- if (fullAgentPolicy.agent.monitoring.logs) {
- names = names.concat(
- MONITORING_DATASETS.map((dataset) => `logs-${dataset}-${monitoringNamespace}`)
- );
- }
- if (fullAgentPolicy.agent.monitoring.metrics) {
- names = names.concat(
- MONITORING_DATASETS.map((dataset) => `metrics-${dataset}-${monitoringNamespace}`)
- );
- }
-
- permissions._elastic_agent_checks.indices = [
- {
- names,
- privileges: PACKAGE_POLICY_DEFAULT_INDEX_PRIVILEGES,
- },
- ];
- }
-
- // Only add permissions if output.type is "elasticsearch"
- fullAgentPolicy.output_permissions = Object.keys(fullAgentPolicy.outputs).reduce<
- NonNullable
- >((outputPermissions, outputName) => {
- const output = fullAgentPolicy.outputs[outputName];
- if (output && output.type === 'elasticsearch') {
- outputPermissions[outputName] = permissions;
- }
- return outputPermissions;
- }, {});
-
- // only add settings if not in standalone
- if (!standalone) {
- let settings: Settings;
- try {
- settings = await getSettings(soClient);
- } catch (error) {
- throw new Error('Default settings is not setup');
- }
- if (settings.fleet_server_hosts && settings.fleet_server_hosts.length) {
- fullAgentPolicy.fleet = {
- hosts: settings.fleet_server_hosts,
- };
- }
- }
- return fullAgentPolicy;
+ return getFullAgentPolicy(soClient, id, options);
}
}
diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts
index 26e3955607ad..8103794fb080 100644
--- a/x-pack/plugins/fleet/server/services/output.test.ts
+++ b/x-pack/plugins/fleet/server/services/output.test.ts
@@ -5,8 +5,10 @@
* 2.0.
*/
-import { outputService } from './output';
+import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
+import type { OutputSOAttributes } from '../types';
+import { outputService, outputIdToUuid } from './output';
import { appContextService } from './app_context';
jest.mock('./app_context');
@@ -34,7 +36,97 @@ const CONFIG_WITHOUT_ES_HOSTS = {
},
};
+function getMockedSoClient() {
+ const soClient = savedObjectsClientMock.create();
+ soClient.get.mockImplementation(async (type: string, id: string) => {
+ switch (id) {
+ case outputIdToUuid('output-test'): {
+ return {
+ id: outputIdToUuid('output-test'),
+ type: 'ingest-outputs',
+ references: [],
+ attributes: {
+ output_id: 'output-test',
+ },
+ };
+ }
+ default:
+ throw new Error('not found');
+ }
+ });
+
+ return soClient;
+}
+
describe('Output Service', () => {
+ describe('create', () => {
+ it('work with a predefined id', async () => {
+ const soClient = getMockedSoClient();
+ soClient.create.mockResolvedValue({
+ id: outputIdToUuid('output-test'),
+ type: 'ingest-output',
+ attributes: {},
+ references: [],
+ });
+ await outputService.create(
+ soClient,
+ {
+ is_default: false,
+ name: 'Test',
+ type: 'elasticsearch',
+ },
+ { id: 'output-test' }
+ );
+
+ expect(soClient.create).toBeCalled();
+
+ // ID should always be the same for a predefined id
+ expect(soClient.create.mock.calls[0][2]?.id).toEqual(outputIdToUuid('output-test'));
+ expect((soClient.create.mock.calls[0][1] as OutputSOAttributes).output_id).toEqual(
+ 'output-test'
+ );
+ });
+ });
+
+ describe('get', () => {
+ it('work with a predefined id', async () => {
+ const soClient = getMockedSoClient();
+ const output = await outputService.get(soClient, 'output-test');
+
+ expect(soClient.get).toHaveBeenCalledWith('ingest-outputs', outputIdToUuid('output-test'));
+
+ expect(output.id).toEqual('output-test');
+ });
+ });
+
+ describe('getDefaultOutputId', () => {
+ it('work with a predefined id', async () => {
+ const soClient = getMockedSoClient();
+ soClient.find.mockResolvedValue({
+ page: 1,
+ per_page: 100,
+ total: 1,
+ saved_objects: [
+ {
+ id: outputIdToUuid('output-test'),
+ type: 'ingest-outputs',
+ references: [],
+ score: 0,
+ attributes: {
+ output_id: 'output-test',
+ is_default: true,
+ },
+ },
+ ],
+ });
+ const defaultId = await outputService.getDefaultOutputId(soClient);
+
+ expect(soClient.find).toHaveBeenCalled();
+
+ expect(defaultId).toEqual('output-test');
+ });
+ });
+
describe('getDefaultESHosts', () => {
afterEach(() => {
mockedAppContextService.getConfig.mockReset();
diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts
index 8c6bc7eca040..5a7ba1e2c122 100644
--- a/x-pack/plugins/fleet/server/services/output.ts
+++ b/x-pack/plugins/fleet/server/services/output.ts
@@ -5,7 +5,8 @@
* 2.0.
*/
-import type { SavedObjectsClientContract } from 'src/core/server';
+import type { SavedObject, SavedObjectsClientContract } from 'src/core/server';
+import uuid from 'uuid/v5';
import type { NewOutput, Output, OutputSOAttributes } from '../types';
import { DEFAULT_OUTPUT, OUTPUT_SAVED_OBJECT_TYPE } from '../constants';
@@ -17,8 +18,33 @@ const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE;
const DEFAULT_ES_HOSTS = ['http://localhost:9200'];
+// differentiate
+function isUUID(val: string) {
+ return (
+ typeof val === 'string' &&
+ val.match(/[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}/)
+ );
+}
+
+export function outputIdToUuid(id: string) {
+ if (isUUID(id)) {
+ return id;
+ }
+
+ // UUID v5 need a namespace (uuid.DNS), changing this params will result in loosing the ability to generate predicable uuid
+ return uuid(id, uuid.DNS);
+}
+
+function outputSavedObjectToOutput(so: SavedObject) {
+ const { output_id: outputId, ...atributes } = so.attributes;
+ return {
+ id: outputId ?? so.id,
+ ...atributes,
+ };
+}
+
class OutputService {
- public async getDefaultOutput(soClient: SavedObjectsClientContract) {
+ private async _getDefaultOutputsSO(soClient: SavedObjectsClientContract) {
return await soClient.find({
type: OUTPUT_SAVED_OBJECT_TYPE,
searchFields: ['is_default'],
@@ -27,7 +53,7 @@ class OutputService {
}
public async ensureDefaultOutput(soClient: SavedObjectsClientContract) {
- const outputs = await this.getDefaultOutput(soClient);
+ const outputs = await this._getDefaultOutputsSO(soClient);
if (!outputs.saved_objects.length) {
const newDefaultOutput = {
@@ -39,10 +65,7 @@ class OutputService {
return await this.create(soClient, newDefaultOutput);
}
- return {
- id: outputs.saved_objects[0].id,
- ...outputs.saved_objects[0].attributes,
- };
+ return outputSavedObjectToOutput(outputs.saved_objects[0]);
}
public getDefaultESHosts(): string[] {
@@ -60,49 +83,84 @@ class OutputService {
}
public async getDefaultOutputId(soClient: SavedObjectsClientContract) {
- const outputs = await this.getDefaultOutput(soClient);
+ const outputs = await this._getDefaultOutputsSO(soClient);
if (!outputs.saved_objects.length) {
return null;
}
- return outputs.saved_objects[0].id;
+ return outputSavedObjectToOutput(outputs.saved_objects[0]).id;
}
public async create(
soClient: SavedObjectsClientContract,
output: NewOutput,
- options?: { id?: string }
+ options?: { id?: string; overwrite?: boolean }
): Promise {
- const data = { ...output };
+ const data: OutputSOAttributes = { ...output };
+
+ // ensure only default output exists
+ if (data.is_default) {
+ const defaultOuput = await this.getDefaultOutputId(soClient);
+ if (defaultOuput) {
+ throw new Error(`A default output already exists (${defaultOuput})`);
+ }
+ }
if (data.hosts) {
data.hosts = data.hosts.map(normalizeHostsForAgents);
}
- const newSo = await soClient.create(
- SAVED_OBJECT_TYPE,
- data as Output,
- options
- );
+ if (options?.id) {
+ data.output_id = options?.id;
+ }
+
+ const newSo = await soClient.create(SAVED_OBJECT_TYPE, data, {
+ ...options,
+ id: options?.id ? outputIdToUuid(options.id) : undefined,
+ });
return {
- id: newSo.id,
+ id: options?.id ?? newSo.id,
...newSo.attributes,
};
}
+ public async bulkGet(
+ soClient: SavedObjectsClientContract,
+ ids: string[],
+ { ignoreNotFound = false } = { ignoreNotFound: true }
+ ) {
+ const res = await soClient.bulkGet(
+ ids.map((id) => ({ id: outputIdToUuid(id), type: SAVED_OBJECT_TYPE }))
+ );
+
+ return res.saved_objects
+ .map((so) => {
+ if (so.error) {
+ if (!ignoreNotFound || so.error.statusCode !== 404) {
+ throw so.error;
+ }
+ return undefined;
+ }
+
+ return outputSavedObjectToOutput(so);
+ })
+ .filter((output): output is Output => typeof output !== 'undefined');
+ }
+
public async get(soClient: SavedObjectsClientContract, id: string): Promise {
- const outputSO = await soClient.get(SAVED_OBJECT_TYPE, id);
+ const outputSO = await soClient.get(SAVED_OBJECT_TYPE, outputIdToUuid(id));
if (outputSO.error) {
throw new Error(outputSO.error.message);
}
- return {
- id: outputSO.id,
- ...outputSO.attributes,
- };
+ return outputSavedObjectToOutput(outputSO);
+ }
+
+ public async delete(soClient: SavedObjectsClientContract, id: string) {
+ return soClient.delete(SAVED_OBJECT_TYPE, outputIdToUuid(id));
}
public async update(soClient: SavedObjectsClientContract, id: string, data: Partial) {
@@ -111,8 +169,11 @@ class OutputService {
if (updateData.hosts) {
updateData.hosts = updateData.hosts.map(normalizeHostsForAgents);
}
-
- const outputSO = await soClient.update(SAVED_OBJECT_TYPE, id, updateData);
+ const outputSO = await soClient.update(
+ SAVED_OBJECT_TYPE,
+ outputIdToUuid(id),
+ updateData
+ );
if (outputSO.error) {
throw new Error(outputSO.error.message);
@@ -127,12 +188,7 @@ class OutputService {
});
return {
- items: outputs.saved_objects.map((outputSO) => {
- return {
- id: outputSO.id,
- ...outputSO.attributes,
- };
- }),
+ items: outputs.saved_objects.map(outputSavedObjectToOutput),
total: outputs.total,
page: 1,
perPage: 1000,
diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts
index 86fdd2f0aa80..43887bc2787f 100644
--- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts
+++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts
@@ -9,7 +9,7 @@ import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/serve
import { SavedObjectsErrorHelpers } from '../../../../../src/core/server';
-import type { PreconfiguredAgentPolicy } from '../../common/types';
+import type { PreconfiguredAgentPolicy, PreconfiguredOutput } from '../../common/types';
import type { AgentPolicy, NewPackagePolicy, Output } from '../types';
import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../constants';
@@ -19,9 +19,15 @@ import * as agentPolicy from './agent_policy';
import {
ensurePreconfiguredPackagesAndPolicies,
comparePreconfiguredPolicyToCurrent,
+ ensurePreconfiguredOutputs,
+ cleanPreconfiguredOutputs,
} from './preconfiguration';
+import { outputService } from './output';
jest.mock('./agent_policy_update');
+jest.mock('./output');
+
+const mockedOutputService = outputService as jest.Mocked;
const mockInstalledPackages = new Map();
const mockConfiguredPolicies = new Map();
@@ -156,12 +162,17 @@ jest.mock('./app_context', () => ({
}));
const spyAgentPolicyServiceUpdate = jest.spyOn(agentPolicy.agentPolicyService, 'update');
+const spyAgentPolicyServicBumpAllAgentPoliciesForOutput = jest.spyOn(
+ agentPolicy.agentPolicyService,
+ 'bumpAllAgentPoliciesForOutput'
+);
describe('policy preconfiguration', () => {
beforeEach(() => {
mockInstalledPackages.clear();
mockConfiguredPolicies.clear();
spyAgentPolicyServiceUpdate.mockClear();
+ spyAgentPolicyServicBumpAllAgentPoliciesForOutput.mockClear();
});
it('should perform a no-op when passed no policies or packages', async () => {
@@ -480,3 +491,168 @@ describe('comparePreconfiguredPolicyToCurrent', () => {
expect(hasChanged).toBe(false);
});
});
+
+describe('output preconfiguration', () => {
+ beforeEach(() => {
+ mockedOutputService.create.mockReset();
+ mockedOutputService.update.mockReset();
+ mockedOutputService.getDefaultESHosts.mockReturnValue(['http://default-es:9200']);
+ mockedOutputService.bulkGet.mockImplementation(async (soClient, id): Promise => {
+ return [
+ {
+ id: 'existing-output-1',
+ is_default: false,
+ name: 'Output 1',
+ // @ts-ignore
+ type: 'elasticsearch',
+ hosts: ['http://es.co:80'],
+ is_preconfigured: true,
+ },
+ ];
+ });
+ });
+
+ it('should create preconfigured output that does not exists', async () => {
+ const soClient = savedObjectsClientMock.create();
+ const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
+ await ensurePreconfiguredOutputs(soClient, esClient, [
+ {
+ id: 'non-existing-output-1',
+ name: 'Output 1',
+ type: 'elasticsearch',
+ is_default: false,
+ hosts: ['http://test.fr'],
+ },
+ ]);
+
+ expect(mockedOutputService.create).toBeCalled();
+ expect(mockedOutputService.update).not.toBeCalled();
+ expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).not.toBeCalled();
+ });
+
+ it('should set default hosts if hosts is not set output that does not exists', async () => {
+ const soClient = savedObjectsClientMock.create();
+ const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
+ await ensurePreconfiguredOutputs(soClient, esClient, [
+ {
+ id: 'non-existing-output-1',
+ name: 'Output 1',
+ type: 'elasticsearch',
+ is_default: false,
+ },
+ ]);
+
+ expect(mockedOutputService.create).toBeCalled();
+ expect(mockedOutputService.create.mock.calls[0][1].hosts).toEqual(['http://default-es:9200']);
+ });
+
+ it('should update output if preconfigured output exists and changed', async () => {
+ const soClient = savedObjectsClientMock.create();
+ const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
+ soClient.find.mockResolvedValue({ saved_objects: [], page: 0, per_page: 0, total: 0 });
+ await ensurePreconfiguredOutputs(soClient, esClient, [
+ {
+ id: 'existing-output-1',
+ is_default: false,
+ name: 'Output 1',
+ type: 'elasticsearch',
+ hosts: ['http://newhostichanged.co:9201'], // field that changed
+ },
+ ]);
+
+ expect(mockedOutputService.create).not.toBeCalled();
+ expect(mockedOutputService.update).toBeCalled();
+ expect(spyAgentPolicyServicBumpAllAgentPoliciesForOutput).toBeCalled();
+ });
+
+ const SCENARIOS: Array<{ name: string; data: PreconfiguredOutput }> = [
+ {
+ name: 'no changes',
+ data: {
+ id: 'existing-output-1',
+ is_default: false,
+ name: 'Output 1',
+ type: 'elasticsearch',
+ hosts: ['http://es.co:80'],
+ },
+ },
+ {
+ name: 'hosts without port',
+ data: {
+ id: 'existing-output-1',
+ is_default: false,
+ name: 'Output 1',
+ type: 'elasticsearch',
+ hosts: ['http://es.co'],
+ },
+ },
+ ];
+ SCENARIOS.forEach((scenario) => {
+ const { data, name } = scenario;
+ it(`should do nothing if preconfigured output exists and did not changed (${name})`, async () => {
+ const soClient = savedObjectsClientMock.create();
+ const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
+ await ensurePreconfiguredOutputs(soClient, esClient, [data]);
+
+ expect(mockedOutputService.create).not.toBeCalled();
+ expect(mockedOutputService.update).not.toBeCalled();
+ });
+ });
+
+ it('should not delete non deleted preconfigured output', async () => {
+ const soClient = savedObjectsClientMock.create();
+ mockedOutputService.list.mockResolvedValue({
+ items: [
+ { id: 'output1', is_preconfigured: true } as Output,
+ { id: 'output2', is_preconfigured: true } as Output,
+ ],
+ page: 1,
+ perPage: 10000,
+ total: 1,
+ });
+ await cleanPreconfiguredOutputs(soClient, [
+ {
+ id: 'output1',
+ is_default: false,
+ name: 'Output 1',
+ type: 'elasticsearch',
+ hosts: ['http://es.co:9201'],
+ },
+ {
+ id: 'output2',
+ is_default: false,
+ name: 'Output 2',
+ type: 'elasticsearch',
+ hosts: ['http://es.co:9201'],
+ },
+ ]);
+
+ expect(mockedOutputService.delete).not.toBeCalled();
+ });
+
+ it('should delete deleted preconfigured output', async () => {
+ const soClient = savedObjectsClientMock.create();
+ mockedOutputService.list.mockResolvedValue({
+ items: [
+ { id: 'output1', is_preconfigured: true } as Output,
+ { id: 'output2', is_preconfigured: true } as Output,
+ ],
+ page: 1,
+ perPage: 10000,
+ total: 1,
+ });
+ await cleanPreconfiguredOutputs(soClient, [
+ {
+ id: 'output1',
+ is_default: false,
+ name: 'Output 1',
+ type: 'elasticsearch',
+ hosts: ['http://es.co:9201'],
+ },
+ ]);
+
+ expect(mockedOutputService.delete).toBeCalled();
+ expect(mockedOutputService.delete).toBeCalledTimes(1);
+ expect(mockedOutputService.delete.mock.calls[0][1]).toEqual('output2');
+ });
+});
diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts
index 37ed98a6f4aa..30c5c27c6891 100644
--- a/x-pack/plugins/fleet/server/services/preconfiguration.ts
+++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts
@@ -8,6 +8,7 @@
import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server';
import { i18n } from '@kbn/i18n';
import { groupBy, omit, pick, isEqual } from 'lodash';
+import { safeDump } from 'js-yaml';
import type {
NewPackagePolicy,
@@ -17,16 +18,15 @@ import type {
PreconfiguredAgentPolicy,
PreconfiguredPackage,
PreconfigurationError,
+ PreconfiguredOutput,
} from '../../common';
-import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../common';
-
+import { AGENT_POLICY_SAVED_OBJECT_TYPE, normalizeHostsForAgents } from '../../common';
import {
PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE,
PRECONFIGURATION_LATEST_KEYWORD,
} from '../constants';
import { escapeSearchQueryPhrase } from './saved_object';
-
import { pkgToPkgKey } from './epm/registry';
import { getInstallation, getPackageInfo } from './epm/packages';
import { ensurePackagesCompletedInstall } from './epm/packages/install';
@@ -35,6 +35,7 @@ import { agentPolicyService, addPackageToAgentPolicy } from './agent_policy';
import type { InputsOverride } from './package_policy';
import { overridePackageInputs } from './package_policy';
import { appContextService } from './app_context';
+import { outputService } from './output';
interface PreconfigurationResult {
policies: Array<{ id: string; updated_at: string }>;
@@ -42,6 +43,89 @@ interface PreconfigurationResult {
nonFatalErrors: PreconfigurationError[];
}
+function isPreconfiguredOutputDifferentFromCurrent(
+ existingOutput: Output,
+ preconfiguredOutput: Partial
+): boolean {
+ return (
+ existingOutput.is_default !== preconfiguredOutput.is_default ||
+ existingOutput.name !== preconfiguredOutput.name ||
+ existingOutput.type !== preconfiguredOutput.type ||
+ (preconfiguredOutput.hosts &&
+ !isEqual(
+ existingOutput.hosts?.map(normalizeHostsForAgents),
+ preconfiguredOutput.hosts.map(normalizeHostsForAgents)
+ )) ||
+ existingOutput.ca_sha256 !== preconfiguredOutput.ca_sha256 ||
+ existingOutput.config_yaml !== preconfiguredOutput.config_yaml
+ );
+}
+
+export async function ensurePreconfiguredOutputs(
+ soClient: SavedObjectsClientContract,
+ esClient: ElasticsearchClient,
+ outputs: PreconfiguredOutput[]
+) {
+ if (outputs.length === 0) {
+ return;
+ }
+
+ const existingOutputs = await outputService.bulkGet(
+ soClient,
+ outputs.map(({ id }) => id),
+ { ignoreNotFound: true }
+ );
+
+ await Promise.all(
+ outputs.map(async (output) => {
+ const existingOutput = existingOutputs.find((o) => o.id === output.id);
+
+ const { id, config, ...outputData } = output;
+
+ const configYaml = config ? safeDump(config) : undefined;
+
+ const data = {
+ ...outputData,
+ config_yaml: configYaml,
+ is_preconfigured: true,
+ };
+
+ if (!data.hosts || data.hosts.length === 0) {
+ data.hosts = outputService.getDefaultESHosts();
+ }
+
+ if (!existingOutput) {
+ await outputService.create(soClient, data, { id, overwrite: true });
+ } else if (isPreconfiguredOutputDifferentFromCurrent(existingOutput, data)) {
+ await outputService.update(soClient, id, data);
+ // Bump revision of all policies using that output
+ if (outputData.is_default) {
+ await agentPolicyService.bumpAllAgentPolicies(soClient, esClient);
+ } else {
+ await agentPolicyService.bumpAllAgentPoliciesForOutput(soClient, esClient, id);
+ }
+ }
+ })
+ );
+}
+
+export async function cleanPreconfiguredOutputs(
+ soClient: SavedObjectsClientContract,
+ outputs: PreconfiguredOutput[]
+) {
+ const existingPreconfiguredOutput = (await outputService.list(soClient)).items.filter(
+ (o) => o.is_preconfigured === true
+ );
+ const logger = appContextService.getLogger();
+
+ for (const output of existingPreconfiguredOutput) {
+ if (!outputs.find(({ id }) => output.id === id)) {
+ logger.info(`Deleting preconfigured output ${output.id}`);
+ await outputService.delete(soClient, output.id);
+ }
+ }
+}
+
export async function ensurePreconfiguredPackagesAndPolicies(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
@@ -224,7 +308,7 @@ export async function ensurePreconfiguredPackagesAndPolicies(
}
// Add the is_managed flag after configuring package policies to avoid errors
if (shouldAddIsManagedFlag) {
- agentPolicyService.update(soClient, esClient, policy!.id, { is_managed: true });
+ await agentPolicyService.update(soClient, esClient, policy!.id, { is_managed: true });
}
}
}
diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts
index 1f3c3c5082b3..8c49bffdbf25 100644
--- a/x-pack/plugins/fleet/server/services/setup.ts
+++ b/x-pack/plugins/fleet/server/services/setup.ts
@@ -15,7 +15,11 @@ import { SO_SEARCH_LIMIT, DEFAULT_PACKAGES } from '../constants';
import { appContextService } from './app_context';
import { agentPolicyService } from './agent_policy';
-import { ensurePreconfiguredPackagesAndPolicies } from './preconfiguration';
+import {
+ cleanPreconfiguredOutputs,
+ ensurePreconfiguredOutputs,
+ ensurePreconfiguredPackagesAndPolicies,
+} from './preconfiguration';
import { outputService } from './output';
import { generateEnrollmentAPIKey, hasEnrollementAPIKeysForPolicy } from './api_keys';
@@ -45,23 +49,27 @@ async function createSetupSideEffects(
soClient: SavedObjectsClientContract,
esClient: ElasticsearchClient
): Promise {
- const [defaultOutput] = await Promise.all([
- outputService.ensureDefaultOutput(soClient),
+ const {
+ agentPolicies: policiesOrUndefined,
+ packages: packagesOrUndefined,
+ outputs: outputsOrUndefined,
+ } = appContextService.getConfig() ?? {};
+
+ const policies = policiesOrUndefined ?? [];
+ let packages = packagesOrUndefined ?? [];
+
+ await Promise.all([
+ ensurePreconfiguredOutputs(soClient, esClient, outputsOrUndefined ?? []),
settingsService.settingsSetup(soClient),
]);
+ const defaultOutput = await outputService.ensureDefaultOutput(soClient);
+
await awaitIfFleetServerSetupPending();
if (appContextService.getConfig()?.agentIdVerificationEnabled) {
await ensureFleetGlobalEsAssets(soClient, esClient);
}
- const { agentPolicies: policiesOrUndefined, packages: packagesOrUndefined } =
- appContextService.getConfig() ?? {};
-
- const policies = policiesOrUndefined ?? [];
-
- let packages = packagesOrUndefined ?? [];
-
// Ensure that required packages are always installed even if they're left out of the config
const preconfiguredPackageNames = new Set(packages.map((pkg) => pkg.name));
@@ -90,6 +98,8 @@ async function createSetupSideEffects(
defaultOutput
);
+ await cleanPreconfiguredOutputs(soClient, outputsOrUndefined ?? []);
+
await ensureDefaultEnrollmentAPIKeysExists(soClient, esClient);
await ensureAgentActionPolicyChangeExists(soClient, esClient);
diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx
index f686b969fd03..63e6c277ed71 100644
--- a/x-pack/plugins/fleet/server/types/index.tsx
+++ b/x-pack/plugins/fleet/server/types/index.tsx
@@ -27,6 +27,7 @@ export {
PackagePolicySOAttributes,
FullAgentPolicyInput,
FullAgentPolicy,
+ FullAgentPolicyOutput,
AgentPolicy,
AgentPolicySOAttributes,
NewAgentPolicy,
diff --git a/x-pack/plugins/fleet/server/types/models/preconfiguration.test.ts b/x-pack/plugins/fleet/server/types/models/preconfiguration.test.ts
new file mode 100644
index 000000000000..eb349e0d0f82
--- /dev/null
+++ b/x-pack/plugins/fleet/server/types/models/preconfiguration.test.ts
@@ -0,0 +1,81 @@
+/*
+ * 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 { PreconfiguredOutputsSchema, PreconfiguredAgentPoliciesSchema } from './preconfiguration';
+
+describe('Test preconfiguration schema', () => {
+ describe('PreconfiguredOutputsSchema', () => {
+ it('should not allow multiple default output', () => {
+ expect(() => {
+ PreconfiguredOutputsSchema.validate([
+ {
+ id: 'output-1',
+ name: 'Output 1',
+ type: 'elasticsearch',
+ is_default: true,
+ },
+ {
+ id: 'output-2',
+ name: 'Output 2',
+ type: 'elasticsearch',
+ is_default: true,
+ },
+ ]);
+ }).toThrowError('preconfigured outputs need to have only one default output.');
+ });
+ it('should not allow multiple output with same ids', () => {
+ expect(() => {
+ PreconfiguredOutputsSchema.validate([
+ {
+ id: 'nonuniqueid',
+ name: 'Output 1',
+ type: 'elasticsearch',
+ },
+ {
+ id: 'nonuniqueid',
+ name: 'Output 2',
+ type: 'elasticsearch',
+ },
+ ]);
+ }).toThrowError('preconfigured outputs need to have unique ids.');
+ });
+ it('should not allow multiple output with same names', () => {
+ expect(() => {
+ PreconfiguredOutputsSchema.validate([
+ {
+ id: 'output-1',
+ name: 'nonuniquename',
+ type: 'elasticsearch',
+ },
+ {
+ id: 'output-2',
+ name: 'nonuniquename',
+ type: 'elasticsearch',
+ },
+ ]);
+ }).toThrowError('preconfigured outputs need to have unique names.');
+ });
+ });
+
+ describe('PreconfiguredAgentPoliciesSchema', () => {
+ it('should not allow multiple outputs in one policy', () => {
+ expect(() => {
+ PreconfiguredAgentPoliciesSchema.validate([
+ {
+ id: 'policy-1',
+ name: 'Policy 1',
+ package_policies: [],
+ data_output_id: 'test1',
+ monitoring_output_id: 'test2',
+ },
+ ]);
+ }).toThrowError(
+ '[0]: Currently Fleet only support one output per agent policy data_output_id should be the same as monitoring_output_id.'
+ );
+ });
+ });
+});
diff --git a/x-pack/plugins/fleet/server/types/models/preconfiguration.ts b/x-pack/plugins/fleet/server/types/models/preconfiguration.ts
index 4ea9f086bda6..b65fa122911d 100644
--- a/x-pack/plugins/fleet/server/types/models/preconfiguration.ts
+++ b/x-pack/plugins/fleet/server/types/models/preconfiguration.ts
@@ -14,6 +14,8 @@ import {
DEFAULT_FLEET_SERVER_AGENT_POLICY,
DEFAULT_PACKAGES,
} from '../../constants';
+import type { PreconfiguredOutput } from '../../../common';
+import { outputType } from '../../../common';
import { AgentPolicyBaseSchema } from './agent_policy';
import { NamespaceSchema } from './package_policy';
@@ -47,47 +49,94 @@ export const PreconfiguredPackagesSchema = schema.arrayOf(
}
);
-export const PreconfiguredAgentPoliciesSchema = schema.arrayOf(
+function validatePreconfiguredOutputs(outputs: PreconfiguredOutput[]) {
+ const acc = { names: new Set(), ids: new Set(), is_default: false };
+
+ for (const output of outputs) {
+ if (acc.names.has(output.name)) {
+ return 'preconfigured outputs need to have unique names.';
+ }
+ if (acc.ids.has(output.id)) {
+ return 'preconfigured outputs need to have unique ids.';
+ }
+ if (acc.is_default && output.is_default) {
+ return 'preconfigured outputs need to have only one default output.';
+ }
+
+ acc.ids.add(output.id);
+ acc.names.add(output.name);
+ acc.is_default = acc.is_default || output.is_default;
+ }
+}
+
+export const PreconfiguredOutputsSchema = schema.arrayOf(
schema.object({
- ...AgentPolicyBaseSchema,
- namespace: schema.maybe(NamespaceSchema),
- id: schema.maybe(schema.oneOf([schema.string(), schema.number()])),
- is_default: schema.maybe(schema.boolean()),
- is_default_fleet_server: schema.maybe(schema.boolean()),
- package_policies: schema.arrayOf(
- schema.object({
- name: schema.string(),
- package: schema.object({
- name: schema.string(),
- }),
- description: schema.maybe(schema.string()),
- namespace: schema.maybe(NamespaceSchema),
- inputs: schema.maybe(
- schema.arrayOf(
- schema.object({
- type: schema.string(),
- enabled: schema.maybe(schema.boolean()),
- keep_enabled: schema.maybe(schema.boolean()),
- vars: varsSchema,
- streams: schema.maybe(
- schema.arrayOf(
- schema.object({
- data_stream: schema.object({
- type: schema.maybe(schema.string()),
- dataset: schema.string(),
- }),
- enabled: schema.maybe(schema.boolean()),
- keep_enabled: schema.maybe(schema.boolean()),
- vars: varsSchema,
- })
- )
- ),
- })
- )
- ),
- })
- ),
+ id: schema.string(),
+ is_default: schema.boolean({ defaultValue: false }),
+ name: schema.string(),
+ type: schema.oneOf([schema.literal(outputType.Elasticsearch)]),
+ hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))),
+ ca_sha256: schema.maybe(schema.string()),
+ config: schema.maybe(schema.object({}, { unknowns: 'allow' })),
}),
+ {
+ defaultValue: [],
+ validate: validatePreconfiguredOutputs,
+ }
+);
+
+export const PreconfiguredAgentPoliciesSchema = schema.arrayOf(
+ schema.object(
+ {
+ ...AgentPolicyBaseSchema,
+ namespace: schema.maybe(NamespaceSchema),
+ id: schema.maybe(schema.oneOf([schema.string(), schema.number()])),
+ is_default: schema.maybe(schema.boolean()),
+ is_default_fleet_server: schema.maybe(schema.boolean()),
+ data_output_id: schema.maybe(schema.string()),
+ monitoring_output_id: schema.maybe(schema.string()),
+ package_policies: schema.arrayOf(
+ schema.object({
+ name: schema.string(),
+ package: schema.object({
+ name: schema.string(),
+ }),
+ description: schema.maybe(schema.string()),
+ namespace: schema.maybe(NamespaceSchema),
+ inputs: schema.maybe(
+ schema.arrayOf(
+ schema.object({
+ type: schema.string(),
+ enabled: schema.maybe(schema.boolean()),
+ keep_enabled: schema.maybe(schema.boolean()),
+ vars: varsSchema,
+ streams: schema.maybe(
+ schema.arrayOf(
+ schema.object({
+ data_stream: schema.object({
+ type: schema.maybe(schema.string()),
+ dataset: schema.string(),
+ }),
+ enabled: schema.maybe(schema.boolean()),
+ keep_enabled: schema.maybe(schema.boolean()),
+ vars: varsSchema,
+ })
+ )
+ ),
+ })
+ )
+ ),
+ })
+ ),
+ },
+ {
+ validate: (policy) => {
+ if (policy.data_output_id !== policy.monitoring_output_id) {
+ return 'Currently Fleet only support one output per agent policy data_output_id should be the same as monitoring_output_id.';
+ }
+ },
+ }
+ ),
{
defaultValue: [DEFAULT_AGENT_POLICY, DEFAULT_FLEET_SERVER_AGENT_POLICY],
}
diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts
index 9a9273e43f6f..29b0fb1352e5 100644
--- a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts
+++ b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts
@@ -27,12 +27,18 @@ interface AxisConfig {
hide?: boolean;
}
-export type YAxisMode = 'auto' | 'left' | 'right';
+export type YAxisMode = 'auto' | 'left' | 'right' | 'bottom';
+export type LineStyle = 'solid' | 'dashed' | 'dotted';
+export type FillStyle = 'none' | 'above' | 'below';
export interface YConfig {
forAccessor: string;
axisMode?: YAxisMode;
color?: string;
+ icon?: string;
+ lineWidth?: number;
+ lineStyle?: LineStyle;
+ fill?: FillStyle;
}
export type AxisTitlesVisibilityConfigResult = AxesSettingsConfig & {
@@ -161,6 +167,24 @@ export const yAxisConfig: ExpressionFunctionDefinition<
types: ['string'],
help: 'The color of the series',
},
+ lineStyle: {
+ types: ['string'],
+ options: ['solid', 'dotted', 'dashed'],
+ help: 'The style of the threshold line',
+ },
+ lineWidth: {
+ types: ['number'],
+ help: 'The width of the threshold line',
+ },
+ icon: {
+ types: ['string'],
+ help: 'An optional icon used for threshold lines',
+ },
+ fill: {
+ types: ['string'],
+ options: ['none', 'above', 'below'],
+ help: '',
+ },
},
fn: function fn(input: unknown, args: YConfig) {
return {
diff --git a/x-pack/plugins/lens/public/assets/chart_bar_threshold.tsx b/x-pack/plugins/lens/public/assets/chart_bar_threshold.tsx
new file mode 100644
index 000000000000..88e0a46b5538
--- /dev/null
+++ b/x-pack/plugins/lens/public/assets/chart_bar_threshold.tsx
@@ -0,0 +1,40 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { EuiIconProps } from '@elastic/eui';
+
+export const LensIconChartBarThreshold = ({
+ title,
+ titleId,
+ ...props
+}: Omit) => (
+
+ {title ? {title} : null}
+
+
+
+
+
+);
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx
index 0259acc4dcca..69e4aa629cec 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import React, { useState } from 'react';
+import React, { useState, useMemo } from 'react';
import {
EuiToolTip,
EuiButton,
@@ -38,12 +38,17 @@ export function AddLayerButton({
}: AddLayerButtonProps) {
const [showLayersChoice, toggleLayersChoice] = useState(false);
- const hasMultipleLayers = Boolean(visualization.appendLayer && visualizationState);
- if (!hasMultipleLayers) {
+ const supportedLayers = useMemo(() => {
+ if (!visualization.appendLayer || !visualizationState) {
+ return null;
+ }
+ return visualization.getSupportedLayers?.(visualizationState, layersMeta);
+ }, [visualization, visualizationState, layersMeta]);
+
+ if (supportedLayers == null) {
return null;
}
- const supportedLayers = visualization.getSupportedLayers?.(visualizationState, layersMeta);
- if (supportedLayers?.length === 1) {
+ if (supportedLayers.length === 1) {
return (
new Promise((r) => setTimeout(r, time));
+
let container: HTMLDivElement | undefined;
beforeEach(() => {
@@ -137,7 +141,7 @@ describe('ConfigPanel', () => {
const updater = () => 'updated';
updateDatasource('mockindexpattern', updater);
- await new Promise((r) => setTimeout(r, 0));
+ await waitMs(0);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(
(lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater(
@@ -147,7 +151,7 @@ describe('ConfigPanel', () => {
updateAll('mockindexpattern', updater, props.visualizationState);
// wait for one tick so async updater has a chance to trigger
- await new Promise((r) => setTimeout(r, 0));
+ await waitMs(0);
expect(lensStore.dispatch).toHaveBeenCalledTimes(2);
expect(
(lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.updater(
@@ -293,4 +297,164 @@ describe('ConfigPanel', () => {
expect(focusedEl?.children[0].getAttribute('data-test-subj')).toEqual('lns-layerPanel-1');
});
});
+
+ describe('initial default value', () => {
+ function prepareAndMountComponent(props: ReturnType) {
+ (generateId as jest.Mock).mockReturnValue(`newId`);
+ return mountWithProvider(
+ ,
+
+ {
+ preloadedState: {
+ datasourceStates: {
+ mockindexpattern: {
+ isLoading: false,
+ state: 'state',
+ },
+ },
+ activeDatasourceId: 'mockindexpattern',
+ },
+ },
+ {
+ attachTo: container,
+ }
+ );
+ }
+ function clickToAddLayer(instance: ReactWrapper) {
+ act(() => {
+ instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click');
+ });
+ instance.update();
+ act(() => {
+ instance
+ .find(`[data-test-subj="lnsLayerAddButton-${layerTypes.THRESHOLD}"]`)
+ .first()
+ .simulate('click');
+ });
+ instance.update();
+
+ return waitMs(0);
+ }
+
+ function clickToAddDimension(instance: ReactWrapper) {
+ act(() => {
+ instance.find('[data-test-subj="lns-empty-dimension"]').last().simulate('click');
+ });
+ return waitMs(0);
+ }
+
+ it('should not add an initial dimension when not specified', async () => {
+ const props = getDefaultProps();
+ props.activeVisualization.getSupportedLayers = jest.fn(() => [
+ { type: layerTypes.DATA, label: 'Data Layer' },
+ {
+ type: layerTypes.THRESHOLD,
+ label: 'Threshold layer',
+ },
+ ]);
+ mockDatasource.initializeDimension = jest.fn();
+
+ const { instance, lensStore } = await prepareAndMountComponent(props);
+ await clickToAddLayer(instance);
+
+ expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
+ expect(mockDatasource.initializeDimension).not.toHaveBeenCalled();
+ });
+
+ it('should not add an initial dimension when initialDimensions are not available for the given layer type', async () => {
+ const props = getDefaultProps();
+ props.activeVisualization.getSupportedLayers = jest.fn(() => [
+ {
+ type: layerTypes.DATA,
+ label: 'Data Layer',
+ initialDimensions: [
+ {
+ groupId: 'testGroup',
+ columnId: 'myColumn',
+ dataType: 'number',
+ label: 'Initial value',
+ staticValue: 100,
+ },
+ ],
+ },
+ {
+ type: layerTypes.THRESHOLD,
+ label: 'Threshold layer',
+ },
+ ]);
+ mockDatasource.initializeDimension = jest.fn();
+
+ const { instance, lensStore } = await prepareAndMountComponent(props);
+ await clickToAddLayer(instance);
+
+ expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
+ expect(mockDatasource.initializeDimension).not.toHaveBeenCalled();
+ });
+
+ it('should use group initial dimension value when adding a new layer if available', async () => {
+ const props = getDefaultProps();
+ props.activeVisualization.getSupportedLayers = jest.fn(() => [
+ { type: layerTypes.DATA, label: 'Data Layer' },
+ {
+ type: layerTypes.THRESHOLD,
+ label: 'Threshold layer',
+ initialDimensions: [
+ {
+ groupId: 'testGroup',
+ columnId: 'myColumn',
+ dataType: 'number',
+ label: 'Initial value',
+ staticValue: 100,
+ },
+ ],
+ },
+ ]);
+ mockDatasource.initializeDimension = jest.fn();
+
+ const { instance, lensStore } = await prepareAndMountComponent(props);
+ await clickToAddLayer(instance);
+
+ expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
+ expect(mockDatasource.initializeDimension).toHaveBeenCalledWith(undefined, 'newId', {
+ columnId: 'myColumn',
+ dataType: 'number',
+ groupId: 'testGroup',
+ label: 'Initial value',
+ staticValue: 100,
+ });
+ });
+
+ it('should add an initial dimension value when clicking on the empty dimension button', async () => {
+ const props = getDefaultProps();
+ props.activeVisualization.getSupportedLayers = jest.fn(() => [
+ {
+ type: layerTypes.DATA,
+ label: 'Data Layer',
+ initialDimensions: [
+ {
+ groupId: 'a',
+ columnId: 'newId',
+ dataType: 'number',
+ label: 'Initial value',
+ staticValue: 100,
+ },
+ ],
+ },
+ ]);
+ mockDatasource.initializeDimension = jest.fn();
+
+ const { instance, lensStore } = await prepareAndMountComponent(props);
+
+ await clickToAddDimension(instance);
+ expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
+
+ expect(mockDatasource.initializeDimension).toHaveBeenCalledWith('state', 'first', {
+ groupId: 'a',
+ columnId: 'newId',
+ dataType: 'number',
+ label: 'Initial value',
+ staticValue: 100,
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx
index f7fe2beefa96..57e4cf5b8dff 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx
@@ -26,8 +26,9 @@ import {
useLensSelector,
selectVisualization,
VisualizationState,
+ LensAppState,
} from '../../../state_management';
-import { AddLayerButton } from './add_layer';
+import { AddLayerButton, getLayerType } from './add_layer';
export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) {
const visualization = useLensSelector(selectVisualization);
@@ -177,6 +178,33 @@ export function LayerPanels(
layerIds.length
) === 'clear'
}
+ onEmptyDimensionAdd={(columnId, { groupId }) => {
+ // avoid state update if the datasource does not support initializeDimension
+ if (
+ activeDatasourceId != null &&
+ datasourceMap[activeDatasourceId]?.initializeDimension
+ ) {
+ dispatchLens(
+ updateState({
+ subType: 'LAYER_DEFAULT_DIMENSION',
+ updater: (state) =>
+ addInitialValueIfAvailable({
+ ...props,
+ state,
+ activeDatasourceId,
+ layerId,
+ layerType: getLayerType(
+ activeVisualization,
+ state.visualization.state,
+ layerId
+ ),
+ columnId,
+ groupId,
+ }),
+ })
+ );
+ }
+ }}
onRemoveLayer={() => {
dispatchLens(
updateState({
@@ -232,21 +260,92 @@ export function LayerPanels(
dispatchLens(
updateState({
subType: 'ADD_LAYER',
- updater: (state) =>
- appendLayer({
+ updater: (state) => {
+ const newState = appendLayer({
activeVisualization,
generateId: () => id,
trackUiEvent,
activeDatasource: datasourceMap[activeDatasourceId!],
state,
layerType,
- }),
+ });
+ return addInitialValueIfAvailable({
+ ...props,
+ activeDatasourceId: activeDatasourceId!,
+ state: newState,
+ layerId: id,
+ layerType,
+ });
+ },
})
);
-
setNextFocusedLayerId(id);
}}
/>
);
}
+
+function addInitialValueIfAvailable({
+ state,
+ activeVisualization,
+ framePublicAPI,
+ layerType,
+ activeDatasourceId,
+ datasourceMap,
+ layerId,
+ columnId,
+ groupId,
+}: ConfigPanelWrapperProps & {
+ state: LensAppState;
+ activeDatasourceId: string;
+ activeVisualization: Visualization;
+ layerId: string;
+ layerType: string;
+ columnId?: string;
+ groupId?: string;
+}) {
+ const layerInfo = activeVisualization
+ .getSupportedLayers(state.visualization.state, framePublicAPI)
+ .find(({ type }) => type === layerType);
+
+ const activeDatasource = datasourceMap[activeDatasourceId];
+
+ if (layerInfo?.initialDimensions && activeDatasource?.initializeDimension) {
+ const info = groupId
+ ? layerInfo.initialDimensions.find(({ groupId: id }) => id === groupId)
+ : // pick the first available one if not passed
+ layerInfo.initialDimensions[0];
+
+ if (info) {
+ return {
+ ...state,
+ datasourceStates: {
+ ...state.datasourceStates,
+ [activeDatasourceId]: {
+ ...state.datasourceStates[activeDatasourceId],
+ state: activeDatasource.initializeDimension(
+ state.datasourceStates[activeDatasourceId].state,
+ layerId,
+ {
+ ...info,
+ columnId: columnId || info.columnId,
+ }
+ ),
+ },
+ },
+ visualization: {
+ ...state.visualization,
+ state: activeVisualization.setDimension({
+ groupId: info.groupId,
+ layerId,
+ columnId: columnId || info.columnId,
+ prevState: state.visualization.state,
+ frame: framePublicAPI,
+ }),
+ },
+ };
+ }
+ }
+ return state;
+}
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx
index 13b7b8cfecf5..f777fd0976df 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx
@@ -83,6 +83,7 @@ describe('LayerPanel', () => {
registerNewLayerRef: jest.fn(),
isFullscreen: false,
toggleFullscreen: jest.fn(),
+ onEmptyDimensionAdd: jest.fn(),
};
}
@@ -920,4 +921,33 @@ describe('LayerPanel', () => {
expect(updateVisualization).toHaveBeenCalledTimes(1);
});
});
+
+ describe('add a new dimension', () => {
+ it('should call onEmptyDimensionAdd callback on new dimension creation', async () => {
+ mockVisualization.getConfiguration.mockReturnValue({
+ groups: [
+ {
+ groupLabel: 'A',
+ groupId: 'a',
+ accessors: [],
+ filterOperations: () => true,
+ supportsMoreColumns: true,
+ dataTestSubj: 'lnsGroup',
+ },
+ ],
+ });
+ const props = getDefaultProps();
+ const { instance } = await mountWithProvider( );
+
+ act(() => {
+ instance.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
+ });
+ instance.update();
+
+ expect(props.onEmptyDimensionAdd).toHaveBeenCalledWith(
+ 'newid',
+ expect.objectContaining({ groupId: 'a' })
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
index 520c2bc837c6..8c947d3502f9 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx
@@ -57,6 +57,7 @@ export function LayerPanel(
onRemoveLayer: () => void;
registerNewLayerRef: (layerId: string, instance: HTMLDivElement | null) => void;
toggleFullscreen: () => void;
+ onEmptyDimensionAdd: (columnId: string, group: { groupId: string }) => void;
}
) {
const [activeDimension, setActiveDimension] = useState(
@@ -124,7 +125,11 @@ export function LayerPanel(
dateRange,
};
- const { groups, supportStaticValue } = useMemo(
+ const {
+ groups,
+ supportStaticValue,
+ supportFieldFormat = true,
+ } = useMemo(
() => activeVisualization.getConfiguration(layerVisualizationConfigProps),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -227,13 +232,25 @@ export function LayerPanel(
const isDimensionPanelOpen = Boolean(activeId);
const updateDataLayerState = useCallback(
- (newState: unknown, { isDimensionComplete = true }: { isDimensionComplete?: boolean } = {}) => {
+ (
+ newState: unknown,
+ {
+ isDimensionComplete = true,
+ // this flag is a hack to force a sync render where it was planned an async/setTimeout state update
+ // TODO: revisit this once we get rid of updateDatasourceAsync upstream
+ forceRender = false,
+ }: { isDimensionComplete?: boolean; forceRender?: boolean } = {}
+ ) => {
if (!activeGroup || !activeId) {
return;
}
if (allAccessors.includes(activeId)) {
if (isDimensionComplete) {
- updateDatasourceAsync(datasourceId, newState);
+ if (forceRender) {
+ updateDatasource(datasourceId, newState);
+ } else {
+ updateDatasourceAsync(datasourceId, newState);
+ }
} else {
// The datasource can indicate that the previously-valid column is no longer
// complete, which clears the visualization. This keeps the flyout open and reuses
@@ -263,7 +280,11 @@ export function LayerPanel(
);
setActiveDimension({ ...activeDimension, isNew: false });
} else {
- updateDatasourceAsync(datasourceId, newState);
+ if (forceRender) {
+ updateDatasource(datasourceId, newState);
+ } else {
+ updateDatasourceAsync(datasourceId, newState);
+ }
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -295,11 +316,10 @@ export function LayerPanel(
hasBorder
hasShadow
>
-
+
{groups.map((group, groupIndex) => {
const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0;
@@ -460,6 +480,8 @@ export function LayerPanel(
columnId: accessorConfig.columnId,
groupId: group.groupId,
filterOperations: group.filterOperations,
+ invalid: group.invalid,
+ invalidMessage: group.invalidMessage,
}}
/>
@@ -478,6 +500,7 @@ export function LayerPanel(
layerDatasource={layerDatasource}
layerDatasourceDropProps={layerDatasourceDropProps}
onClick={(id) => {
+ props.onEmptyDimensionAdd(id, group);
setActiveDimension({
activeGroup: group,
activeId: id,
@@ -538,6 +561,8 @@ export function LayerPanel(
toggleFullscreen,
isFullscreen,
setState: updateDataLayerState,
+ supportStaticValue: Boolean(supportStaticValue),
+ supportFieldFormat: Boolean(supportFieldFormat),
layerType: activeVisualization.getLayerType(layerId, visualizationState),
}}
/>
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.test.tsx
new file mode 100644
index 000000000000..04c430143a3c
--- /dev/null
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.test.tsx
@@ -0,0 +1,71 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import {
+ createMockFramePublicAPI,
+ createMockVisualization,
+ mountWithProvider,
+} from '../../../mocks';
+import { Visualization } from '../../../types';
+import { LayerSettings } from './layer_settings';
+
+describe('LayerSettings', () => {
+ let mockVisualization: jest.Mocked;
+ const frame = createMockFramePublicAPI();
+
+ function getDefaultProps() {
+ return {
+ activeVisualization: mockVisualization,
+ layerConfigProps: {
+ layerId: 'myLayer',
+ state: {},
+ frame,
+ dateRange: { fromDate: 'now-7d', toDate: 'now' },
+ activeData: frame.activeData,
+ setState: jest.fn(),
+ },
+ };
+ }
+
+ beforeEach(() => {
+ mockVisualization = {
+ ...createMockVisualization(),
+ id: 'testVis',
+ visualizationTypes: [
+ {
+ icon: 'empty',
+ id: 'testVis',
+ label: 'TEST1',
+ groupLabel: 'testVisGroup',
+ },
+ ],
+ };
+ });
+
+ it('should render nothing with no custom renderer nor description', async () => {
+ // @ts-expect-error
+ mockVisualization.getDescription.mockReturnValue(undefined);
+ const { instance } = await mountWithProvider( );
+ expect(instance.html()).toBe(null);
+ });
+
+ it('should render a static header if visualization has only a description value', async () => {
+ mockVisualization.getDescription.mockReturnValue({
+ icon: 'myIcon',
+ label: 'myVisualizationType',
+ });
+ const { instance } = await mountWithProvider( );
+ expect(instance.find('StaticHeader').first().prop('label')).toBe('myVisualizationType');
+ });
+
+ it('should call the custom renderer if available', async () => {
+ mockVisualization.renderLayerHeader = jest.fn();
+ await mountWithProvider( );
+ expect(mockVisualization.renderLayerHeader).toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx
index 467b1ecfe1b5..fc88ff2af8bb 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx
@@ -6,44 +6,23 @@
*/
import React from 'react';
-import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle } from '@elastic/eui';
import { NativeRenderer } from '../../../native_renderer';
import { Visualization, VisualizationLayerWidgetProps } from '../../../types';
+import { StaticHeader } from '../../../shared_components';
export function LayerSettings({
- layerId,
activeVisualization,
layerConfigProps,
}: {
- layerId: string;
activeVisualization: Visualization;
layerConfigProps: VisualizationLayerWidgetProps;
}) {
- const description = activeVisualization.getDescription(layerConfigProps.state);
-
if (!activeVisualization.renderLayerHeader) {
+ const description = activeVisualization.getDescription(layerConfigProps.state);
if (!description) {
return null;
}
- return (
-
- {description.icon && (
-
- {' '}
-
- )}
-
-
- {description.label}
-
-
-
- );
+ return ;
}
return (
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts
index 632989057b48..90fa2ab080dd 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts
@@ -45,21 +45,22 @@ describe('suggestion helpers', () => {
generateSuggestion(),
]);
const suggestedState = {};
- const suggestions = getSuggestions({
- visualizationMap: {
- vis1: {
- ...mockVisualization,
- getSuggestions: () => [
- {
- score: 0.5,
- title: 'Test',
- state: suggestedState,
- previewIcon: 'empty',
- },
- ],
- },
+ const visualizationMap = {
+ vis1: {
+ ...mockVisualization,
+ getSuggestions: () => [
+ {
+ score: 0.5,
+ title: 'Test',
+ state: suggestedState,
+ previewIcon: 'empty',
+ },
+ ],
},
- activeVisualizationId: 'vis1',
+ };
+ const suggestions = getSuggestions({
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@@ -74,38 +75,39 @@ describe('suggestion helpers', () => {
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
generateSuggestion(),
]);
- const suggestions = getSuggestions({
- visualizationMap: {
- vis1: {
- ...mockVisualization1,
- getSuggestions: () => [
- {
- score: 0.5,
- title: 'Test',
- state: {},
- previewIcon: 'empty',
- },
- {
- score: 0.5,
- title: 'Test2',
- state: {},
- previewIcon: 'empty',
- },
- ],
- },
- vis2: {
- ...mockVisualization2,
- getSuggestions: () => [
- {
- score: 0.5,
- title: 'Test3',
- state: {},
- previewIcon: 'empty',
- },
- ],
- },
+ const visualizationMap = {
+ vis1: {
+ ...mockVisualization1,
+ getSuggestions: () => [
+ {
+ score: 0.5,
+ title: 'Test',
+ state: {},
+ previewIcon: 'empty',
+ },
+ {
+ score: 0.5,
+ title: 'Test2',
+ state: {},
+ previewIcon: 'empty',
+ },
+ ],
+ },
+ vis2: {
+ ...mockVisualization2,
+ getSuggestions: () => [
+ {
+ score: 0.5,
+ title: 'Test3',
+ state: {},
+ previewIcon: 'empty',
+ },
+ ],
},
- activeVisualizationId: 'vis1',
+ };
+ const suggestions = getSuggestions({
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@@ -116,11 +118,12 @@ describe('suggestion helpers', () => {
it('should call getDatasourceSuggestionsForField when a field is passed', () => {
datasourceMap.mock.getDatasourceSuggestionsForField.mockReturnValue([generateSuggestion()]);
const droppedField = {};
+ const visualizationMap = {
+ vis1: createMockVisualization(),
+ };
getSuggestions({
- visualizationMap: {
- vis1: createMockVisualization(),
- },
- activeVisualizationId: 'vis1',
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@@ -128,7 +131,8 @@ describe('suggestion helpers', () => {
});
expect(datasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith(
datasourceStates.mock.state,
- droppedField
+ droppedField,
+ expect.any(Function)
);
});
@@ -148,12 +152,13 @@ describe('suggestion helpers', () => {
mock2: createMockDatasource('a'),
mock3: createMockDatasource('a'),
};
+ const visualizationMap = {
+ vis1: createMockVisualization(),
+ };
const droppedField = {};
getSuggestions({
- visualizationMap: {
- vis1: createMockVisualization(),
- },
- activeVisualizationId: 'vis1',
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap: multiDatasourceMap,
datasourceStates: multiDatasourceStates,
@@ -161,11 +166,13 @@ describe('suggestion helpers', () => {
});
expect(multiDatasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith(
multiDatasourceStates.mock.state,
- droppedField
+ droppedField,
+ expect.any(Function)
);
expect(multiDatasourceMap.mock2.getDatasourceSuggestionsForField).toHaveBeenCalledWith(
multiDatasourceStates.mock2.state,
- droppedField
+ droppedField,
+ expect.any(Function)
);
expect(multiDatasourceMap.mock3.getDatasourceSuggestionsForField).not.toHaveBeenCalled();
});
@@ -174,11 +181,14 @@ describe('suggestion helpers', () => {
datasourceMap.mock.getDatasourceSuggestionsForVisualizeField.mockReturnValue([
generateSuggestion(),
]);
+
+ const visualizationMap = {
+ vis1: createMockVisualization(),
+ };
+
getSuggestions({
- visualizationMap: {
- vis1: createMockVisualization(),
- },
- activeVisualizationId: 'vis1',
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@@ -214,11 +224,13 @@ describe('suggestion helpers', () => {
indexPatternId: '1',
fieldName: 'test',
};
+
+ const visualizationMap = {
+ vis1: createMockVisualization(),
+ };
getSuggestions({
- visualizationMap: {
- vis1: createMockVisualization(),
- },
- activeVisualizationId: 'vis1',
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap: multiDatasourceMap,
datasourceStates: multiDatasourceStates,
@@ -245,38 +257,39 @@ describe('suggestion helpers', () => {
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
generateSuggestion(),
]);
- const suggestions = getSuggestions({
- visualizationMap: {
- vis1: {
- ...mockVisualization1,
- getSuggestions: () => [
- {
- score: 0.2,
- title: 'Test',
- state: {},
- previewIcon: 'empty',
- },
- {
- score: 0.8,
- title: 'Test2',
- state: {},
- previewIcon: 'empty',
- },
- ],
- },
- vis2: {
- ...mockVisualization2,
- getSuggestions: () => [
- {
- score: 0.6,
- title: 'Test3',
- state: {},
- previewIcon: 'empty',
- },
- ],
- },
+ const visualizationMap = {
+ vis1: {
+ ...mockVisualization1,
+ getSuggestions: () => [
+ {
+ score: 0.2,
+ title: 'Test',
+ state: {},
+ previewIcon: 'empty',
+ },
+ {
+ score: 0.8,
+ title: 'Test2',
+ state: {},
+ previewIcon: 'empty',
+ },
+ ],
},
- activeVisualizationId: 'vis1',
+ vis2: {
+ ...mockVisualization2,
+ getSuggestions: () => [
+ {
+ score: 0.6,
+ title: 'Test3',
+ state: {},
+ previewIcon: 'empty',
+ },
+ ],
+ },
+ };
+ const suggestions = getSuggestions({
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@@ -305,12 +318,13 @@ describe('suggestion helpers', () => {
{ state: {}, table: table1, keptLayerIds: ['first'] },
{ state: {}, table: table2, keptLayerIds: ['first'] },
]);
+ const visualizationMap = {
+ vis1: mockVisualization1,
+ vis2: mockVisualization2,
+ };
getSuggestions({
- visualizationMap: {
- vis1: mockVisualization1,
- vis2: mockVisualization2,
- },
- activeVisualizationId: 'vis1',
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@@ -357,18 +371,20 @@ describe('suggestion helpers', () => {
previewIcon: 'empty',
},
]);
- const suggestions = getSuggestions({
- visualizationMap: {
- vis1: {
- ...mockVisualization1,
- getSuggestions: vis1Suggestions,
- },
- vis2: {
- ...mockVisualization2,
- getSuggestions: vis2Suggestions,
- },
+ const visualizationMap = {
+ vis1: {
+ ...mockVisualization1,
+ getSuggestions: vis1Suggestions,
},
- activeVisualizationId: 'vis1',
+ vis2: {
+ ...mockVisualization2,
+ getSuggestions: vis2Suggestions,
+ },
+ };
+
+ const suggestions = getSuggestions({
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@@ -389,12 +405,15 @@ describe('suggestion helpers', () => {
generateSuggestion(0),
generateSuggestion(1),
]);
+
+ const visualizationMap = {
+ vis1: mockVisualization1,
+ vis2: mockVisualization2,
+ };
+
getSuggestions({
- visualizationMap: {
- vis1: mockVisualization1,
- vis2: mockVisualization2,
- },
- activeVisualizationId: 'vis1',
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@@ -419,12 +438,13 @@ describe('suggestion helpers', () => {
generateSuggestion(0),
generateSuggestion(1),
]);
+ const visualizationMap = {
+ vis1: mockVisualization1,
+ vis2: mockVisualization2,
+ };
getSuggestions({
- visualizationMap: {
- vis1: mockVisualization1,
- vis2: mockVisualization2,
- },
- activeVisualizationId: 'vis1',
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@@ -451,12 +471,14 @@ describe('suggestion helpers', () => {
generateSuggestion(0),
generateSuggestion(1),
]);
+ const visualizationMap = {
+ vis1: mockVisualization1,
+ vis2: mockVisualization2,
+ };
+
getSuggestions({
- visualizationMap: {
- vis1: mockVisualization1,
- vis2: mockVisualization2,
- },
- activeVisualizationId: 'vis1',
+ visualizationMap,
+ activeVisualization: visualizationMap.vis1,
visualizationState: {},
datasourceMap,
datasourceStates,
@@ -538,7 +560,8 @@ describe('suggestion helpers', () => {
humanData: {
label: 'myfieldLabel',
},
- }
+ },
+ expect.any(Function)
);
});
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts
index 2f3fe3795a88..a5c7871f33df 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts
@@ -58,7 +58,7 @@ export function getSuggestions({
datasourceMap,
datasourceStates,
visualizationMap,
- activeVisualizationId,
+ activeVisualization,
subVisualizationId,
visualizationState,
field,
@@ -69,7 +69,7 @@ export function getSuggestions({
datasourceMap: DatasourceMap;
datasourceStates: DatasourceStates;
visualizationMap: VisualizationMap;
- activeVisualizationId: string | null;
+ activeVisualization?: Visualization;
subVisualizationId?: string;
visualizationState: unknown;
field?: unknown;
@@ -83,16 +83,12 @@ export function getSuggestions({
const layerTypesMap = datasources.reduce((memo, [datasourceId, datasource]) => {
const datasourceState = datasourceStates[datasourceId].state;
- if (!activeVisualizationId || !datasourceState || !visualizationMap[activeVisualizationId]) {
+ if (!activeVisualization || !datasourceState) {
return memo;
}
const layers = datasource.getLayers(datasourceState);
for (const layerId of layers) {
- const type = getLayerType(
- visualizationMap[activeVisualizationId],
- visualizationState,
- layerId
- );
+ const type = getLayerType(activeVisualization, visualizationState, layerId);
memo[layerId] = type;
}
return memo;
@@ -112,7 +108,11 @@ export function getSuggestions({
visualizeTriggerFieldContext.fieldName
);
} else if (field) {
- dataSourceSuggestions = datasource.getDatasourceSuggestionsForField(datasourceState, field);
+ dataSourceSuggestions = datasource.getDatasourceSuggestionsForField(
+ datasourceState,
+ field,
+ (layerId) => isLayerSupportedByVisualization(layerId, [layerTypes.DATA]) // a field dragged to workspace should added to data layer
+ );
} else {
dataSourceSuggestions = datasource.getDatasourceSuggestionsFromCurrentState(
datasourceState,
@@ -121,7 +121,6 @@ export function getSuggestions({
}
return dataSourceSuggestions.map((suggestion) => ({ ...suggestion, datasourceId }));
});
-
// Pass all table suggestions to all visualization extensions to get visualization suggestions
// and rank them by score
return Object.entries(visualizationMap)
@@ -139,12 +138,8 @@ export function getSuggestions({
.flatMap((datasourceSuggestion) => {
const table = datasourceSuggestion.table;
const currentVisualizationState =
- visualizationId === activeVisualizationId ? visualizationState : undefined;
- const palette =
- mainPalette ||
- (activeVisualizationId && visualizationMap[activeVisualizationId]?.getMainPalette
- ? visualizationMap[activeVisualizationId].getMainPalette?.(visualizationState)
- : undefined);
+ visualizationId === activeVisualization?.id ? visualizationState : undefined;
+ const palette = mainPalette || activeVisualization?.getMainPalette?.(visualizationState);
return getVisualizationSuggestions(
visualization,
@@ -169,14 +164,14 @@ export function getVisualizeFieldSuggestions({
datasourceMap,
datasourceStates,
visualizationMap,
- activeVisualizationId,
+ activeVisualization,
visualizationState,
visualizeTriggerFieldContext,
}: {
datasourceMap: DatasourceMap;
datasourceStates: DatasourceStates;
visualizationMap: VisualizationMap;
- activeVisualizationId: string | null;
+ activeVisualization: Visualization;
subVisualizationId?: string;
visualizationState: unknown;
visualizeTriggerFieldContext?: VisualizeFieldContext;
@@ -185,12 +180,12 @@ export function getVisualizeFieldSuggestions({
datasourceMap,
datasourceStates,
visualizationMap,
- activeVisualizationId,
+ activeVisualization,
visualizationState,
visualizeTriggerFieldContext,
});
if (suggestions.length) {
- return suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0];
+ return suggestions.find((s) => s.visualizationId === activeVisualization?.id) || suggestions[0];
}
}
@@ -263,18 +258,19 @@ export function getTopSuggestionForField(
(datasourceLayer) => datasourceLayer.getTableSpec().length > 0
);
- const mainPalette =
- visualization.activeId && visualizationMap[visualization.activeId]?.getMainPalette
- ? visualizationMap[visualization.activeId].getMainPalette?.(visualization.state)
- : undefined;
+ const activeVisualization = visualization.activeId
+ ? visualizationMap[visualization.activeId]
+ : undefined;
+
+ const mainPalette = activeVisualization?.getMainPalette?.(visualization.state);
const suggestions = getSuggestions({
datasourceMap: { [datasource.id]: datasource },
datasourceStates,
visualizationMap:
hasData && visualization.activeId
- ? { [visualization.activeId]: visualizationMap[visualization.activeId] }
+ ? { [visualization.activeId]: activeVisualization! }
: visualizationMap,
- activeVisualizationId: visualization.activeId,
+ activeVisualization,
visualizationState: visualization.state,
field,
mainPalette,
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx
index 858fcedf215e..5e5e19ea29e8 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx
@@ -201,7 +201,9 @@ export function SuggestionPanel({
datasourceMap,
datasourceStates: currentDatasourceStates,
visualizationMap,
- activeVisualizationId: currentVisualization.activeId,
+ activeVisualization: currentVisualization.activeId
+ ? visualizationMap[currentVisualization.activeId]
+ : undefined,
visualizationState: currentVisualization.state,
activeData,
})
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
index 28c0567d784e..51d4f2955a52 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx
@@ -515,11 +515,14 @@ function getTopSuggestion(
props.visualizationMap[visualization.activeId].getMainPalette
? props.visualizationMap[visualization.activeId].getMainPalette!(visualization.state)
: undefined;
+
const unfilteredSuggestions = getSuggestions({
datasourceMap: props.datasourceMap,
datasourceStates,
visualizationMap: { [visualizationId]: newVisualization },
- activeVisualizationId: visualization.activeId,
+ activeVisualization: visualization.activeId
+ ? props.visualizationMap[visualization.activeId]
+ : undefined,
visualizationState: visualization.state,
subVisualizationId,
activeData: props.framePublicAPI.activeData,
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx
index e386bac026fd..d25e6754fe03 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx
@@ -11,15 +11,11 @@ import { i18n } from '@kbn/i18n';
import {
EuiListGroup,
EuiFormRow,
- EuiFieldText,
EuiSpacer,
EuiListGroupItemProps,
EuiFormLabel,
EuiToolTip,
EuiText,
- EuiTabs,
- EuiTab,
- EuiCallOut,
} from '@elastic/eui';
import { IndexPatternDimensionEditorProps } from './dimension_panel';
import { OperationSupportMatrix } from './operation_support';
@@ -47,41 +43,29 @@ import { setTimeScaling, TimeScaling } from './time_scaling';
import { defaultFilter, Filtering, setFilter } from './filtering';
import { AdvancedOptions } from './advanced_options';
import { setTimeShift, TimeShift } from './time_shift';
-import { useDebouncedValue } from '../../shared_components';
+import { LayerType } from '../../../common';
+import {
+ quickFunctionsName,
+ staticValueOperationName,
+ isQuickFunction,
+ getParamEditor,
+ formulaOperationName,
+ DimensionEditorTabs,
+ CalloutWarning,
+ LabelInput,
+ getErrorMessage,
+} from './dimensions_editor_helpers';
+import type { TemporaryState } from './dimensions_editor_helpers';
const operationPanels = getOperationDisplay();
export interface DimensionEditorProps extends IndexPatternDimensionEditorProps {
selectedColumn?: IndexPatternColumn;
+ layerType: LayerType;
operationSupportMatrix: OperationSupportMatrix;
currentIndexPattern: IndexPattern;
}
-const LabelInput = ({ value, onChange }: { value: string; onChange: (value: string) => void }) => {
- const { inputValue, handleInputChange, initialValue } = useDebouncedValue({ onChange, value });
-
- return (
-
- {
- handleInputChange(e.target.value);
- }}
- placeholder={initialValue}
- />
-
- );
-};
-
export function DimensionEditor(props: DimensionEditorProps) {
const {
selectedColumn,
@@ -96,6 +80,8 @@ export function DimensionEditor(props: DimensionEditorProps) {
dimensionGroups,
toggleFullscreen,
isFullscreen,
+ supportStaticValue,
+ supportFieldFormat = true,
layerType,
} = props;
const services = {
@@ -110,6 +96,11 @@ export function DimensionEditor(props: DimensionEditorProps) {
const selectedOperationDefinition =
selectedColumn && operationDefinitionMap[selectedColumn.operationType];
+ const [temporaryState, setTemporaryState] = useState('none');
+
+ const temporaryQuickFunction = Boolean(temporaryState === quickFunctionsName);
+ const temporaryStaticValue = Boolean(temporaryState === staticValueOperationName);
+
const updateLayer = useCallback(
(newLayer) => setState((prevState) => mergeLayer({ state: prevState, layerId, newLayer })),
[layerId, setState]
@@ -141,9 +132,64 @@ export function DimensionEditor(props: DimensionEditorProps) {
...incompleteParams
} = incompleteInfo || {};
- const ParamEditor = selectedOperationDefinition?.paramEditor;
+ const isQuickFunctionSelected = Boolean(
+ supportStaticValue
+ ? selectedOperationDefinition && isQuickFunction(selectedOperationDefinition.type)
+ : !selectedOperationDefinition || isQuickFunction(selectedOperationDefinition.type)
+ );
+ const showQuickFunctions = temporaryQuickFunction || isQuickFunctionSelected;
+
+ const showStaticValueFunction =
+ temporaryStaticValue ||
+ (temporaryState === 'none' &&
+ supportStaticValue &&
+ (!selectedColumn || selectedColumn?.operationType === staticValueOperationName));
+
+ const addStaticValueColumn = (prevLayer = props.state.layers[props.layerId]) => {
+ if (selectedColumn?.operationType !== staticValueOperationName) {
+ trackUiEvent(`indexpattern_dimension_operation_static_value`);
+ return insertOrReplaceColumn({
+ layer: prevLayer,
+ indexPattern: currentIndexPattern,
+ columnId,
+ op: staticValueOperationName,
+ visualizationGroups: dimensionGroups,
+ });
+ }
+ return prevLayer;
+ };
+
+ // this function intercepts the state update for static value function
+ // and. if in temporary state, it merges the "add new static value column" state with the incoming
+ // changes from the static value operation (which has to be a function)
+ // Note: it forced a rerender at this point to avoid UI glitches in async updates (another hack upstream)
+ // TODO: revisit this once we get rid of updateDatasourceAsync upstream
+ const moveDefinetelyToStaticValueAndUpdate = (
+ setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer)
+ ) => {
+ if (temporaryStaticValue) {
+ setTemporaryState('none');
+ if (typeof setter === 'function') {
+ return setState(
+ (prevState) => {
+ const layer = setter(addStaticValueColumn(prevState.layers[layerId]));
+ return mergeLayer({ state: prevState, layerId, newLayer: layer });
+ },
+ {
+ isDimensionComplete: true,
+ forceRender: true,
+ }
+ );
+ }
+ }
+ return setStateWrapper(setter);
+ };
- const [temporaryQuickFunction, setQuickFunction] = useState(false);
+ const ParamEditor = getParamEditor(
+ temporaryStaticValue,
+ selectedOperationDefinition,
+ supportStaticValue && !showQuickFunctions
+ );
const possibleOperations = useMemo(() => {
return Object.values(operationDefinitionMap)
@@ -245,9 +291,9 @@ export function DimensionEditor(props: DimensionEditorProps) {
[`aria-pressed`]: isActive,
onClick() {
if (
- operationDefinitionMap[operationType].input === 'none' ||
- operationDefinitionMap[operationType].input === 'managedReference' ||
- operationDefinitionMap[operationType].input === 'fullReference'
+ ['none', 'fullReference', 'managedReference'].includes(
+ operationDefinitionMap[operationType].input
+ )
) {
// Clear invalid state because we are reseting to a valid column
if (selectedColumn?.operationType === operationType) {
@@ -264,9 +310,12 @@ export function DimensionEditor(props: DimensionEditorProps) {
visualizationGroups: dimensionGroups,
targetGroup: props.groupId,
});
- if (temporaryQuickFunction && newLayer.columns[columnId].operationType !== 'formula') {
+ if (
+ temporaryQuickFunction &&
+ isQuickFunction(newLayer.columns[columnId].operationType)
+ ) {
// Only switch the tab once the formula is fully removed
- setQuickFunction(false);
+ setTemporaryState('none');
}
setStateWrapper(newLayer);
trackUiEvent(`indexpattern_dimension_operation_${operationType}`);
@@ -297,9 +346,12 @@ export function DimensionEditor(props: DimensionEditorProps) {
});
// );
}
- if (temporaryQuickFunction && newLayer.columns[columnId].operationType !== 'formula') {
+ if (
+ temporaryQuickFunction &&
+ isQuickFunction(newLayer.columns[columnId].operationType)
+ ) {
// Only switch the tab once the formula is fully removed
- setQuickFunction(false);
+ setTemporaryState('none');
}
setStateWrapper(newLayer);
trackUiEvent(`indexpattern_dimension_operation_${operationType}`);
@@ -314,7 +366,7 @@ export function DimensionEditor(props: DimensionEditorProps) {
}
if (temporaryQuickFunction) {
- setQuickFunction(false);
+ setTemporaryState('none');
}
const newLayer = replaceColumn({
layer: props.state.layers[props.layerId],
@@ -348,29 +400,10 @@ export function DimensionEditor(props: DimensionEditorProps) {
!currentFieldIsInvalid &&
!incompleteInfo &&
selectedColumn &&
- selectedColumn.operationType !== 'formula';
+ isQuickFunction(selectedColumn.operationType);
const quickFunctions = (
<>
- {temporaryQuickFunction && selectedColumn?.operationType === 'formula' && (
- <>
-
-
- {i18n.translate('xpack.lens.indexPattern.formulaWarningText', {
- defaultMessage: 'To overwrite your formula, select a quick function',
- })}
-
-
- >
- )}
{i18n.translate('xpack.lens.indexPattern.functionsLabel', {
@@ -608,24 +641,28 @@ export function DimensionEditor(props: DimensionEditorProps) {
>
);
- const formulaTab = ParamEditor ? (
-
+ const customParamEditor = ParamEditor ? (
+ <>
+
+ >
) : null;
+ const TabContent = showQuickFunctions ? quickFunctions : customParamEditor;
+
const onFormatChange = useCallback(
(newFormat) => {
updateLayer(
@@ -640,58 +677,69 @@ export function DimensionEditor(props: DimensionEditorProps) {
[columnId, layerId, state.layers, updateLayer]
);
+ const hasFormula =
+ !isFullscreen && operationSupportMatrix.operationWithoutField.has(formulaOperationName);
+
+ const hasTabs = hasFormula || supportStaticValue;
+
return (
- {!isFullscreen && operationSupportMatrix.operationWithoutField.has('formula') ? (
-
- {
- if (selectedColumn?.operationType === 'formula') {
- setQuickFunction(true);
+ {hasTabs ? (
+ {
+ if (tabClicked === 'quickFunctions') {
+ if (selectedColumn && !isQuickFunction(selectedColumn.operationType)) {
+ setTemporaryState(quickFunctionsName);
+ return;
}
- }}
- >
- {i18n.translate('xpack.lens.indexPattern.quickFunctionsLabel', {
- defaultMessage: 'Quick functions',
- })}
-
- {
- if (selectedColumn?.operationType !== 'formula') {
- setQuickFunction(false);
+ }
+
+ if (tabClicked === 'static_value') {
+ // when coming from a formula, set a temporary state
+ if (selectedColumn?.operationType === formulaOperationName) {
+ return setTemporaryState(staticValueOperationName);
+ }
+ setTemporaryState('none');
+ setStateWrapper(addStaticValueColumn());
+ return;
+ }
+
+ if (tabClicked === 'formula') {
+ setTemporaryState('none');
+ if (selectedColumn?.operationType !== formulaOperationName) {
const newLayer = insertOrReplaceColumn({
layer: props.state.layers[props.layerId],
indexPattern: currentIndexPattern,
columnId,
- op: 'formula',
+ op: formulaOperationName,
visualizationGroups: dimensionGroups,
});
setStateWrapper(newLayer);
trackUiEvent(`indexpattern_dimension_operation_formula`);
- return;
- } else {
- setQuickFunction(false);
}
- }}
- >
- {i18n.translate('xpack.lens.indexPattern.formulaLabel', {
- defaultMessage: 'Formula',
- })}
-
-
+ }
+ }}
+ />
) : null}
- {isFullscreen
- ? formulaTab
- : selectedOperationDefinition?.type === 'formula' && !temporaryQuickFunction
- ? formulaTab
- : quickFunctions}
+
+ {TabContent}
- {!isFullscreen && !currentFieldIsInvalid && !temporaryQuickFunction && (
+ {!isFullscreen && !currentFieldIsInvalid && temporaryState === 'none' && (
{!incompleteInfo && selectedColumn && (
)}
- {!isFullscreen &&
+ {supportFieldFormat &&
+ !isFullscreen &&
selectedColumn &&
(selectedColumn.dataType === 'number' || selectedColumn.operationType === 'range') ? (
@@ -735,26 +784,3 @@ export function DimensionEditor(props: DimensionEditorProps) {
);
}
-
-function getErrorMessage(
- selectedColumn: IndexPatternColumn | undefined,
- incompleteOperation: boolean,
- input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined,
- fieldInvalid: boolean
-) {
- if (selectedColumn && incompleteOperation) {
- if (input === 'field') {
- return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', {
- defaultMessage: 'This field does not work with the selected function.',
- });
- }
- return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', {
- defaultMessage: 'To use this function, select a field.',
- });
- }
- if (fieldInvalid) {
- return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', {
- defaultMessage: 'Invalid field. Check your index pattern or pick another field.',
- });
- }
-}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx
index 5d56661f1591..d823def1da11 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx
@@ -52,6 +52,13 @@ jest.mock('lodash', () => {
};
});
jest.mock('../../id_generator');
+// Mock the Monaco Editor component
+jest.mock('../operations/definitions/formula/editor/formula_editor', () => {
+ return {
+ WrappedFormulaEditor: () =>
,
+ FormulaEditor: () =>
,
+ };
+});
const fields = [
{
@@ -211,6 +218,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
dimensionGroups: [],
groupId: 'a',
isFullscreen: false,
+ supportStaticValue: false,
toggleFullscreen: jest.fn(),
};
@@ -402,8 +410,9 @@ describe('IndexPatternDimensionEditorPanel', () => {
const items: EuiListGroupItemProps[] = wrapper.find(EuiListGroup).prop('listItems') || [];
- expect(items.find(({ id }) => id === 'math')).toBeUndefined();
- expect(items.find(({ id }) => id === 'formula')).toBeUndefined();
+ ['math', 'formula', 'static_value'].forEach((hiddenOp) => {
+ expect(items.some(({ id }) => id === hiddenOp)).toBe(false);
+ });
});
it('should indicate that reference-based operations are not compatible when they are incomplete', () => {
@@ -2217,4 +2226,130 @@ describe('IndexPatternDimensionEditorPanel', () => {
0
);
});
+
+ it('should not show tabs when formula and static_value operations are not available', () => {
+ const stateWithInvalidCol: IndexPatternPrivateState = getStateWithColumns({
+ col1: {
+ label: 'Average of memory',
+ dataType: 'number',
+ isBucketed: false,
+ // Private
+ operationType: 'average',
+ sourceField: 'memory',
+ params: {
+ format: { id: 'bytes', params: { decimals: 2 } },
+ },
+ },
+ });
+
+ const props = {
+ ...defaultProps,
+ filterOperations: jest.fn((op) => {
+ // the formula operation will fall into this metadata category
+ return !(op.dataType === 'number' && op.scale === 'ratio');
+ }),
+ };
+
+ wrapper = mount(
+
+ );
+
+ expect(wrapper.find('[data-test-subj="lens-dimensionTabs"]').exists()).toBeFalsy();
+ });
+
+ it('should show the formula tab when supported', () => {
+ const stateWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({
+ col1: {
+ label: 'Formula',
+ dataType: 'number',
+ isBucketed: false,
+ operationType: 'formula',
+ references: ['ref1'],
+ params: {},
+ },
+ });
+
+ wrapper = mount(
+
+ );
+
+ expect(
+ wrapper.find('[data-test-subj="lens-dimensionTabs-formula"]').first().prop('isSelected')
+ ).toBeTruthy();
+ });
+
+ it('should now show the static_value tab when not supported', () => {
+ const stateWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({
+ col1: {
+ label: 'Formula',
+ dataType: 'number',
+ isBucketed: false,
+ operationType: 'formula',
+ references: ['ref1'],
+ params: {},
+ },
+ });
+
+ wrapper = mount(
+
+ );
+
+ expect(wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').exists()).toBeFalsy();
+ });
+
+ it('should show the static value tab when supported', () => {
+ const staticWithFormulaColumn: IndexPatternPrivateState = getStateWithColumns({
+ col1: {
+ label: 'Formula',
+ dataType: 'number',
+ isBucketed: false,
+ operationType: 'formula',
+ references: ['ref1'],
+ params: {},
+ },
+ });
+
+ wrapper = mount(
+
+ );
+
+ expect(
+ wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').exists()
+ ).toBeTruthy();
+ });
+
+ it('should select the quick function tab by default', () => {
+ const stateWithNoColumn: IndexPatternPrivateState = getStateWithColumns({});
+
+ wrapper = mount(
+
+ );
+
+ expect(
+ wrapper
+ .find('[data-test-subj="lens-dimensionTabs-quickFunctions"]')
+ .first()
+ .prop('isSelected')
+ ).toBeTruthy();
+ });
+
+ it('should select the static value tab when supported by default', () => {
+ const stateWithNoColumn: IndexPatternPrivateState = getStateWithColumns({});
+
+ wrapper = mount(
+
+ );
+
+ expect(
+ wrapper.find('[data-test-subj="lens-dimensionTabs-static_value"]').first().prop('isSelected')
+ ).toBeTruthy();
+ });
});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx
index f3e51516d161..ac8296cca968 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx
@@ -16,7 +16,7 @@ import { IndexPatternColumn } from '../indexpattern';
import { isColumnInvalid } from '../utils';
import { IndexPatternPrivateState } from '../types';
import { DimensionEditor } from './dimension_editor';
-import type { DateRange } from '../../../common';
+import { DateRange, layerTypes } from '../../../common';
import { getOperationSupportMatrix } from './operation_support';
export type IndexPatternDimensionTriggerProps =
@@ -49,11 +49,11 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens
const layerId = props.layerId;
const layer = props.state.layers[layerId];
const currentIndexPattern = props.state.indexPatterns[layer.indexPatternId];
- const { columnId, uniqueLabel } = props;
+ const { columnId, uniqueLabel, invalid, invalidMessage } = props;
const currentColumnHasErrors = useMemo(
- () => isColumnInvalid(layer, columnId, currentIndexPattern),
- [layer, columnId, currentIndexPattern]
+ () => invalid || isColumnInvalid(layer, columnId, currentIndexPattern),
+ [layer, columnId, currentIndexPattern, invalid]
);
const selectedColumn: IndexPatternColumn | null = layer.columns[props.columnId] ?? null;
@@ -67,15 +67,17 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens
return (
- {i18n.translate('xpack.lens.configure.invalidConfigTooltip', {
- defaultMessage: 'Invalid configuration.',
- })}
-
- {i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', {
- defaultMessage: 'Click for more details.',
- })}
-
+ invalidMessage ?? (
+
+ {i18n.translate('xpack.lens.configure.invalidConfigTooltip', {
+ defaultMessage: 'Invalid configuration.',
+ })}
+
+ {i18n.translate('xpack.lens.configure.invalidConfigTooltipClick', {
+ defaultMessage: 'Click for more details.',
+ })}
+
+ )
}
anchorClassName="eui-displayBlock"
>
@@ -127,6 +129,7 @@ export const IndexPatternDimensionEditorComponent = function IndexPatternDimensi
return (
void;
+}) => {
+ const { inputValue, handleInputChange, initialValue } = useDebouncedValue({ onChange, value });
+
+ return (
+
+ {
+ handleInputChange(e.target.value);
+ }}
+ placeholder={initialValue}
+ />
+
+ );
+};
+
+export function getParamEditor(
+ temporaryStaticValue: boolean,
+ selectedOperationDefinition: typeof operationDefinitionMap[string] | undefined,
+ showDefaultStaticValue: boolean
+) {
+ if (temporaryStaticValue) {
+ return operationDefinitionMap[staticValueOperationName].paramEditor;
+ }
+ if (selectedOperationDefinition?.paramEditor) {
+ return selectedOperationDefinition.paramEditor;
+ }
+ if (showDefaultStaticValue) {
+ return operationDefinitionMap[staticValueOperationName].paramEditor;
+ }
+ return null;
+}
+
+export const CalloutWarning = ({
+ currentOperationType,
+ temporaryStateType,
+}: {
+ currentOperationType: keyof typeof operationDefinitionMap | undefined;
+ temporaryStateType: TemporaryState;
+}) => {
+ if (
+ temporaryStateType === 'none' ||
+ (currentOperationType != null && isQuickFunction(currentOperationType))
+ ) {
+ return null;
+ }
+ if (
+ currentOperationType === staticValueOperationName &&
+ temporaryStateType === 'quickFunctions'
+ ) {
+ return (
+ <>
+
+
+ {i18n.translate('xpack.lens.indexPattern.staticValueWarningText', {
+ defaultMessage: 'To overwrite your static value, select a quick function',
+ })}
+
+
+ >
+ );
+ }
+ return (
+ <>
+
+ {temporaryStateType !== 'quickFunctions' ? (
+
+ {i18n.translate('xpack.lens.indexPattern.formulaWarningStaticValueText', {
+ defaultMessage: 'To overwrite your formula, change the value in the input field',
+ })}
+
+ ) : (
+
+ {i18n.translate('xpack.lens.indexPattern.formulaWarningText', {
+ defaultMessage: 'To overwrite your formula, select a quick function',
+ })}
+
+ )}
+
+ >
+ );
+};
+
+type DimensionEditorTabsType =
+ | typeof quickFunctionsName
+ | typeof staticValueOperationName
+ | typeof formulaOperationName;
+
+export const DimensionEditorTabs = ({
+ tabsEnabled,
+ tabsState,
+ onClick,
+}: {
+ tabsEnabled: Record;
+ tabsState: Record;
+ onClick: (tabClicked: DimensionEditorTabsType) => void;
+}) => {
+ return (
+
+ {tabsEnabled.static_value ? (
+ onClick(staticValueOperationName)}
+ >
+ {i18n.translate('xpack.lens.indexPattern.staticValueLabel', {
+ defaultMessage: 'Static value',
+ })}
+
+ ) : null}
+ onClick(quickFunctionsName)}
+ >
+ {i18n.translate('xpack.lens.indexPattern.quickFunctionsLabel', {
+ defaultMessage: 'Quick functions',
+ })}
+
+ {tabsEnabled.formula ? (
+ onClick(formulaOperationName)}
+ >
+ {i18n.translate('xpack.lens.indexPattern.formulaLabel', {
+ defaultMessage: 'Formula',
+ })}
+
+ ) : null}
+
+ );
+};
+
+export function getErrorMessage(
+ selectedColumn: IndexPatternColumn | undefined,
+ incompleteOperation: boolean,
+ input: 'none' | 'field' | 'fullReference' | 'managedReference' | undefined,
+ fieldInvalid: boolean
+) {
+ if (selectedColumn && incompleteOperation) {
+ if (input === 'field') {
+ return i18n.translate('xpack.lens.indexPattern.invalidOperationLabel', {
+ defaultMessage: 'This field does not work with the selected function.',
+ });
+ }
+ return i18n.translate('xpack.lens.indexPattern.chooseFieldLabel', {
+ defaultMessage: 'To use this function, select a field.',
+ });
+ }
+ if (fieldInvalid) {
+ return i18n.translate('xpack.lens.indexPattern.invalidFieldLabel', {
+ defaultMessage: 'Invalid field. Check your index pattern or pick another field.',
+ });
+ }
+}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts
index 26aac5dab31e..85807721f80f 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts
@@ -17,6 +17,7 @@ import { OperationMetadata, DropType } from '../../../types';
import { IndexPatternColumn, MedianIndexPatternColumn } from '../../operations';
import { getFieldByNameFactory } from '../../pure_helpers';
import { generateId } from '../../../id_generator';
+import { layerTypes } from '../../../../common';
jest.mock('../../../id_generator');
@@ -263,7 +264,6 @@ describe('IndexPatternDimensionEditorPanel', () => {
dateRange: { fromDate: 'now-1d', toDate: 'now' },
columnId: 'col1',
layerId: 'first',
- layerType: 'data',
uniqueLabel: 'stuff',
groupId: 'group1',
filterOperations: () => true,
@@ -287,6 +287,8 @@ describe('IndexPatternDimensionEditorPanel', () => {
dimensionGroups: [],
isFullscreen: false,
toggleFullscreen: () => {},
+ supportStaticValue: false,
+ layerType: layerTypes.DATA,
};
jest.clearAllMocks();
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts
index e09c3e904f53..b518f667a0bf 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts
@@ -121,8 +121,12 @@ function onMoveCompatible(
indexPattern,
});
- let updatedColumnOrder = getColumnOrder(modifiedLayer);
- updatedColumnOrder = reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId);
+ const updatedColumnOrder = reorderByGroups(
+ dimensionGroups,
+ groupId,
+ getColumnOrder(modifiedLayer),
+ columnId
+ );
// Time to replace
setState(
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
index 06c8a50cd2df..1dfc7d40f6f3 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts
@@ -1623,4 +1623,87 @@ describe('IndexPattern Data Source', () => {
expect(indexPatternDatasource.isTimeBased(state)).toEqual(false);
});
});
+
+ describe('#initializeDimension', () => {
+ it('should return the same state if no static value is passed', () => {
+ const state = enrichBaseState({
+ currentIndexPatternId: '1',
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['metric'],
+ columns: {
+ metric: {
+ label: 'Count of records',
+ dataType: 'number',
+ isBucketed: false,
+ sourceField: 'Records',
+ operationType: 'count',
+ },
+ },
+ },
+ },
+ });
+ expect(
+ indexPatternDatasource.initializeDimension!(state, 'first', {
+ columnId: 'newStatic',
+ label: 'MyNewColumn',
+ groupId: 'a',
+ dataType: 'number',
+ })
+ ).toBe(state);
+ });
+
+ it('should add a new static value column if a static value is passed', () => {
+ const state = enrichBaseState({
+ currentIndexPatternId: '1',
+ layers: {
+ first: {
+ indexPatternId: '1',
+ columnOrder: ['metric'],
+ columns: {
+ metric: {
+ label: 'Count of records',
+ dataType: 'number',
+ isBucketed: false,
+ sourceField: 'Records',
+ operationType: 'count',
+ },
+ },
+ },
+ },
+ });
+ expect(
+ indexPatternDatasource.initializeDimension!(state, 'first', {
+ columnId: 'newStatic',
+ label: 'MyNewColumn',
+ groupId: 'a',
+ dataType: 'number',
+ staticValue: 0, // use a falsy value to check also this corner case
+ })
+ ).toEqual({
+ ...state,
+ layers: {
+ ...state.layers,
+ first: {
+ ...state.layers.first,
+ incompleteColumns: {},
+ columnOrder: ['metric', 'newStatic'],
+ columns: {
+ ...state.layers.first.columns,
+ newStatic: {
+ dataType: 'number',
+ isBucketed: false,
+ label: 'Static value: 0',
+ operationType: 'static_value',
+ params: { value: 0 },
+ references: [],
+ scale: 'ratio',
+ },
+ },
+ },
+ },
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
index 6a45e3c987f3..2138b06a4c34 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx
@@ -44,7 +44,7 @@ import {
import { isDraggedField, normalizeOperationDataType } from './utils';
import { LayerPanel } from './layerpanel';
-import { IndexPatternColumn, getErrorMessages } from './operations';
+import { IndexPatternColumn, getErrorMessages, insertNewColumn } from './operations';
import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
@@ -192,6 +192,27 @@ export function getIndexPatternDatasource({
});
},
+ initializeDimension(state, layerId, { columnId, groupId, label, dataType, staticValue }) {
+ const indexPattern = state.indexPatterns[state.layers[layerId]?.indexPatternId];
+ if (staticValue == null) {
+ return state;
+ }
+ return mergeLayer({
+ state,
+ layerId,
+ newLayer: insertNewColumn({
+ layer: state.layers[layerId],
+ op: 'static_value',
+ columnId,
+ field: undefined,
+ indexPattern,
+ visualizationGroups: [],
+ initialParams: { params: { value: staticValue } },
+ targetGroup: groupId,
+ }),
+ });
+ },
+
toExpression: (state, layerId) => toExpression(state, layerId, uiSettings),
renderDataPanel(
@@ -404,9 +425,14 @@ export function getIndexPatternDatasource({
},
};
},
- getDatasourceSuggestionsForField(state, draggedField) {
+ getDatasourceSuggestionsForField(state, draggedField, filterLayers) {
return isDraggedField(draggedField)
- ? getDatasourceSuggestionsForField(state, draggedField.indexPatternId, draggedField.field)
+ ? getDatasourceSuggestionsForField(
+ state,
+ draggedField.indexPatternId,
+ draggedField.field,
+ filterLayers
+ )
: [];
},
getDatasourceSuggestionsFromCurrentState,
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx
index 4b8bbc09c679..a5d6db4be331 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx
@@ -1198,6 +1198,91 @@ describe('IndexPattern Data Source suggestions', () => {
})
);
});
+
+ it('should apply layers filter if passed and model the suggestion based on that', () => {
+ (generateId as jest.Mock).mockReturnValue('newid');
+ const initialState = stateWithNonEmptyTables();
+
+ const modifiedState: IndexPatternPrivateState = {
+ ...initialState,
+ layers: {
+ thresholdLayer: {
+ indexPatternId: '1',
+ columnOrder: ['threshold'],
+ columns: {
+ threshold: {
+ dataType: 'number',
+ isBucketed: false,
+ label: 'Static Value: 0',
+ operationType: 'static_value',
+ params: { value: '0' },
+ references: [],
+ scale: 'ratio',
+ },
+ },
+ },
+ currentLayer: {
+ indexPatternId: '1',
+ columnOrder: ['metric', 'ref'],
+ columns: {
+ metric: {
+ label: '',
+ customLabel: true,
+ dataType: 'number',
+ isBucketed: false,
+ operationType: 'average',
+ sourceField: 'bytes',
+ },
+ ref: {
+ label: '',
+ customLabel: true,
+ dataType: 'number',
+ isBucketed: false,
+ operationType: 'cumulative_sum',
+ references: ['metric'],
+ },
+ },
+ },
+ },
+ };
+
+ const suggestions = getSuggestionSubset(
+ getDatasourceSuggestionsForField(
+ modifiedState,
+ '1',
+ documentField,
+ (layerId) => layerId !== 'thresholdLayer'
+ )
+ );
+ // should ignore the threshold layer
+ expect(suggestions).toContainEqual(
+ expect.objectContaining({
+ table: expect.objectContaining({
+ changeType: 'extended',
+ columns: [
+ {
+ columnId: 'ref',
+ operation: {
+ dataType: 'number',
+ isBucketed: false,
+ label: '',
+ scale: undefined,
+ },
+ },
+ {
+ columnId: 'newid',
+ operation: {
+ dataType: 'number',
+ isBucketed: false,
+ label: 'Count of records',
+ scale: 'ratio',
+ },
+ },
+ ],
+ }),
+ })
+ );
+ });
});
describe('finding the layer that is using the current index pattern', () => {
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts
index b0793bf912bb..0fe0ef617dc2 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts
@@ -95,10 +95,14 @@ function buildSuggestion({
export function getDatasourceSuggestionsForField(
state: IndexPatternPrivateState,
indexPatternId: string,
- field: IndexPatternField
+ field: IndexPatternField,
+ filterLayers?: (layerId: string) => boolean
): IndexPatternSuggestion[] {
const layers = Object.keys(state.layers);
- const layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId);
+ let layerIds = layers.filter((id) => state.layers[id].indexPatternId === indexPatternId);
+ if (filterLayers) {
+ layerIds = layerIds.filter(filterLayers);
+ }
if (layerIds.length === 0) {
// The field we're suggesting on does not match any existing layer.
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx
index c2ba893a9b90..499170349c3d 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx
@@ -355,6 +355,33 @@ describe('formula', () => {
references: [],
});
});
+
+ it('should move into Formula previous static_value operation', () => {
+ expect(
+ formulaOperation.buildColumn({
+ previousColumn: {
+ label: 'Static value: 0',
+ dataType: 'number',
+ isBucketed: false,
+ operationType: 'static_value',
+ references: [],
+ params: {
+ value: '0',
+ },
+ },
+ layer,
+ indexPattern,
+ })
+ ).toEqual({
+ label: '0',
+ dataType: 'number',
+ operationType: 'formula',
+ isBucketed: false,
+ scale: 'ratio',
+ params: { isFormulaBroken: false, formula: '0' },
+ references: [],
+ });
+ });
});
describe('regenerateLayerFromAst()', () => {
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts
index 589f547434b9..3db9ebc6f969 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/generate.ts
@@ -38,6 +38,11 @@ export function generateFormula(
previousFormula: string,
operationDefinitionMap: Record | undefined
) {
+ if (previousColumn.operationType === 'static_value') {
+ if (previousColumn.params && 'value' in previousColumn.params) {
+ return String(previousColumn.params.value); // make sure it's a string
+ }
+ }
if ('references' in previousColumn) {
const metric = layer.columns[previousColumn.references[0]];
if (metric && 'sourceField' in metric && metric.dataType === 'number') {
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx
index 45abbcd3d9cf..a39918369486 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/helpers.tsx
@@ -7,7 +7,7 @@
import { i18n } from '@kbn/i18n';
import { IndexPatternColumn, operationDefinitionMap } from '.';
-import { FieldBasedIndexPatternColumn } from './column_types';
+import { FieldBasedIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types';
import { IndexPattern } from '../../types';
export function getInvalidFieldMessage(
@@ -81,8 +81,7 @@ export function isValidNumber(
const inputValueAsNumber = Number(inputValue);
return (
inputValue !== '' &&
- inputValue !== null &&
- inputValue !== undefined &&
+ inputValue != null &&
!Number.isNaN(inputValueAsNumber) &&
Number.isFinite(inputValueAsNumber) &&
(!integer || Number.isInteger(inputValueAsNumber)) &&
@@ -91,7 +90,9 @@ export function isValidNumber(
);
}
-export function getFormatFromPreviousColumn(previousColumn: IndexPatternColumn | undefined) {
+export function getFormatFromPreviousColumn(
+ previousColumn: IndexPatternColumn | ReferenceBasedIndexPatternColumn | undefined
+) {
return previousColumn?.dataType === 'number' &&
previousColumn.params &&
'format' in previousColumn.params &&
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts
index 326b71f72c06..0212c73f4687 100644
--- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts
@@ -49,6 +49,7 @@ import {
formulaOperation,
FormulaIndexPatternColumn,
} from './formula';
+import { staticValueOperation, StaticValueIndexPatternColumn } from './static_value';
import { lastValueOperation, LastValueIndexPatternColumn } from './last_value';
import { FrameDatasourceAPI, OperationMetadata } from '../../../types';
import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types';
@@ -87,7 +88,8 @@ export type IndexPatternColumn =
| DerivativeIndexPatternColumn
| MovingAverageIndexPatternColumn
| MathIndexPatternColumn
- | FormulaIndexPatternColumn;
+ | FormulaIndexPatternColumn
+ | StaticValueIndexPatternColumn;
export type FieldBasedIndexPatternColumn = Extract;
@@ -119,6 +121,7 @@ export { CountIndexPatternColumn } from './count';
export { LastValueIndexPatternColumn } from './last_value';
export { RangeIndexPatternColumn } from './ranges';
export { FormulaIndexPatternColumn, MathIndexPatternColumn } from './formula';
+export { StaticValueIndexPatternColumn } from './static_value';
// List of all operation definitions registered to this data source.
// If you want to implement a new operation, add the definition to this array and
@@ -147,6 +150,7 @@ const internalOperationDefinitions = [
overallMinOperation,
overallMaxOperation,
overallAverageOperation,
+ staticValueOperation,
];
export { termsOperation } from './terms';
@@ -168,6 +172,7 @@ export {
overallMinOperation,
} from './calculations';
export { formulaOperation } from './formula/formula';
+export { staticValueOperation } from './static_value';
/**
* Properties passed to the operation-specific part of the popover editor
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx
new file mode 100644
index 000000000000..0a6620eecf30
--- /dev/null
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.test.tsx
@@ -0,0 +1,404 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { shallow, mount } from 'enzyme';
+import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
+import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
+import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
+import { createMockedIndexPattern } from '../../mocks';
+import { staticValueOperation } from './index';
+import { IndexPattern, IndexPatternLayer } from '../../types';
+import { StaticValueIndexPatternColumn } from './static_value';
+import { EuiFieldNumber } from '@elastic/eui';
+import { act } from 'react-dom/test-utils';
+
+jest.mock('lodash', () => {
+ const original = jest.requireActual('lodash');
+
+ return {
+ ...original,
+ debounce: (fn: unknown) => fn,
+ };
+});
+
+const uiSettingsMock = {} as IUiSettingsClient;
+
+const defaultProps = {
+ storage: {} as IStorageWrapper,
+ uiSettings: uiSettingsMock,
+ savedObjectsClient: {} as SavedObjectsClientContract,
+ dateRange: { fromDate: 'now-1d', toDate: 'now' },
+ data: dataPluginMock.createStartContract(),
+ http: {} as HttpSetup,
+ indexPattern: {
+ ...createMockedIndexPattern(),
+ hasRestrictions: false,
+ } as IndexPattern,
+ operationDefinitionMap: {},
+ isFullscreen: false,
+ toggleFullscreen: jest.fn(),
+ setIsCloseable: jest.fn(),
+ layerId: '1',
+};
+
+describe('static_value', () => {
+ let layer: IndexPatternLayer;
+
+ beforeEach(() => {
+ layer = {
+ indexPatternId: '1',
+ columnOrder: ['col1', 'col2'],
+ columns: {
+ col1: {
+ label: 'Top value of category',
+ dataType: 'string',
+ isBucketed: true,
+ operationType: 'terms',
+ params: {
+ orderBy: { type: 'alphabetical' },
+ size: 3,
+ orderDirection: 'asc',
+ },
+ sourceField: 'category',
+ },
+ col2: {
+ label: 'Static value: 23',
+ dataType: 'number',
+ isBucketed: false,
+ operationType: 'static_value',
+ references: [],
+ params: {
+ value: '23',
+ },
+ },
+ },
+ };
+ });
+
+ function getLayerWithStaticValue(newValue: string): IndexPatternLayer {
+ return {
+ ...layer,
+ columns: {
+ ...layer.columns,
+ col2: {
+ ...layer.columns.col2,
+ label: `Static value: ${newValue}`,
+ params: {
+ value: newValue,
+ },
+ } as StaticValueIndexPatternColumn,
+ },
+ };
+ }
+
+ describe('getDefaultLabel', () => {
+ it('should return the label for the given value', () => {
+ expect(
+ staticValueOperation.getDefaultLabel(
+ {
+ label: 'Static value: 23',
+ dataType: 'number',
+ isBucketed: false,
+ operationType: 'static_value',
+ references: [],
+ params: {
+ value: '23',
+ },
+ },
+ createMockedIndexPattern(),
+ layer.columns
+ )
+ ).toBe('Static value: 23');
+ });
+
+ it('should return the default label for non valid value', () => {
+ expect(
+ staticValueOperation.getDefaultLabel(
+ {
+ label: 'Static value',
+ dataType: 'number',
+ isBucketed: false,
+ operationType: 'static_value',
+ references: [],
+ params: {
+ value: '',
+ },
+ },
+ createMockedIndexPattern(),
+ layer.columns
+ )
+ ).toBe('Static value');
+ });
+ });
+
+ describe('getErrorMessage', () => {
+ it('should return no error for valid values', () => {
+ expect(
+ staticValueOperation.getErrorMessage!(
+ getLayerWithStaticValue('23'),
+ 'col2',
+ createMockedIndexPattern()
+ )
+ ).toBeUndefined();
+ // test for potential falsy value
+ expect(
+ staticValueOperation.getErrorMessage!(
+ getLayerWithStaticValue('0'),
+ 'col2',
+ createMockedIndexPattern()
+ )
+ ).toBeUndefined();
+ });
+
+ it('should return error for invalid values', () => {
+ for (const value of ['NaN', 'Infinity', 'string']) {
+ expect(
+ staticValueOperation.getErrorMessage!(
+ getLayerWithStaticValue(value),
+ 'col2',
+ createMockedIndexPattern()
+ )
+ ).toEqual(expect.arrayContaining([expect.stringMatching('is not a valid number')]));
+ }
+ });
+ });
+
+ describe('toExpression', () => {
+ it('should return a mathColumn operation with valid value', () => {
+ for (const value of ['23', '0', '-1']) {
+ expect(
+ staticValueOperation.toExpression(
+ getLayerWithStaticValue(value),
+ 'col2',
+ createMockedIndexPattern()
+ )
+ ).toEqual([
+ {
+ type: 'function',
+ function: 'mathColumn',
+ arguments: {
+ id: ['col2'],
+ name: [`Static value: ${value}`],
+ expression: [value],
+ },
+ },
+ ]);
+ }
+ });
+
+ it('should fallback to mapColumn for invalid value', () => {
+ for (const value of ['NaN', '', 'Infinity']) {
+ expect(
+ staticValueOperation.toExpression(
+ getLayerWithStaticValue(value),
+ 'col2',
+ createMockedIndexPattern()
+ )
+ ).toEqual([
+ {
+ type: 'function',
+ function: 'mapColumn',
+ arguments: {
+ id: ['col2'],
+ name: [`Static value`],
+ expression: ['100'],
+ },
+ },
+ ]);
+ }
+ });
+ });
+
+ describe('buildColumn', () => {
+ it('should set default static value', () => {
+ expect(
+ staticValueOperation.buildColumn({
+ indexPattern: createMockedIndexPattern(),
+ layer: { columns: {}, columnOrder: [], indexPatternId: '' },
+ })
+ ).toEqual({
+ label: 'Static value',
+ dataType: 'number',
+ operationType: 'static_value',
+ isBucketed: false,
+ scale: 'ratio',
+ params: { value: '100' },
+ references: [],
+ });
+ });
+
+ it('should merge a previousColumn', () => {
+ expect(
+ staticValueOperation.buildColumn({
+ indexPattern: createMockedIndexPattern(),
+ layer: { columns: {}, columnOrder: [], indexPatternId: '' },
+ previousColumn: {
+ label: 'Static value',
+ dataType: 'number',
+ operationType: 'static_value',
+ isBucketed: false,
+ scale: 'ratio',
+ params: { value: '23' },
+ references: [],
+ },
+ })
+ ).toEqual({
+ label: 'Static value: 23',
+ dataType: 'number',
+ operationType: 'static_value',
+ isBucketed: false,
+ scale: 'ratio',
+ params: { value: '23' },
+ references: [],
+ });
+ });
+
+ it('should create a static_value from passed arguments', () => {
+ expect(
+ staticValueOperation.buildColumn(
+ {
+ indexPattern: createMockedIndexPattern(),
+ layer: { columns: {}, columnOrder: [], indexPatternId: '' },
+ },
+ { value: '23' }
+ )
+ ).toEqual({
+ label: 'Static value: 23',
+ dataType: 'number',
+ operationType: 'static_value',
+ isBucketed: false,
+ scale: 'ratio',
+ params: { value: '23' },
+ references: [],
+ });
+ });
+
+ it('should prioritize passed arguments over previousColumn', () => {
+ expect(
+ staticValueOperation.buildColumn(
+ {
+ indexPattern: createMockedIndexPattern(),
+ layer: { columns: {}, columnOrder: [], indexPatternId: '' },
+ previousColumn: {
+ label: 'Static value',
+ dataType: 'number',
+ operationType: 'static_value',
+ isBucketed: false,
+ scale: 'ratio',
+ params: { value: '23' },
+ references: [],
+ },
+ },
+ { value: '53' }
+ )
+ ).toEqual({
+ label: 'Static value: 53',
+ dataType: 'number',
+ operationType: 'static_value',
+ isBucketed: false,
+ scale: 'ratio',
+ params: { value: '53' },
+ references: [],
+ });
+ });
+ });
+
+ describe('paramEditor', () => {
+ const ParamEditor = staticValueOperation.paramEditor!;
+ it('should render current static_value', () => {
+ const updateLayerSpy = jest.fn();
+ const instance = shallow(
+
+ );
+
+ const input = instance.find('[data-test-subj="lns-indexPattern-static_value-input"]');
+
+ expect(input.prop('value')).toEqual('23');
+ });
+
+ it('should update state on change', async () => {
+ const updateLayerSpy = jest.fn();
+ const instance = mount(
+
+ );
+
+ const input = instance
+ .find('[data-test-subj="lns-indexPattern-static_value-input"]')
+ .find(EuiFieldNumber);
+
+ await act(async () => {
+ input.prop('onChange')!({
+ currentTarget: { value: '27' },
+ } as React.ChangeEvent);
+ });
+
+ instance.update();
+
+ expect(updateLayerSpy.mock.calls[0]).toEqual([expect.any(Function)]);
+ // check that the result of the setter call is correct
+ expect(updateLayerSpy.mock.calls[0][0](layer)).toEqual({
+ ...layer,
+ columns: {
+ ...layer.columns,
+ col2: {
+ ...layer.columns.col2,
+ params: {
+ value: '27',
+ },
+ label: 'Static value: 27',
+ },
+ },
+ });
+ });
+
+ it('should not update on invalid input, but show invalid value locally', async () => {
+ const updateLayerSpy = jest.fn();
+ const instance = mount(
+
+ );
+
+ const input = instance
+ .find('[data-test-subj="lns-indexPattern-static_value-input"]')
+ .find(EuiFieldNumber);
+
+ await act(async () => {
+ input.prop('onChange')!({
+ currentTarget: { value: '' },
+ } as React.ChangeEvent);
+ });
+
+ instance.update();
+
+ expect(updateLayerSpy).not.toHaveBeenCalled();
+ expect(
+ instance
+ .find('[data-test-subj="lns-indexPattern-static_value-input"]')
+ .find(EuiFieldNumber)
+ .prop('value')
+ ).toEqual('');
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx
new file mode 100644
index 000000000000..a76c5f64d175
--- /dev/null
+++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/static_value.tsx
@@ -0,0 +1,222 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import React, { useCallback } from 'react';
+import { i18n } from '@kbn/i18n';
+import { EuiFieldNumber, EuiFormLabel, EuiSpacer } from '@elastic/eui';
+import { OperationDefinition } from './index';
+import { ReferenceBasedIndexPatternColumn } from './column_types';
+import type { IndexPattern } from '../../types';
+import { useDebouncedValue } from '../../../shared_components';
+import { getFormatFromPreviousColumn, isValidNumber } from './helpers';
+
+const defaultLabel = i18n.translate('xpack.lens.indexPattern.staticValueLabelDefault', {
+ defaultMessage: 'Static value',
+});
+
+const defaultValue = 100;
+
+function isEmptyValue(value: number | string | undefined) {
+ return value == null || value === '';
+}
+
+function ofName(value: number | string | undefined) {
+ if (isEmptyValue(value)) {
+ return defaultLabel;
+ }
+ return i18n.translate('xpack.lens.indexPattern.staticValueLabelWithValue', {
+ defaultMessage: 'Static value: {value}',
+ values: { value },
+ });
+}
+
+export interface StaticValueIndexPatternColumn extends ReferenceBasedIndexPatternColumn {
+ operationType: 'static_value';
+ params: {
+ value?: string;
+ format?: {
+ id: string;
+ params?: {
+ decimals: number;
+ };
+ };
+ };
+}
+
+export const staticValueOperation: OperationDefinition<
+ StaticValueIndexPatternColumn,
+ 'managedReference'
+> = {
+ type: 'static_value',
+ displayName: defaultLabel,
+ getDefaultLabel: (column) => ofName(column.params.value),
+ input: 'managedReference',
+ hidden: true,
+ getDisabledStatus(indexPattern: IndexPattern) {
+ return undefined;
+ },
+ getErrorMessage(layer, columnId) {
+ const column = layer.columns[columnId] as StaticValueIndexPatternColumn;
+
+ return !isValidNumber(column.params.value)
+ ? [
+ i18n.translate('xpack.lens.indexPattern.staticValueError', {
+ defaultMessage: 'The static value of {value} is not a valid number',
+ values: { value: column.params.value },
+ }),
+ ]
+ : undefined;
+ },
+ getPossibleOperation() {
+ return {
+ dataType: 'number',
+ isBucketed: false,
+ scale: 'ratio',
+ };
+ },
+ toExpression: (layer, columnId) => {
+ const currentColumn = layer.columns[columnId] as StaticValueIndexPatternColumn;
+ const params = currentColumn.params;
+ // TODO: improve this logic
+ const useDisplayLabel = currentColumn.label !== defaultLabel;
+ const label = isValidNumber(params.value)
+ ? useDisplayLabel
+ ? currentColumn.label
+ : params?.value ?? defaultLabel
+ : defaultLabel;
+
+ return [
+ {
+ type: 'function',
+ function: isValidNumber(params.value) ? 'mathColumn' : 'mapColumn',
+ arguments: {
+ id: [columnId],
+ name: [label || defaultLabel],
+ expression: [isValidNumber(params.value) ? params.value! : String(defaultValue)],
+ },
+ },
+ ];
+ },
+ buildColumn({ previousColumn, layer, indexPattern }, columnParams, operationDefinitionMap) {
+ const existingStaticValue =
+ previousColumn?.params &&
+ 'value' in previousColumn.params &&
+ isValidNumber(previousColumn.params.value)
+ ? previousColumn.params.value
+ : undefined;
+ const previousParams: StaticValueIndexPatternColumn['params'] = {
+ ...{ value: existingStaticValue },
+ ...getFormatFromPreviousColumn(previousColumn),
+ ...columnParams,
+ };
+ return {
+ label: ofName(previousParams.value),
+ dataType: 'number',
+ operationType: 'static_value',
+ isBucketed: false,
+ scale: 'ratio',
+ params: { ...previousParams, value: previousParams.value ?? String(defaultValue) },
+ references: [],
+ };
+ },
+ isTransferable: (column) => {
+ return true;
+ },
+ createCopy(layer, sourceId, targetId, indexPattern, operationDefinitionMap) {
+ const currentColumn = layer.columns[sourceId] as StaticValueIndexPatternColumn;
+ return {
+ ...layer,
+ columns: {
+ ...layer.columns,
+ [targetId]: { ...currentColumn },
+ },
+ };
+ },
+
+ paramEditor: function StaticValueEditor({
+ layer,
+ updateLayer,
+ currentColumn,
+ columnId,
+ activeData,
+ layerId,
+ indexPattern,
+ }) {
+ const onChange = useCallback(
+ (newValue) => {
+ // even if debounced it's triggering for empty string with the previous valid value
+ if (currentColumn.params.value === newValue) {
+ return;
+ }
+ // Because of upstream specific UX flows, we need fresh layer state here
+ // so need to use the updater pattern
+ updateLayer((newLayer) => {
+ const newColumn = newLayer.columns[columnId] as StaticValueIndexPatternColumn;
+ return {
+ ...newLayer,
+ columns: {
+ ...newLayer.columns,
+ [columnId]: {
+ ...newColumn,
+ label: newColumn?.customLabel ? newColumn.label : ofName(newValue),
+ params: {
+ ...newColumn.params,
+ value: newValue,
+ },
+ },
+ },
+ };
+ });
+ },
+ [columnId, updateLayer, currentColumn?.params?.value]
+ );
+
+ // Pick the data from the current activeData (to be used when the current operation is not static_value)
+ const activeDataValue =
+ activeData &&
+ activeData[layerId] &&
+ activeData[layerId]?.rows?.length === 1 &&
+ activeData[layerId].rows[0][columnId];
+
+ const fallbackValue =
+ currentColumn?.operationType !== 'static_value' && activeDataValue != null
+ ? activeDataValue
+ : String(defaultValue);
+
+ const { inputValue, handleInputChange } = useDebouncedValue(
+ {
+ value: currentColumn?.params?.value || fallbackValue,
+ onChange,
+ },
+ { allowFalsyValue: true }
+ );
+
+ const onChangeHandler = useCallback(
+ (e: React.ChangeEvent